a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
284 lines
7.6 KiB
Go
284 lines
7.6 KiB
Go
package analysis
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// Step is one node in a discovered execution flow. Depth preserves the
|
|
// call-tree shape so the UI can render branches instead of flattening
|
|
// siblings into a false sequence: traceForward emits DFS preorder, and
|
|
// the parent of a step is the nearest preceding step with a smaller
|
|
// depth. Sibling order in the slice is the child-declaration order of
|
|
// the parent function.
|
|
type Step struct {
|
|
ID string `json:"id"`
|
|
Depth int `json:"depth"`
|
|
}
|
|
|
|
// Process represents a discovered execution flow in the codebase.
|
|
type Process struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"` // human-readable name
|
|
EntryPoint string `json:"entry_point"` // node ID of the entry function
|
|
Steps []Step `json:"steps"` // DFS preorder with call-tree depth
|
|
StepCount int `json:"step_count"`
|
|
Files []string `json:"files"` // unique files touched
|
|
Score float64 `json:"score"` // entry point confidence score
|
|
}
|
|
|
|
// ProcessResult is the output of process discovery.
|
|
type ProcessResult struct {
|
|
Processes []Process `json:"processes"`
|
|
NodeToProcs map[string][]string `json:"node_to_processes"` // nodeID → process IDs
|
|
}
|
|
|
|
// DiscoverProcesses finds execution flows by identifying entry points and tracing forward.
|
|
func DiscoverProcesses(g graph.Store) *ProcessResult {
|
|
nodes := g.AllNodes()
|
|
// Meta-less call-edge scan (see LightEdgeScanner): process discovery reads only
|
|
// endpoints off each call edge.
|
|
edges := graph.EdgesForKindsLight(g, graph.EdgeCalls)
|
|
|
|
// Build call graph adjacency (forward only)
|
|
callees := make(map[string][]string) // who does this function call?
|
|
callers := make(map[string][]string) // who calls this function?
|
|
|
|
for _, e := range edges {
|
|
if e.Kind == graph.EdgeCalls {
|
|
callees[e.From] = append(callees[e.From], e.To)
|
|
callers[e.To] = append(callers[e.To], e.From)
|
|
}
|
|
}
|
|
|
|
// Score each function/method as a potential entry point
|
|
type scored struct {
|
|
node *graph.Node
|
|
score float64
|
|
}
|
|
var candidates []scored
|
|
|
|
nodeMap := make(map[string]*graph.Node)
|
|
for _, n := range nodes {
|
|
nodeMap[n.ID] = n
|
|
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
|
|
continue
|
|
}
|
|
|
|
score := scoreEntryPoint(n, len(callees[n.ID]), len(callers[n.ID]))
|
|
if score > 0.5 {
|
|
candidates = append(candidates, scored{n, score})
|
|
}
|
|
}
|
|
|
|
// Sort by score descending
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
return candidates[i].score > candidates[j].score
|
|
})
|
|
|
|
// Trace forward from each entry point to build processes
|
|
result := &ProcessResult{
|
|
NodeToProcs: make(map[string][]string),
|
|
}
|
|
|
|
seen := make(map[string]bool) // avoid duplicate processes
|
|
for i, c := range candidates {
|
|
if i >= 50 { // cap at 50 processes
|
|
break
|
|
}
|
|
if seen[c.node.ID] {
|
|
continue
|
|
}
|
|
|
|
steps := traceForward(c.node.ID, callees, 15) // max depth 15
|
|
if len(steps) < 2 {
|
|
continue // not interesting
|
|
}
|
|
seen[c.node.ID] = true
|
|
|
|
fileSet := make(map[string]bool)
|
|
for _, s := range steps {
|
|
if n, ok := nodeMap[s.ID]; ok {
|
|
fileSet[n.FilePath] = true
|
|
}
|
|
}
|
|
files := make([]string, 0, len(fileSet))
|
|
for f := range fileSet {
|
|
files = append(files, f)
|
|
}
|
|
sort.Strings(files)
|
|
|
|
procID := fmt.Sprintf("process-%d", len(result.Processes))
|
|
proc := Process{
|
|
ID: procID,
|
|
Name: inferProcessName(c.node),
|
|
EntryPoint: c.node.ID,
|
|
Steps: steps,
|
|
StepCount: len(steps),
|
|
Files: files,
|
|
Score: c.score,
|
|
}
|
|
result.Processes = append(result.Processes, proc)
|
|
|
|
for _, s := range steps {
|
|
result.NodeToProcs[s.ID] = append(result.NodeToProcs[s.ID], procID)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func scoreEntryPoint(n *graph.Node, calleeCount, callerCount int) float64 {
|
|
if calleeCount == 0 {
|
|
return 0 // leaf functions are not entry points
|
|
}
|
|
|
|
// Base score: ratio of outgoing to incoming calls
|
|
base := float64(calleeCount) / (float64(callerCount) + 1.0)
|
|
|
|
// Name pattern multiplier
|
|
nameMult := namePatternMultiplier(n.Name, n.Language)
|
|
|
|
// Export/visibility multiplier
|
|
exportMult := 1.0
|
|
if isExportedForProcess(n) {
|
|
exportMult = 1.5
|
|
}
|
|
|
|
// Low caller count bonus (true entry points have few callers)
|
|
callerMult := 1.0
|
|
if callerCount == 0 {
|
|
callerMult = 2.0
|
|
} else if callerCount <= 2 {
|
|
callerMult = 1.3
|
|
}
|
|
|
|
// Framework entry points stamped by the entrypoints detector (Spring
|
|
// handlers, JAX-RS resources, annotated servlets, the JVM main, …)
|
|
// are invoked by a runtime, not application code — the most reliable
|
|
// process roots. Test fixtures are stamped too (so dead-code keeps
|
|
// them live) but are noise as top-level processes, so skip the boost
|
|
// for them.
|
|
entryMult := 1.0
|
|
if ep, _ := n.Meta["entry_point"].(bool); ep {
|
|
if kind, _ := n.Meta["entry_point_kind"].(string); !strings.HasPrefix(kind, "junit:") {
|
|
entryMult = 2.0
|
|
}
|
|
}
|
|
|
|
return base * nameMult * exportMult * callerMult * entryMult
|
|
}
|
|
|
|
// isExportedForProcess mirrors the dead-code visibility logic: for
|
|
// keyword-visibility languages (Java) it trusts the recorded modifier
|
|
// so a private helper isn't handed the public-API entry-point boost.
|
|
func isExportedForProcess(n *graph.Node) bool {
|
|
if n.Language == "java" {
|
|
if v, ok := n.Meta["visibility"].(string); ok && v != "" {
|
|
return v == "public" || v == "protected"
|
|
}
|
|
}
|
|
return isExported(n.Name, n.Language)
|
|
}
|
|
|
|
func namePatternMultiplier(name, lang string) float64 {
|
|
lower := strings.ToLower(name)
|
|
|
|
// High-value entry point patterns
|
|
entryPatterns := []string{
|
|
"main", "init", "run", "start", "serve", "listen",
|
|
"handle", "handler", "controller", "middleware",
|
|
"route", "endpoint", "dispatch",
|
|
}
|
|
for _, p := range entryPatterns {
|
|
if strings.HasPrefix(lower, p) || strings.HasSuffix(lower, p) {
|
|
return 1.5
|
|
}
|
|
}
|
|
|
|
// Go-specific
|
|
if lang == "go" {
|
|
if strings.HasPrefix(name, "New") || strings.HasPrefix(name, "Serve") {
|
|
return 1.3
|
|
}
|
|
if strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Benchmark") {
|
|
return 0.3
|
|
}
|
|
}
|
|
|
|
// Utility patterns (deprioritize)
|
|
utilPatterns := []string{
|
|
"get", "set", "is", "has", "to", "from", "parse",
|
|
"format", "validate", "helper", "util", "string",
|
|
}
|
|
for _, p := range utilPatterns {
|
|
if strings.HasPrefix(lower, p) {
|
|
return 0.5
|
|
}
|
|
}
|
|
|
|
return 1.0
|
|
}
|
|
|
|
func isExported(name, lang string) bool {
|
|
if lang == "go" {
|
|
return len(name) > 0 && name[0] >= 'A' && name[0] <= 'Z'
|
|
}
|
|
// For other languages, assume exported if not starting with underscore
|
|
return !strings.HasPrefix(name, "_")
|
|
}
|
|
|
|
func traceForward(startID string, callees map[string][]string, maxDepth int) []Step {
|
|
var result []Step
|
|
visited := make(map[string]bool)
|
|
|
|
var dfs func(id string, depth int)
|
|
dfs = func(id string, depth int) {
|
|
if visited[id] || depth > maxDepth {
|
|
return
|
|
}
|
|
visited[id] = true
|
|
result = append(result, Step{ID: id, Depth: depth})
|
|
|
|
for _, callee := range callees[id] {
|
|
if !visited[callee] {
|
|
dfs(callee, depth+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
dfs(startID, 0)
|
|
return result
|
|
}
|
|
|
|
func inferProcessName(n *graph.Node) string {
|
|
name := n.Name
|
|
lower := strings.ToLower(name)
|
|
|
|
// Try to extract a descriptive name
|
|
if lower == "main" {
|
|
return "main execution"
|
|
}
|
|
if strings.HasPrefix(lower, "handle") {
|
|
subject := strings.TrimPrefix(name, "Handle")
|
|
subject = strings.TrimPrefix(subject, "handle")
|
|
if subject != "" {
|
|
return strings.ToLower(subject[:1]) + subject[1:] + " handling"
|
|
}
|
|
}
|
|
if strings.HasPrefix(lower, "serve") {
|
|
return name + " flow"
|
|
}
|
|
if strings.HasPrefix(name, "New") {
|
|
return strings.TrimPrefix(name, "New") + " initialization"
|
|
}
|
|
if strings.HasPrefix(name, "Test") {
|
|
return name
|
|
}
|
|
|
|
return name + " flow"
|
|
}
|