项目文件夹

文件
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

528 行
19 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// Package reach precomputes per-depth incoming-reachability sets on
// every impact-seed node so blast-radius queries (AnalyzeImpact,
// explain_change_impact, simulate_chain step-impact, prompt
// SafeToChange / PreCommit, diff_context) answer in O(seeds × reach)
// map lookups instead of a live BFS.
//
// The package depends only on internal/graph; it is imported by the
// indexer (build site) and the analysis package (consumer) so the
// import graph stays acyclic — analysis already imports indexer in
// its bench tests.
package reach
import (
"context"
"sort"
"sync/atomic"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/progress"
)
// Reachability index keys. Each value is a []string of node IDs that
// can reach the carrier node via incoming edges within the named
// number of hops. Tiers are per-depth (not cumulative) so they map
// 1:1 onto AnalyzeImpact's ByDepth tiers.
//
// Parallel `*_conf` and `*_label` keys carry the representative
// in-edge's Confidence and ConfidenceLabel for each ID, indexed by
// position. They turn the fast path into a pure lookup — no
// GetInEdges calls at query time — so a precomputed AnalyzeImpact
// stays sub-ms even on graphs with high fan-in.
//
// Stored on Node.Meta — gob-serialized into the daemon snapshot so
// warm starts keep O(1) impact lookups without paying the build cost.
const (
MetaReachD1 = "reach_d1"
MetaReachD2 = "reach_d2"
MetaReachD3 = "reach_d3"
MetaReachD1Conf = "reach_d1_conf"
MetaReachD2Conf = "reach_d2_conf"
MetaReachD3Conf = "reach_d3_conf"
MetaReachD1Label = "reach_d1_label"
MetaReachD2Label = "reach_d2_label"
MetaReachD3Label = "reach_d3_label"
// MetaReachBuild is a monotonic build-generation counter stamped
// on every node the indexer touched in the most recent reach pass.
// Consumers compare it against the graph-level counter on
// AnalyzeImpact entry to decide whether to trust the precomputed
// sets or fall back to a live walk. The Meta value is a uint64.
MetaReachBuild = "reach_build"
)
// ReachableEdge returns true when an edge participates in the impact
// graph. Mirrors AnalyzeImpact's filter exactly so the precomputed
// sets and the live walk agree on membership. Exported so the
// AnalyzeImpact live-walk path can share the same filter and tests
// can assert filter parity across the two code paths.
func ReachableEdge(k graph.EdgeKind) bool {
return k != graph.EdgeDefines && k != graph.EdgeMemberOf
}
// ImpactSeedKind returns true for node kinds that are sensible impact
// seeds — the symbols a developer actually changes. Files, imports,
// parameters, and similar wiring kinds carry no useful blast radius,
// so we skip them to keep the index lean.
func ImpactSeedKind(k graph.NodeKind) bool {
switch k {
case graph.KindFunction, graph.KindMethod,
graph.KindType, graph.KindInterface,
graph.KindField, graph.KindEnumMember,
graph.KindConstant, graph.KindVariable:
return true
}
return false
}
// Stats reports the work BuildIndex did.
type Stats struct {
NodesIndexed int // nodes that received reach_d* entries
EntriesD1 int // total reach_d1 IDs across all indexed nodes
EntriesD2 int // total reach_d2 IDs
EntriesD3 int // total reach_d3 IDs
Build uint64 // generation tag stamped on every indexed node
}
// buildCounter is a process-wide monotonic generation counter used to
// invalidate cached reach sets across snapshot reloads and
// incremental rebuilds. Bumped on every BuildIndex / ClearIndex call.
var buildCounter uint64
// BuildIndex precomputes per-depth incoming reachability sets for
// every impact-seed node in g and stores them under Node.Meta as
// []string slices keyed reach_d1 / reach_d2 / reach_d3. Tiers are
// per-depth (a node appears in at most one tier per seed). The build
// generation is stamped under MetaReachBuild so consumers can detect
// stale entries after partial rebuilds.
//
// Cost: O(N · E_avg) where E_avg is the average reach-3 fan-in
// (typically <200 nodes per seed on real call graphs). Empirically
// completes in well under a second on 50k-node graphs. Run after all
// graph-shaping passes settle (resolver, semantic enrichment, cross-
// repo edges, gRPC stub resolution).
//
// Safe to call repeatedly: existing reach_d* entries are overwritten
// and the build counter advances each time so any consumer that read
// an entry from a prior generation will fall back to a live walk.
func BuildIndex(g graph.Store) *Stats {
return BuildIndexCtx(context.Background(), g)
}
// BuildIndexCtx is BuildIndex with intra-stage progress reporting.
// Pulls a progress.Reporter from ctx (no-op when none is attached) and
// emits per-seed progress every reachProgressEvery seeds — the pass
// otherwise looks hung from the outside, since "reach" is one of the
// longest stages on monorepo-scale graphs (~200 s on k8s with 150 k
// impact seeds). Pure operator-visibility instrumentation: the per-
// report call is cheap (no I/O when the reporter is the default no-op).
func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats {
if g == nil {
return &Stats{}
}
reporter := progress.FromContext(ctx)
mu := g.ResolveMutex()
mu.Lock()
defer mu.Unlock()
build := atomic.AddUint64(&buildCounter, 1)
stats := &Stats{Build: build}
nodes := g.AllNodes()
// Sort by ID so the deterministic iteration order produces stable
// reach slices — important for snapshot determinism and for tests
// that compare reach payloads across runs.
sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID })
// Pre-count impact seeds so the progress denominator is real, not
// the total node count (the loop skips ~80% of nodes — files,
// imports, params, vars, …).
var seedTotal int
for _, n := range nodes {
if n != nil && ImpactSeedKind(n.Kind) {
seedTotal++
}
}
reporter.Report("reachability index", 0, seedTotal)
const reachProgressEvery = 1000
seedsDone := 0
// Collect the seed nodes we stamp so we can persist the Meta back
// through the store in one batch at the end. On the in-memory
// backend the in-place stamp already persists (n is canonical); on
// disk backends n is a GetNode reconstruction, so without
// the write-back the whole reach index would be computed and then
// thrown away. Mirrors the per-seed AddNode in Lookup's slow path.
stamped := make([]*graph.Node, 0, seedTotal)
for _, n := range nodes {
if n == nil || !ImpactSeedKind(n.Kind) {
continue
}
tiers := compute(g, n.ID)
if n.Meta == nil {
n.Meta = make(map[string]any, 10)
}
// Always stamp the build counter so a node with no reach
// (sink with zero callers) still proves "we tried" — without
// this, lookups for sink nodes would fall back to a live walk
// on every call.
n.Meta[MetaReachBuild] = build
setOrDeleteStrings(n.Meta, MetaReachD1, tiers[0].IDs)
setOrDeleteStrings(n.Meta, MetaReachD2, tiers[1].IDs)
setOrDeleteStrings(n.Meta, MetaReachD3, tiers[2].IDs)
setOrDeleteFloats(n.Meta, MetaReachD1Conf, tiers[0].Conf)
setOrDeleteFloats(n.Meta, MetaReachD2Conf, tiers[1].Conf)
setOrDeleteFloats(n.Meta, MetaReachD3Conf, tiers[2].Conf)
setOrDeleteStrings(n.Meta, MetaReachD1Label, tiers[0].Labels)
setOrDeleteStrings(n.Meta, MetaReachD2Label, tiers[1].Labels)
setOrDeleteStrings(n.Meta, MetaReachD3Label, tiers[2].Labels)
stamped = append(stamped, n)
stats.NodesIndexed++
stats.EntriesD1 += len(tiers[0].IDs)
stats.EntriesD2 += len(tiers[1].IDs)
stats.EntriesD3 += len(tiers[2].IDs)
seedsDone++
if seedsDone%reachProgressEvery == 0 {
reporter.Report("reachability index", seedsDone, seedTotal)
}
}
// Persist every stamped node's Meta back through the store in one
// batch (no-op-ish on the in-memory backend, the durable write on
// disk backends). AddBatch with no edges only upserts the nodes.
if len(stamped) > 0 {
g.AddBatch(stamped, nil)
}
reporter.Report("reachability index", seedsDone, seedTotal)
return stats
}
// tier holds the per-depth precomputed payload: a parallel triple of
// (ID, edge-confidence, edge-confidence-label) so the fast path can
// hydrate an ImpactEntry without a single GetInEdges call at query
// time. Sorted by ID for stable snapshot output and test parity.
type tier struct {
IDs []string
Conf []float64
Labels []string
}
// setOrDeleteStrings keeps Meta lean — empty tiers are removed rather
// than stored as []string{} so cold-start gob payloads stay small and
// downstream code can rely on "key absent" == "no callers at this tier".
func setOrDeleteStrings(m map[string]any, key string, value []string) {
if len(value) == 0 {
delete(m, key)
return
}
m[key] = value
}
// setOrDeleteFloats mirrors setOrDeleteStrings for the parallel
// confidence arrays.
func setOrDeleteFloats(m map[string]any, key string, value []float64) {
if len(value) == 0 {
delete(m, key)
return
}
m[key] = value
}
// compute walks incoming edges from seed up to depth 3 and returns
// per-depth tiers carrying every ID encountered plus the
// representative in-edge's confidence + label. Each ID appears in at
// most one tier (BFS visited set is shared across depths). Edges are
// filtered with ReachableEdge so the result matches AnalyzeImpact;
// file / import nodes are walked through for fan-out but excluded
// from the tier slices.
func compute(g graph.Store, seedID string) [3]tier {
var result [3]tier
visited := map[string]struct{}{seedID: {}}
current := []string{seedID}
for depth := 1; depth <= 3 && len(current) > 0; depth++ {
// Batch the whole BFS level's incoming-edge fetch into one
// backend round-trip. The per-node g.GetInEdges(id) form issued
// one query per node on disk backends — an
// O(reachable-nodes) query storm that turned a single
// AnalyzeImpact live walk into a multi-minute (timeout) call on
// a disk backend. GetInEdgesByNodeIDs collapses it to one query per depth.
inEdges := g.GetInEdgesByNodeIDs(current)
// First pass: discover this level's new From-nodes in
// deterministic (current-order, edge-order) order, recording the
// representative in-edge for each.
type cand struct {
from string
conf float64
kind graph.EdgeKind
}
var next []string
var cands []cand
for _, id := range current {
for _, e := range inEdges[id] {
if !ReachableEdge(e.Kind) {
continue
}
if _, seen := visited[e.From]; seen {
continue
}
visited[e.From] = struct{}{}
next = append(next, e.From)
cands = append(cands, cand{from: e.From, conf: e.Confidence, kind: e.Kind})
}
}
// Batch the node-kind lookups too — the original called
// g.GetNode(e.From) once per discovered node (a second per-node
// query storm on disk backends). File / import nodes are still
// walked through for fan-out (they stay in `next`) but excluded
// from the result tiers, exactly as before.
ids := make([]string, len(cands))
for i := range cands {
ids[i] = cands[i].from
}
nodes := g.GetNodesByIDs(ids)
slot := depth - 1
for _, c := range cands {
n := nodes[c.from]
if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
result[slot].IDs = append(result[slot].IDs, c.from)
result[slot].Conf = append(result[slot].Conf, c.conf)
result[slot].Labels = append(result[slot].Labels,
graph.ConfidenceLabelFor(c.kind, c.conf))
}
current = next
}
for i := range result {
sortTierByID(&result[i])
}
return result
}
// sortTierByID sorts a tier's parallel arrays in lock-step by ID so
// repeated builds produce identical snapshots and consumers can
// binary-search for membership.
func sortTierByID(t *tier) {
n := len(t.IDs)
if n <= 1 {
return
}
idx := make([]int, n)
for i := range idx {
idx[i] = i
}
sort.Slice(idx, func(a, b int) bool { return t.IDs[idx[a]] < t.IDs[idx[b]] })
ids := make([]string, n)
conf := make([]float64, n)
labels := make([]string, n)
for newPos, oldPos := range idx {
ids[newPos] = t.IDs[oldPos]
conf[newPos] = t.Conf[oldPos]
labels[newPos] = t.Labels[oldPos]
}
t.IDs = ids
t.Conf = conf
t.Labels = labels
}
// ClearIndex removes reach_d* and reach_build entries from every node
// and bumps the build counter so any cached lookups dated to a prior
// generation are invalidated. Use when the graph topology has shifted
// so far that a full rebuild is cheaper than incremental invalidation.
func ClearIndex(g graph.Store) {
if g == nil {
return
}
mu := g.ResolveMutex()
mu.Lock()
defer mu.Unlock()
atomic.AddUint64(&buildCounter, 1)
for _, n := range g.AllNodes() {
if n == nil || n.Meta == nil {
continue
}
for _, k := range []string{
MetaReachD1, MetaReachD2, MetaReachD3,
MetaReachD1Conf, MetaReachD2Conf, MetaReachD3Conf,
MetaReachD1Label, MetaReachD2Label, MetaReachD3Label,
MetaReachBuild,
} {
delete(n.Meta, k)
}
}
}
// Entry is one precomputed reach record: a node ID and the
// representative in-edge's confidence + confidence-label so the
// AnalyzeImpact fast path can hydrate an ImpactEntry with zero
// GetInEdges calls.
type Entry struct {
ID string
Conf float64
Label string
}
// Lookup returns the per-depth reach for seedID. On a fresh cache hit
// (build counter matches current generation) it returns the cached
// tiers in sub-millisecond. On a miss — first call for this seed, or
// the global build counter has advanced past the stamped value
// because the graph mutated — it runs the BFS on demand under
// g.ResolveMutex(), caches the result onto n.Meta, and returns the
// fresh tiers. Returns hit=false only when seedID names no node or
// names a node whose kind is not an impact seed (KindFunction,
// KindMethod, KindType, KindInterface).
//
// This is the "lazy reach index" — the eager BuildIndex pass that
// used to walk every impact seed during cold-index has been removed
// from the IndexCtx hot path because the breakeven was untenable on
// monorepo graphs: ~2000 s of cold-index work on k8s to save ~10 ms
// per query, requiring ~200 k queries to break even. The lazy form
// pays the 10 ms only on the first AnalyzeImpact call that names a
// given seed, then caches forever. BuildIndex remains available for
// `gortex enrich reach` (explicit prebuild) and for callers that
// want to pay the cost up front under controlled conditions.
func Lookup(g graph.Store, seedID string) (d1, d2, d3 []Entry, hit bool) {
if g == nil {
return nil, nil, nil, false
}
n := g.GetNode(seedID)
if n == nil {
return nil, nil, nil, false
}
if !ImpactSeedKind(n.Kind) {
return nil, nil, nil, false
}
currentBuild := atomic.LoadUint64(&buildCounter)
// Fast path: existing stamp matches the current build generation.
if d1, d2, d3, ok := readCached(n, currentBuild); ok {
return d1, d2, d3, true
}
// Slow path: compute the tiers and cache them. Acquire the resolve
// mutex so the Meta writes don't race other graph-wide passes that
// already serialise on it (markTestSymbolsAndEmitEdges, clone
// detection, ResolveTemporalCalls).
mu := g.ResolveMutex()
mu.Lock()
defer mu.Unlock()
// Re-check after acquiring the lock: another goroutine may have
// computed and cached this seed while we were waiting.
if d1, d2, d3, ok := readCached(n, currentBuild); ok {
return d1, d2, d3, true
}
tiers := compute(g, seedID)
if n.Meta == nil {
n.Meta = make(map[string]any, 10)
}
n.Meta[MetaReachBuild] = currentBuild
setOrDeleteStrings(n.Meta, MetaReachD1, tiers[0].IDs)
setOrDeleteStrings(n.Meta, MetaReachD2, tiers[1].IDs)
setOrDeleteStrings(n.Meta, MetaReachD3, tiers[2].IDs)
setOrDeleteFloats(n.Meta, MetaReachD1Conf, tiers[0].Conf)
setOrDeleteFloats(n.Meta, MetaReachD2Conf, tiers[1].Conf)
setOrDeleteFloats(n.Meta, MetaReachD3Conf, tiers[2].Conf)
setOrDeleteStrings(n.Meta, MetaReachD1Label, tiers[0].Labels)
setOrDeleteStrings(n.Meta, MetaReachD2Label, tiers[1].Labels)
setOrDeleteStrings(n.Meta, MetaReachD3Label, tiers[2].Labels)
// Persist the freshly-stamped Meta through the store. On the
// in-memory backend n is the canonical node, so the mutations above
// already stuck — AddNode re-inserts the same pointer idempotently.
// On disk backends n is a per-call reconstruction returned
// by GetNode, so the in-place stamp would otherwise be discarded the
// moment this function returns: the lazy reach cache would never
// survive a single query, forcing a full recompute on every
// AnalyzeImpact / explain_change_impact / get_callers call. AddNode
// upserts the Meta column so the cache actually sticks.
g.AddNode(n)
d1 = readTier(n.Meta, MetaReachD1, MetaReachD1Conf, MetaReachD1Label)
d2 = readTier(n.Meta, MetaReachD2, MetaReachD2Conf, MetaReachD2Label)
d3 = readTier(n.Meta, MetaReachD3, MetaReachD3Conf, MetaReachD3Label)
return d1, d2, d3, true
}
// readCached reads the stamped reach tiers off n.Meta when the stamp
// matches currentBuild. Returns ok=false when the stamp is missing
// (never built), stale (graph has changed since), or has the wrong
// Go type (snapshot from an older format).
func readCached(n *graph.Node, currentBuild uint64) (d1, d2, d3 []Entry, ok bool) {
if n.Meta == nil {
return nil, nil, nil, false
}
raw, present := n.Meta[MetaReachBuild]
if !present {
return nil, nil, nil, false
}
var stamped uint64
switch v := raw.(type) {
case uint64:
stamped = v
case uint32:
stamped = uint64(v)
case int:
stamped = uint64(v)
case int64:
stamped = uint64(v)
default:
return nil, nil, nil, false
}
if stamped != currentBuild {
return nil, nil, nil, false
}
d1 = readTier(n.Meta, MetaReachD1, MetaReachD1Conf, MetaReachD1Label)
d2 = readTier(n.Meta, MetaReachD2, MetaReachD2Conf, MetaReachD2Label)
d3 = readTier(n.Meta, MetaReachD3, MetaReachD3Conf, MetaReachD3Label)
return d1, d2, d3, true
}
// InvalidateIndex advances the global build counter so every future
// Lookup recomputes against the new graph state. Call this whenever
// the graph mutates in a way that could change reach sets — at the
// end of every IndexCtx / IncrementalReindex / global-pass run.
//
// The cached Meta entries on nodes that survived the mutation are
// not deleted; they're simply tagged with a stale build counter, so
// the next Lookup on each falls through to a fresh compute. This is
// strictly cheaper than walking all nodes to clear Meta — the
// invalidation is O(1) and only the seeds actually queried pay the
// recompute cost.
func InvalidateIndex() {
atomic.AddUint64(&buildCounter, 1)
}
// readTier reconstructs an []Entry from the parallel arrays. Missing
// confidence / label keys (or shorter slices) zero-fill so older
// snapshots that lack the parallel data degrade gracefully — the
// caller still sees the ID set, just with zero confidence.
func readTier(meta map[string]any, idsKey, confKey, labelKey string) []Entry {
ids, _ := meta[idsKey].([]string)
if len(ids) == 0 {
return nil
}
conf, _ := meta[confKey].([]float64)
labels, _ := meta[labelKey].([]string)
out := make([]Entry, len(ids))
for i, id := range ids {
out[i].ID = id
if i < len(conf) {
out[i].Conf = conf[i]
}
if i < len(labels) {
out[i].Label = labels[i]
}
}
return out
}
// BuildCounter returns the current generation tag. Tests use it to
// assert that a rebuild actually bumped the counter.
func BuildCounter() uint64 {
return atomic.LoadUint64(&buildCounter)
}