项目文件夹

文件
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 行
14 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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
}