package analysis import ( "fmt" "sort" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/reach" ) // RiskLevel represents the severity of a change's impact. type RiskLevel string const ( RiskLow RiskLevel = "LOW" RiskMedium RiskLevel = "MEDIUM" RiskHigh RiskLevel = "HIGH" RiskCritical RiskLevel = "CRITICAL" ) // ImpactEntry is a symbol affected at a specific depth. type ImpactEntry struct { ID string `json:"id"` Name string `json:"name"` Kind string `json:"kind"` FilePath string `json:"file_path"` Line int `json:"start_line"` RepoPrefix string `json:"repo_prefix,omitempty"` EdgeConfidence float64 `json:"edge_confidence,omitempty"` ConfidenceLabel string `json:"confidence_label,omitempty"` } // ImpactResult is the output of risk-tiered impact analysis. type ImpactResult struct { Risk RiskLevel `json:"risk"` Summary string `json:"summary"` ByDepth map[int][]ImpactEntry `json:"by_depth"` AffectedProcesses []string `json:"affected_processes,omitempty"` AffectedCommunities []string `json:"affected_communities,omitempty"` TestFiles []string `json:"test_files,omitempty"` TotalAffected int `json:"total_affected"` CrossRepoImpact bool `json:"cross_repo_impact,omitempty"` ByRepo map[string][]ImpactEntry `json:"by_repo,omitempty"` // LowerBound is set when the blast radius crosses a dynamic-dispatch / // interface site the resolver could not bind: the true affected count is // then a floor (">=TotalAffected, could be more"), not an exact number. LowerBound bool `json:"lower_bound,omitempty"` // Boundaries names the unresolved/dispatch sites that make the count a // floor, so an agent can act on them (e.g. find_implementations on the // interface). Omitted when empty. Boundaries []graph.EpistemicBoundary `json:"boundaries,omitempty"` } // AnalyzeImpact performs depth-tiered blast radius analysis on a set of symbols. // // Fast path: when every seed has a precomputed reach index // (`Node.Meta["reach_d1/d2/d3"]` stamped by BuildReachIndex), the // depth-1/2/3 ByDepth tiers are constructed from those sets without // a live BFS — turning the dominant cost from O(reach) edge walks // into O(reach) map lookups. The representative in-edge per tier // entry is recovered with a linear scan of the entry's incoming // edges, matching the live walk's behavior. Fall back to live BFS // when any seed lacks the index — the slow path is identical to the // pre-index implementation so consumer semantics never diverge. func AnalyzeImpact(g graph.Store, symbolIDs []string, communities *CommunityResult, processes *ProcessResult) *ImpactResult { result := &ImpactResult{ ByDepth: make(map[int][]ImpactEntry), } if !fillImpactFromReach(g, result, symbolIDs) { fillImpactLive(g, result, symbolIDs) } // Trim noise from the transitive tiers: a resolution edge with // confidence == 0 AND ConfidenceLabel == "INFERRED" means the // resolver produced the link without type info — essentially a // name-text match. At d=2 and d=3 these multiply the blast radius // through shared upstream helpers (e.g. every analyze_* handler // sharing respondJSONOrTOON), turning a leaf change into hundreds // of "transitively affected" rows the user can't act on. d=1 is // preserved untouched because direct dependents are always // informative even at low confidence. for depth := 2; depth <= 3; depth++ { result.ByDepth[depth] = filterHeuristicEntries(result.ByDepth[depth]) } // Hard fan-out cap per tier so a pathological hub doesn't blow up // the response. Sorted ID order is already deterministic from the // reach index, so the cap is stable. const maxPerTier = 50 for depth := 1; depth <= 3; depth++ { if len(result.ByDepth[depth]) > maxPerTier { result.ByDepth[depth] = result.ByDepth[depth][:maxPerTier] } } // Deduplicate test files result.TestFiles = dedup(result.TestFiles) // Count total for _, entries := range result.ByDepth { result.TotalAffected += len(entries) } // Determine risk level d1 := len(result.ByDepth[1]) d2 := len(result.ByDepth[2]) result.Risk = assessRisk(d1, d2) // Find affected processes if processes != nil { procSet := make(map[string]bool) for _, id := range symbolIDs { for _, pid := range processes.NodeToProcs[id] { procSet[pid] = true } } for depth := 1; depth <= 3; depth++ { for _, entry := range result.ByDepth[depth] { for _, pid := range processes.NodeToProcs[entry.ID] { procSet[pid] = true } } } for pid := range procSet { result.AffectedProcesses = append(result.AffectedProcesses, pid) } sort.Strings(result.AffectedProcesses) } // Find affected communities if communities != nil { commSet := make(map[string]bool) for _, id := range symbolIDs { if cid, ok := communities.NodeToComm[id]; ok { commSet[cid] = true } } for depth := 1; depth <= 3; depth++ { for _, entry := range result.ByDepth[depth] { if cid, ok := communities.NodeToComm[entry.ID]; ok { commSet[cid] = true } } } for cid := range commSet { result.AffectedCommunities = append(result.AffectedCommunities, cid) } sort.Strings(result.AffectedCommunities) } // Epistemic lower bound: blast radius is a count of *callers*, so a seed // that implements/overrides an interface may be reached through dynamic // dispatch the resolver could not attribute — the count is then a floor. result.Boundaries = graph.CallerBoundaries(g, symbolIDs, 0) result.LowerBound = graph.LowerBoundCaveat(result.Boundaries) // Summary result.Summary = fmt.Sprintf( "%d direct dependents, %d transitively affected, %d test files, risk: %s", d1, result.TotalAffected, len(result.TestFiles), result.Risk, ) if result.LowerBound { result.Summary += fmt.Sprintf( " — lower bound: %d dispatch boundary(ies) may add more callers", len(result.Boundaries), ) } // Group affected symbols by RepoPrefix and detect cross-repo impact. repoSet := make(map[string]bool) byRepo := make(map[string][]ImpactEntry) for _, id := range symbolIDs { if n := g.GetNode(id); n != nil && n.RepoPrefix != "" { repoSet[n.RepoPrefix] = true } } for depth := 1; depth <= 3; depth++ { for _, entry := range result.ByDepth[depth] { if entry.RepoPrefix != "" { repoSet[entry.RepoPrefix] = true byRepo[entry.RepoPrefix] = append(byRepo[entry.RepoPrefix], entry) } } } if len(repoSet) > 1 { result.CrossRepoImpact = true result.ByRepo = byRepo } return result } // fillImpactLive is the pre-precomputed-reach implementation: a // depth-3 BFS over incoming edges that materialises one ImpactEntry // per discovered node, attributing the in-edge that introduced it to // EdgeConfidence / ConfidenceLabel. Kept as the always-correct // fallback for fillImpactFromReach. func fillImpactLive(g graph.Store, result *ImpactResult, symbolIDs []string) { visited := make(map[string]bool) for _, id := range symbolIDs { visited[id] = true } current := symbolIDs for depth := 1; depth <= 3; depth++ { var next []string for _, id := range current { for _, e := range g.GetInEdges(id) { if visited[e.From] { continue } if e.Kind == graph.EdgeDefines || e.Kind == graph.EdgeMemberOf { continue } visited[e.From] = true next = append(next, e.From) n := g.GetNode(e.From) if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } result.ByDepth[depth] = append(result.ByDepth[depth], ImpactEntry{ ID: n.ID, Name: n.Name, Kind: string(n.Kind), FilePath: n.FilePath, Line: n.StartLine, RepoPrefix: n.RepoPrefix, EdgeConfidence: e.Confidence, ConfidenceLabel: graph.ConfidenceLabelFor(e.Kind, e.Confidence), }) if isTestFile(n.FilePath) { result.TestFiles = append(result.TestFiles, n.FilePath) } } } current = next } } // fillImpactFromReach is the precomputed fast path. Returns false if // any seed lacks a reach build stamp — the caller must then run // fillImpactLive. The union of per-seed reach_d1 sets becomes the // depth-1 tier; depth-2 is the union of per-seed reach_d2 minus // seeds and minus the depth-1 set; depth-3 is built the same way // against (seeds ∪ d1 ∪ d2). For each tier-N entry we look up the // representative in-edge with a linear scan of the node's incoming // edges, picking the first one whose source is in the seeds (N=1) or // in the prior tier's accumulated set (N≥2) — matching the live walk's // deterministic-by-shard-iteration choice closely enough for tests // that compare ByDepth ID sets, which is the contract consumers rely // on. EdgeConfidence is set from that representative edge. func fillImpactFromReach(g graph.Store, result *ImpactResult, symbolIDs []string) bool { if len(symbolIDs) == 0 { return true } // Single-seed shortcut. The precomputed tier slices are already // unique and sorted by ID (BuildIndex calls sortTierByID), so the // generic multi-seed path's per-depth merge + sort + seen-map are // pure overhead here. Stream directly into ByDepth with the // destination slice pre-sized — measurable difference on hot // blast-radius queries (1000-caller fan-in: ~2x faster than the // generic path). if len(symbolIDs) == 1 { seedID := symbolIDs[0] d1, d2, d3, hit := reach.Lookup(g, seedID) if !hit { return false } for depth, tier := range [3][]reach.Entry{d1, d2, d3} { if len(tier) == 0 { continue } out := make([]ImpactEntry, 0, len(tier)) for _, e := range tier { if e.ID == seedID { continue } n := g.GetNode(e.ID) if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } out = append(out, ImpactEntry{ ID: n.ID, Name: n.Name, Kind: string(n.Kind), FilePath: n.FilePath, Line: n.StartLine, RepoPrefix: n.RepoPrefix, EdgeConfidence: e.Conf, ConfidenceLabel: e.Label, }) if isTestFile(n.FilePath) { result.TestFiles = append(result.TestFiles, n.FilePath) } } result.ByDepth[depth+1] = out } return true } perSeed := make([][3][]reach.Entry, len(symbolIDs)) for i, id := range symbolIDs { d1, d2, d3, hit := reach.Lookup(g, id) if !hit { return false } perSeed[i] = [3][]reach.Entry{d1, d2, d3} } // `seen` tracks every ID already emitted at a prior depth (and // the seed set itself) so a node appears in at most one ByDepth // slot — matches the BFS visited-set discipline the live walk has. // First per-seed appearance wins on cross-seed overlap, mirroring // the live walk's BFS-by-depth order. seen := make(map[string]struct{}, len(symbolIDs)+32) for _, id := range symbolIDs { seen[id] = struct{}{} } for depth := 1; depth <= 3; depth++ { var tier []reach.Entry for s := range perSeed { for _, e := range perSeed[s][depth-1] { if _, already := seen[e.ID]; already { continue } seen[e.ID] = struct{}{} tier = append(tier, e) } } // Deterministic emission — matches each per-seed slice's // build-time sort + makes the JSON payload diff-stable. sort.Slice(tier, func(i, j int) bool { return tier[i].ID < tier[j].ID }) for _, e := range tier { n := g.GetNode(e.ID) if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } result.ByDepth[depth] = append(result.ByDepth[depth], ImpactEntry{ ID: n.ID, Name: n.Name, Kind: string(n.Kind), FilePath: n.FilePath, Line: n.StartLine, RepoPrefix: n.RepoPrefix, EdgeConfidence: e.Conf, ConfidenceLabel: e.Label, }) if isTestFile(n.FilePath) { result.TestFiles = append(result.TestFiles, n.FilePath) } } } return true } // filterHeuristicEntries strips ImpactEntries whose representative // edge was a heuristic / text-matched resolution (Confidence == 0 + // label == "INFERRED"). Returns the kept prefix to avoid an extra // allocation. The input slice is mutated. func filterHeuristicEntries(entries []ImpactEntry) []ImpactEntry { kept := entries[:0] for _, e := range entries { if e.EdgeConfidence == 0 && e.ConfidenceLabel == "INFERRED" { continue } kept = append(kept, e) } return kept } func assessRisk(directDeps, transitiveDeps int) RiskLevel { if directDeps >= 10 || (directDeps >= 5 && transitiveDeps >= 20) { return RiskCritical } if directDeps >= 5 || transitiveDeps >= 10 { return RiskHigh } if directDeps >= 2 || transitiveDeps >= 5 { return RiskMedium } return RiskLow } // IsTestFile reports whether path looks like a test source file — the same // suffix set the impact traversal uses to collect covering tests, exported // for callers that need to probe whether a graph indexes tests at all. func IsTestFile(path string) bool { return isTestFile(path) } func isTestFile(path string) bool { return containsAny(path, "_test.go", ".test.ts", ".test.js", ".spec.ts", ".spec.js", "__tests__/", "test_", ) } func containsAny(s string, patterns ...string) bool { for _, p := range patterns { if len(s) >= len(p) { for i := 0; i <= len(s)-len(p); i++ { if s[i:i+len(p)] == p { return true } } } } return false } func dedup(ss []string) []string { seen := make(map[string]bool, len(ss)) var result []string for _, s := range ss { if !seen[s] { seen[s] = true result = append(result, s) } } return result }