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

528 lines
19 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 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)
}