Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

419 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}