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
419 lines
14 KiB
Go
419 lines
14 KiB
Go
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
|
||
}
|