diff --git a/internal/analysis/betweenness.go b/internal/analysis/betweenness.go index 2ef92a3cf..f09601a20 100644 --- a/internal/analysis/betweenness.go +++ b/internal/analysis/betweenness.go @@ -1,6 +1,7 @@ package analysis import ( + "context" "math/rand" "sort" @@ -73,8 +74,21 @@ const ( // Pivot sampling is seeded with a fixed seed, so results are // reproducible run to run. func ComputeBetweenness(g graph.Reader) *BetweennessResult { + result, _ := ComputeBetweennessContext(context.Background(), g) + return result +} + +// ComputeBetweennessContext retains the same deterministic pivots and scores, +// but never returns a partial ranking when its owner is cancelled. +func ComputeBetweennessContext(ctx context.Context, g graph.Reader) (*BetweennessResult, error) { + if err := ctx.Err(); err != nil { + return nil, err + } if g == nil { - return &BetweennessResult{Scores: map[string]float64{}} + return &BetweennessResult{Scores: map[string]float64{}}, nil + } + if ctx.Done() != nil { + g = graph.BindReadContext(g, ctx) } // Betweenness measures shortest-path centrality across the // call / reference subgraph; only function and method nodes carry @@ -107,9 +121,12 @@ func ComputeBetweenness(g graph.Reader) *BetweennessResult { // Federation proxy nodes carry real kinds, so the kind filter above // keeps them — drop them here so a remote stub is never a pivot. ids = excludeProxyIDs(ids) + if err := ctx.Err(); err != nil { + return nil, err + } n := len(ids) if n == 0 { - return &BetweennessResult{Scores: map[string]float64{}} + return &BetweennessResult{Scores: map[string]float64{}}, nil } // Stable node ordering: betweenness itself is order-independent, @@ -117,6 +134,9 @@ func ComputeBetweenness(g graph.Reader) *BetweennessResult { // reproducible regardless of the iteration order // NodeIDsByKinds happens to return. sort.Strings(ids) + if err := ctx.Err(); err != nil { + return nil, err + } // Forward adjacency over the call / reference subgraph. // EdgeAdjacencyForKinds returns only the (from, to) projection of @@ -132,37 +152,57 @@ func ComputeBetweenness(g graph.Reader) *BetweennessResult { // wakeup on the production graph. idIndex := make(map[string]int, n) for i, id := range ids { + if i&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } idIndex[id] = i } adjacency := make([][]int, n) - appendPair := func(from, to string) { + scanned := 0 + appendPair := func(from, to string) bool { + scanned++ + if scanned&1023 == 0 && ctx.Err() != nil { + return false + } fromIdx, fromOK := idIndex[from] toIdx, toOK := idIndex[to] if !fromOK || !toOK { - return + return true } adjacency[fromIdx] = append(adjacency[fromIdx], toIdx) + return true } if adjScan, ok := g.(graph.EdgeAdjacencyForKinds); ok { for pair := range adjScan.EdgeAdjacencyForKinds(betweennessKinds, bcNodeKinds) { - appendPair(pair[0], pair[1]) + if !appendPair(pair[0], pair[1]) { + return nil, ctx.Err() + } } } else if es, ok := g.(graph.EdgesByKindsScanner); ok { for e := range es.EdgesByKinds(betweennessKinds) { if e != nil { - appendPair(e.From, e.To) + if !appendPair(e.From, e.To) { + return nil, ctx.Err() + } } } } else { for _, kind := range betweennessKinds { for e := range g.EdgesByKind(kind) { if e != nil { - appendPair(e.From, e.To) + if !appendPair(e.From, e.To) { + return nil, ctx.Err() + } } } } } + if err := ctx.Err(); err != nil { + return nil, err + } // Adaptive fast path: exact for small graphs, sampled otherwise. sources := ids sampled := false @@ -171,10 +211,15 @@ func ComputeBetweenness(g graph.Reader) *BetweennessResult { sources = samplePivots(ids, betweennessPivots) } + if err := ctx.Err(); err != nil { + return nil, err + } scoreDense := make([]float64, n) scratch := newBrandesScratch(n) for _, src := range sources { - brandesAccumulate(idIndex[src], adjacency, scoreDense, &scratch) + if err := brandesAccumulateContext(ctx, idIndex[src], adjacency, scoreDense, &scratch); err != nil { + return nil, err + } } // Rescale the sampled estimate up to a full-source equivalent and convert @@ -186,18 +231,26 @@ func ComputeBetweenness(g graph.Reader) *BetweennessResult { score := make(map[string]float64, n) var max float64 for i, id := range ids { + if i&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } v := scoreDense[i] * scale score[id] = v if v > max { max = v } } + if err := ctx.Err(); err != nil { + return nil, err + } return &BetweennessResult{ Scores: score, Max: max, Sampled: sampled, Pivots: len(sources), - } + }, nil } // samplePivots picks k distinct source nodes from ids using a @@ -250,8 +303,13 @@ func newBrandesScratch(nodes int) brandesScratch { } } -func (s *brandesScratch) reset() { +func (s *brandesScratch) resetContext(ctx context.Context) error { for i := range s.dist { + if i&1023 == 0 { + if err := ctx.Err(); err != nil { + return err + } + } s.sigma[i] = 0 s.dist[i] = -1 s.delta[i] = 0 @@ -259,21 +317,34 @@ func (s *brandesScratch) reset() { } s.queue = s.queue[:0] s.order = s.order[:0] + return ctx.Err() } // The graph is unweighted, so a plain BFS yields shortest paths. Node IDs have // already been lowered to dense integer slots by ComputeBetweenness; no maps // are allocated in the per-pivot loop. -func brandesAccumulate(src int, adjacency [][]int, score []float64, scratch *brandesScratch) { - scratch.reset() +func brandesAccumulateContext(ctx context.Context, src int, adjacency [][]int, score []float64, scratch *brandesScratch) error { + if err := scratch.resetContext(ctx); err != nil { + return err + } scratch.sigma[src] = 1 scratch.dist[src] = 0 scratch.queue = append(scratch.queue, src) for head := 0; head < len(scratch.queue); head++ { + if head&255 == 0 { + if err := ctx.Err(); err != nil { + return err + } + } v := scratch.queue[head] scratch.order = append(scratch.order, v) - for _, w := range adjacency[v] { + for edge, w := range adjacency[v] { + if edge&1023 == 1023 { + if err := ctx.Err(); err != nil { + return err + } + } if scratch.dist[w] < 0 { scratch.dist[w] = scratch.dist[v] + 1 scratch.queue = append(scratch.queue, w) @@ -286,9 +357,19 @@ func brandesAccumulate(src int, adjacency [][]int, score []float64, scratch *bra } for i := len(scratch.order) - 1; i >= 0; i-- { + if i&255 == 0 { + if err := ctx.Err(); err != nil { + return err + } + } w := scratch.order[i] if scratch.sigma[w] != 0 { - for _, v := range scratch.preds[w] { + for edge, v := range scratch.preds[w] { + if edge&1023 == 1023 { + if err := ctx.Err(); err != nil { + return err + } + } scratch.delta[v] += (scratch.sigma[v] / scratch.sigma[w]) * (1 + scratch.delta[w]) } } @@ -296,4 +377,5 @@ func brandesAccumulate(src int, adjacency [][]int, score []float64, scratch *bra score[w] += scratch.delta[w] } } + return ctx.Err() } diff --git a/internal/analysis/deadcode.go b/internal/analysis/deadcode.go index cf0da1f18..d2215bf94 100644 --- a/internal/analysis/deadcode.go +++ b/internal/analysis/deadcode.go @@ -1,6 +1,7 @@ package analysis import ( + "context" "math" "path/filepath" "slices" @@ -19,6 +20,11 @@ type DeadCodeEntry struct { Kind string `json:"kind"` FilePath string `json:"file_path"` Line int `json:"start_line"` + // Ownership is carried to scope-aware callers without changing the wire + // payload or requiring another per-result node lookup. + RepoPrefix string `json:"-"` + WorkspaceID string `json:"-"` + ProjectID string `json:"-"` } // HotspotEntry represents a symbol with disproportionately high complexity metrics. @@ -58,6 +64,13 @@ type HotspotEntry struct { // — none of them have a meaningful "is this code dead?" answer, and // surfacing them drowns the real dead-function signal in noise. type FindDeadCodeOptions struct { + // NodeScope is an additional exact metadata boundary, applied before + // candidate method checks. Nil preserves the caller's existing view. + NodeScope *graph.NodeScope + // RepoPrefixes scopes candidates, implementing types, entry-point files + // and processes. Incoming callers/interfaces remain global evidence. + // Nil preserves the unscoped analysis; a non-nil empty set returns none. + RepoPrefixes []string // IncludeVariables includes variable nodes in the results. Default false. // Variables are excluded by default because the graph does not track // intra-function data flow — local variables always appear "dead" even @@ -98,42 +111,6 @@ type FindDeadCodeOptions struct { SkipCrossRepoNodes bool } -// neverDeadCodeKinds enumerates node kinds the dead-code analyzer must -// never report — regardless of opt-in flags — because the question -// "is this code dead?" has no meaningful answer for them. Includes -// structural nodes (file/package/import), function-shape nodes -// (param/closure/generic_param), extracted metadata (todo/team/ -// release/license), infra surface (table/column/migration/config_key/ -// flag/event/fixture/resource/kustomization/image), package metadata -// (module), and value-extraction nodes (string/enum_member). -// Surfacing any of these drowns real dead-function signal in noise. -var neverDeadCodeKinds = map[graph.NodeKind]bool{ - graph.KindFile: true, - graph.KindPackage: true, - graph.KindImport: true, - graph.KindParam: true, - graph.KindClosure: true, - graph.KindGenericParam: true, - graph.KindString: true, - graph.KindEnumMember: true, - graph.KindModule: true, - graph.KindColumn: true, - graph.KindTable: true, - graph.KindConfigKey: true, - graph.KindFlag: true, - graph.KindEvent: true, - graph.KindMigration: true, - graph.KindFixture: true, - graph.KindTodo: true, - graph.KindTeam: true, - graph.KindRelease: true, - graph.KindLicense: true, - graph.KindResource: true, - graph.KindKustomization: true, - graph.KindImage: true, - graph.KindContract: true, -} - // incomingUsageKinds returns the set of incoming edge kinds that count // as "this symbol is used" for the given node kind. The per-kind list // matters because different shapes are exercised by different edges: @@ -216,8 +193,8 @@ func isEntryPointNode(n *graph.Node) bool { // to flag (modulo the opt-in switches for fields / variables / // constants). Used both for the per-kind allowlist handed to the // DeadCodeCandidator capability and as the source of truth for the -// Go-fallback loop. Kept in lockstep with neverDeadCodeKinds: a kind -// MUST appear in exactly one of the two lists. +// Go-fallback loop. Structural/metadata nodes, intra-function bindings and +// unreviewed future kinds are not candidates, even on a legacy reader. var candidateNodeKinds = []graph.NodeKind{ graph.KindFunction, graph.KindMethod, @@ -236,15 +213,9 @@ func FindDeadCode(g graph.Reader, processes *ProcessResult, excludePatterns []st if len(opts) > 0 { opt = opts[0] } - - // Build set of interface-required method names per type. - // If a type implements an interface, all methods that the interface - // requires are alive even if never called directly (they satisfy the - // contract). We index: typeID → set of required method names. - // Backends that implement graph.IfaceImplementsScanner serve this - // from one join; the fallback walks NodesByKind + EdgesByKind - // just like before. - ifaceRequiredMethods := buildIfaceRequiredMethods(g) + if opt.RepoPrefixes != nil && len(opt.RepoPrefixes) == 0 { + return nil + } // Pick the candidate-set source. When the backend implements // DeadCodeCandidator, the "no incoming usage edge" filter runs @@ -258,7 +229,19 @@ func FindDeadCode(g graph.Reader, processes *ProcessResult, excludePatterns []st // Build set of entry point node IDs from processes entryPoints := make(map[string]bool) if processes != nil { + var entryNodes map[string]*graph.Node + allowedRepos := deadCodeRepoSet(opt.RepoPrefixes) + if allowedRepos != nil { + ids := make([]string, 0, len(processes.Processes)) + for _, proc := range processes.Processes { + ids = append(ids, proc.EntryPoint) + } + entryNodes = g.GetNodesByIDs(ids) + } for _, proc := range processes.Processes { + if allowedRepos != nil && !deadCodeRepoAllows(entryNodes[proc.EntryPoint], allowedRepos) { + continue + } entryPoints[proc.EntryPoint] = true // Also consider all nodes that participate in any process for _, step := range proc.Steps { @@ -273,22 +256,23 @@ func FindDeadCode(g graph.Reader, processes *ProcessResult, excludePatterns []st // NodesByKind(KindFile) so on disk backends we don't have to // materialise AllNodes() just to find the entry-point files. entryPointFiles := make(map[string]bool) - for n := range g.NodesByKind(graph.KindFile) { + for n := range deadCodeFileNodes(g, opt.RepoPrefixes, opt.NodeScope) { if n != nil && isEntryPointNode(n) { entryPointFiles[n.FilePath] = true } } + // Interface contracts matter only for zero-incoming method candidates. + // Expand their actual receiver identities through the selected reader; + // enumerating every scoped node/implementer dominates small-repo calls. + ifaceRequiredMethods, methodOutEdges := deadCodeMethodEvidence(g, candidates, incomingByID, opt) var result []DeadCodeEntry var resultNodes []*graph.Node for _, n := range candidates { - // Skip kinds the analyzer never reports — structural, - // extracted metadata, infra, function-shape, and value-only - // nodes. See neverDeadCodeKinds for the full list and why. - // (The server-side candidator only ships nodes whose kind is - // in candidateNodeKinds, but the Go fallback path scans - // AllNodes so we keep the explicit gate.) - if neverDeadCodeKinds[n.Kind] { + // Keep indexed and legacy readers on the same positive eligibility + // contract. A negative list silently admitted new local/structural + // kinds from AllNodes that an indexed candidator never returned. + if !slices.Contains(candidateNodeKinds, n.Kind) { continue } @@ -398,7 +382,7 @@ func FindDeadCode(g graph.Reader, processes *ProcessResult, excludePatterns []st // an interface contract. Look up the receiver type via member_of edges // and check if any implemented interface requires this method name. if n.Kind == graph.KindMethod { - outEdges := g.GetOutEdges(n.ID) + outEdges := methodOutEdges[n.ID] for _, e := range outEdges { if e.Kind == graph.EdgeMemberOf { if required, ok := ifaceRequiredMethods[e.To]; ok { @@ -473,11 +457,8 @@ func FindDeadCode(g graph.Reader, processes *ProcessResult, excludePatterns []st } result = append(result, DeadCodeEntry{ - ID: n.ID, - Name: n.Name, - Kind: string(n.Kind), - FilePath: n.FilePath, - Line: n.StartLine, + ID: n.ID, Name: n.Name, Kind: string(n.Kind), FilePath: n.FilePath, Line: n.StartLine, + RepoPrefix: n.RepoPrefix, WorkspaceID: n.WorkspaceID, ProjectID: n.ProjectID, }) resultNodes = append(resultNodes, n) } @@ -551,40 +532,50 @@ func FindDeadCode(g graph.Reader, processes *ProcessResult, excludePatterns []st // behaviour. The post-filter loop in FindDeadCode handles both shapes // uniformly — incomingByID==nil means "filter already applied". func collectDeadCodeCandidates(g graph.Reader, opt FindDeadCodeOptions) (candidates []*graph.Node, incomingByID map[string][]*graph.Edge) { - if dc, ok := g.(graph.DeadCodeCandidator); ok { - kinds := candidateNodeKinds[:0:0] - for _, k := range candidateNodeKinds { - // Honour the IncludeFields / IncludeVariables / IncludeConstants - // opt-in switches at the candidate-source: kinds the caller - // explicitly excluded never need to cross cgo. The post- - // filter loop still re-checks these for the fallback path - // (which sees every kind) so the contract holds either way. - switch k { - case graph.KindField: - if !opt.IncludeFields { - continue - } - case graph.KindVariable: - if !opt.IncludeVariables { - continue - } - case graph.KindConstant: - if !opt.IncludeConstants { - continue - } + kinds := candidateNodeKinds[:0:0] + for _, k := range candidateNodeKinds { + // Honour the IncludeFields / IncludeVariables / IncludeConstants + // opt-in switches at the candidate-source: kinds the caller + // explicitly excluded never need to cross cgo. The post- + // filter loop still re-checks these for the fallback path + // (which sees every kind) so the contract holds either way. + switch k { + case graph.KindField: + if !opt.IncludeFields { + continue + } + case graph.KindVariable: + if !opt.IncludeVariables { + continue + } + case graph.KindConstant: + if !opt.IncludeConstants { + continue } - kinds = append(kinds, k) } - allowed := make(map[graph.NodeKind][]graph.EdgeKind, len(kinds)) - for _, k := range kinds { - allowed[k] = incomingUsageKinds(k) + kinds = append(kinds, k) + } + allowed := make(map[graph.NodeKind][]graph.EdgeKind, len(kinds)) + for _, k := range kinds { + allowed[k] = incomingUsageKinds(k) + } + if opt.NodeScope != nil { + if scoped, ok := g.(graph.NodeScopedDeadCodeReader); ok { + return scoped.DeadCodeCandidatesInScope(kinds, allowed, opt.RepoPrefixes, *opt.NodeScope, graph.DeadCodeCandidateExclusions{Stubs: true, External: true}), nil } - return dc.DeadCodeCandidates(kinds, allowed), nil + } + if scoped, ok := g.(graph.ScopedDeadCodeReader); ok { + // Nil is the capability's explicit unscoped query, not a reason to + // bypass visibility-aware indexed filtering with an AllNodes scan. + return filterDeadCodeNodeScope(scoped.DeadCodeCandidatesInRepos(kinds, allowed, opt.RepoPrefixes), opt.NodeScope), nil + } + if dc, ok := g.(graph.DeadCodeCandidator); ok { + return filterDeadCodeNodeScope(filterDeadCodeRepos(dc.DeadCodeCandidates(kinds, allowed), opt.RepoPrefixes), opt.NodeScope), nil } // Fallback: pull every node and the batched in-edge map up front. // Same shape as before the DeadCodeCandidator capability landed. - nodes := g.AllNodes() + nodes := filterDeadCodeNodeScope(filterDeadCodeRepos(g.AllNodes(), opt.RepoPrefixes), opt.NodeScope) nodeIDs := make([]string, 0, len(nodes)) for _, n := range nodes { nodeIDs = append(nodeIDs, n.ID) @@ -592,61 +583,6 @@ func collectDeadCodeCandidates(g graph.Reader, opt FindDeadCodeOptions) (candida return nodes, g.GetInEdgesByNodeIDs(nodeIDs) } -// buildIfaceRequiredMethods returns a map from type ID → set of method names -// that the type must implement to satisfy its interfaces. This is computed by: -// 1. Collecting all interfaces with their required method names (from Meta["methods"]). -// 2. Collecting all EdgeImplements edges (type → interface). -// 3. For each type that implements an interface, merging all required method names. -// -// On backends that implement graph.IfaceImplementsScanner this is a -// single join; otherwise the fallback iterates -// NodesByKind(KindInterface) + EdgesByKind(EdgeImplements). Both paths -// produce the same map. -func buildIfaceRequiredMethods(g graph.Reader) map[string]map[string]bool { - if scanner, ok := g.(graph.IfaceImplementsScanner); ok { - return buildIfaceRequiredMethodsFromRows(scanner.IfaceImplementsRows()) - } - - // Fallback: walk interfaces + EdgeImplements edges Go-side. Uses - // NodesByKind(KindInterface) so disk backends still issue one - // scan per kind instead of pulling AllNodes. - ifaceMethods := make(map[string]map[string]bool) - for n := range g.NodesByKind(graph.KindInterface) { - if n == nil || n.Meta == nil { - continue - } - raw, ok := n.Meta["methods"] - if !ok { - continue - } - methods := decodeMethodNames(raw) - if len(methods) > 0 { - ifaceMethods[n.ID] = methods - } - } - - if len(ifaceMethods) == 0 { - return nil - } - - result := make(map[string]map[string]bool) - for e := range g.EdgesByKind(graph.EdgeImplements) { - // EdgeImplements: From=type, To=interface - iface, ok := ifaceMethods[e.To] - if !ok { - continue - } - if result[e.From] == nil { - result[e.From] = make(map[string]bool) - } - for m := range iface { - result[e.From][m] = true - } - } - - return result -} - // buildIfaceRequiredMethodsFromRows reduces the server-side // IfaceImplementsScanner row set to the typeID → method-name-set // shape the rest of FindDeadCode consumes. Same join logic as the @@ -724,6 +660,19 @@ const hotspotBetweennessWeight = 0.4 // other symbols — that augments the fan-in/out signals rather than replacing them. // If threshold <= 0, the default threshold is mean + 2*stddev. func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float64) []HotspotEntry { + result, _ := FindHotspotsContext(context.Background(), g, communities, threshold) + return result +} + +// FindHotspotsContext computes the same ranking, but cancellation makes the +// entire result unavailable instead of publishing a smaller partial ranking. +func FindHotspotsContext(ctx context.Context, g graph.Reader, communities *CommunityResult, threshold float64) ([]HotspotEntry, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if ctx.Done() != nil { + g = graph.BindReadContext(g, ctx) + } // Pull only function/method node IDs — the hotspots ranking is // callable-only, and the scoring math doesn't touch any column // beyond the id. NodeIDsByKinds returns the projection from a @@ -751,6 +700,9 @@ func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float6 } } } + if err := ctx.Err(); err != nil { + return nil, err + } // Build lookup maps for community membership nodeToComm := make(map[string]string) @@ -763,10 +715,16 @@ func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float6 // list -- it never returns rows for ids the caller didn't ask // for, so the cgo payload stays bounded by the candidate count // rather than the whole graph. - fanIn, fanOut := CollectFanCounts(g, candidateIDs, + fanIn, fanOut, err := collectFanCountsContext(ctx, g, candidateIDs, []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}, []graph.EdgeKind{graph.EdgeCalls}, ) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } // Community crossings per node: outgoing edges (Calls or // References) whose target sits in a different community than @@ -788,7 +746,12 @@ func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float6 } else { crossings = make(map[string]int) countCrossings := func(kind graph.EdgeKind) { + scanned := 0 for e := range g.EdgesByKind(kind) { + if scanned&1023 == 0 && ctx.Err() != nil { + return + } + scanned++ if e == nil { continue } @@ -803,11 +766,17 @@ func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float6 countCrossings(k) } } + if err := ctx.Err(); err != nil { + return nil, err + } // Betweenness centrality — exact on small graphs, sampled on // large ones. Normalized to 0-100 against the graph's own max so // it sits on the same scale as the other score terms. - bc := ComputeBetweenness(g) + bc, err := ComputeBetweennessContext(ctx, g) + if err != nil { + return nil, err + } betweenness := make(map[string]float64, len(bc.Scores)) if bc.Max > 0 { for id, v := range bc.Scores { @@ -830,7 +799,12 @@ func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float6 } entries := make([]rawEntry, 0, len(candidateIDs)) - for _, id := range candidateIDs { + for index, id := range candidateIDs { + if index&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } fi := fanIn[id] fo := fanOut[id] cc := crossings[id] @@ -848,12 +822,17 @@ func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float6 } if len(entries) == 0 { - return nil + return nil, ctx.Err() } // Find max raw score for normalization maxRaw := 0.0 - for _, e := range entries { + for index, e := range entries { + if index&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } if e.rawScore > maxRaw { maxRaw = e.rawScore } @@ -862,6 +841,11 @@ func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float6 // Normalize to 0-100 normalized := make([]float64, len(entries)) for i, e := range entries { + if i&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } if maxRaw > 0 { normalized[i] = (e.rawScore / maxRaw) * 100.0 } @@ -870,13 +854,23 @@ func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float6 // Compute default threshold if not specified: mean + 2*stddev if threshold <= 0 { var sum float64 - for _, s := range normalized { + for index, s := range normalized { + if index&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } sum += s } mean := sum / float64(len(normalized)) var variance float64 - for _, s := range normalized { + for index, s := range normalized { + if index&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } diff := s - mean variance += diff * diff } @@ -895,6 +889,11 @@ func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float6 } survivors := make([]survivor, 0, len(entries)) for i := range entries { + if i&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } score := math.Round(normalized[i]*100) / 100 // round to 2 decimal places if score < threshold { continue @@ -902,17 +901,30 @@ func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float6 survivors = append(survivors, survivor{entryIdx: i, score: score}) } if len(survivors) == 0 { - return nil + return nil, ctx.Err() } survivorIDs := make([]string, 0, len(survivors)) - for _, s := range survivors { + for index, s := range survivors { + if index&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } survivorIDs = append(survivorIDs, entries[s.entryIdx].id) } nodesByID := g.GetNodesByIDs(survivorIDs) + if err := ctx.Err(); err != nil { + return nil, err + } result := make([]HotspotEntry, 0, len(survivors)) - for _, s := range survivors { + for index, s := range survivors { + if index&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } e := entries[s.entryIdx] n := nodesByID[e.id] if n == nil { @@ -937,7 +949,10 @@ func FindHotspots(g graph.Reader, communities *CommunityResult, threshold float6 return result[i].ComplexityScore > result[j].ComplexityScore }) - return result + if err := ctx.Err(); err != nil { + return nil, err + } + return result, nil } // isTestFilePath checks if a file path indicates a test file. @@ -1172,13 +1187,26 @@ func matchesExcludePattern(filePath, nodeID string, patterns []string) bool { // keeps them per-call so a future analyzer with a different kind // split can share the same plumbing. func CollectFanCounts(g graph.Reader, ids []string, fanInKinds []graph.EdgeKind, fanOutKinds []graph.EdgeKind) (fanIn, fanOut map[string]int) { + fanIn, fanOut, _ = collectFanCountsContext(context.Background(), g, ids, fanInKinds, fanOutKinds) + return fanIn, fanOut +} + +func collectFanCountsContext(ctx context.Context, g graph.Reader, ids []string, fanInKinds []graph.EdgeKind, fanOutKinds []graph.EdgeKind) (fanIn, fanOut map[string]int, err error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } fanIn = make(map[string]int, len(ids)) fanOut = make(map[string]int, len(ids)) if len(ids) == 0 { - return fanIn, fanOut + return fanIn, fanOut, nil } if agg, ok := g.(graph.NodeFanAggregator); ok { - for _, r := range agg.NodeFanCounts(ids, fanInKinds, fanOutKinds) { + for index, r := range agg.NodeFanCounts(ids, fanInKinds, fanOutKinds) { + if index&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + } if r.FanIn != 0 { fanIn[r.NodeID] = r.FanIn } @@ -1186,14 +1214,22 @@ func CollectFanCounts(g graph.Reader, ids []string, fanInKinds []graph.EdgeKind, fanOut[r.NodeID] = r.FanOut } } - return fanIn, fanOut + if err := ctx.Err(); err != nil { + return nil, nil, err + } + return fanIn, fanOut, nil } // Fallback path: stream the requested kinds via EdgesByKind and // tally Go-side. ID-set membership keeps the maps bounded to // candidate ids, matching the capability contract. idSet := make(map[string]struct{}, len(ids)) - for _, id := range ids { + for index, id := range ids { + if index&1023 == 0 { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + } if id != "" { idSet[id] = struct{}{} } @@ -1204,7 +1240,12 @@ func CollectFanCounts(g graph.Reader, ids []string, fanInKinds []graph.EdgeKind, return } streamed[kind] = struct{}{} + scanned := 0 for e := range g.EdgesByKind(kind) { + if scanned&1023 == 0 && ctx.Err() != nil { + return + } + scanned++ if e == nil { continue } @@ -1239,9 +1280,15 @@ func CollectFanCounts(g graph.Reader, ids []string, fanInKinds []graph.EdgeKind, allKinds = append(allKinds, k) } for _, k := range allKinds { + if err := ctx.Err(); err != nil { + return nil, nil, err + } _, toIn := inKinds[k] _, toOut := outKinds[k] stream(k, toIn, toOut) } - return fanIn, fanOut + if err := ctx.Err(); err != nil { + return nil, nil, err + } + return fanIn, fanOut, nil } diff --git a/internal/analysis/deadcode_interface_demand_test.go b/internal/analysis/deadcode_interface_demand_test.go new file mode 100644 index 000000000..75c808ca6 --- /dev/null +++ b/internal/analysis/deadcode_interface_demand_test.go @@ -0,0 +1,73 @@ +package analysis + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +type demandIfaceReader struct { + *graph.Graph + globalQueries int + outgoingIDs []string +} + +func (r *demandIfaceReader) IfaceImplementsRowsInRepos(repos []string) []graph.IfaceImplementsRow { + r.globalQueries++ + return r.Graph.IfaceImplementsRowsInRepos(repos) +} +func (r *demandIfaceReader) GetOutEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { + r.outgoingIDs = append(r.outgoingIDs, ids...) + return r.Graph.GetOutEdgesByNodeIDs(ids) +} +func TestDeadCodeInterfaceEvidenceIsDemandedByMethodCandidates(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "a/method", Kind: graph.KindMethod, Name: "work", FilePath: "a/f.go", Language: "go", RepoPrefix: "a", WorkspaceID: "ws"}, + {ID: "a/type", Kind: graph.KindType, Name: "owner", FilePath: "a/f.go", Language: "go", RepoPrefix: "a", WorkspaceID: "ws"}, + {ID: "a/dead", Kind: graph.KindMethod, Name: "unusedMethod", FilePath: "a/f.go", Language: "go", RepoPrefix: "a", WorkspaceID: "ws"}, + {ID: "a/dangling", Kind: graph.KindMethod, Name: "throughMissing", FilePath: "a/f.go", Language: "go", RepoPrefix: "a", WorkspaceID: "ws"}, + {ID: "b/iface", Kind: graph.KindInterface, Name: "Foreign", RepoPrefix: "b", WorkspaceID: "other", Meta: map[string]any{"methods": []string{"work", "throughMissing"}}}, + {ID: "a/unrelated", Kind: graph.KindType, Name: "otherOwner", RepoPrefix: "a", WorkspaceID: "ws", Meta: map[string]any{"external": true}}, + }, []*graph.Edge{ + {From: "a/method", To: "a/type", Kind: graph.EdgeMemberOf}, + {From: "a/type", To: "b/iface", Kind: graph.EdgeImplements}, + {From: "a/dangling", To: "missing-owner", Kind: graph.EdgeMemberOf}, + {From: "missing-owner", To: "b/iface", Kind: graph.EdgeImplements}, + {From: "a/unrelated", To: "b/iface", Kind: graph.EdgeImplements}, + }) + for _, scoped := range []bool{false, true} { + t.Run(map[bool]string{false: "unbound dangling owner", true: "exact scope"}[scoped], func(t *testing.T) { + r := &demandIfaceReader{Graph: g} + opts := FindDeadCodeOptions{} + if scoped { + scope := graph.NodeScope{WorkspaceID: "ws"} + opts.NodeScope = &scope + } + result := FindDeadCode(r, nil, nil, opts) + var ids []string + for _, e := range result { + ids = append(ids, e.ID) + } + require.Contains(t, ids, "a/dead") + require.NotContains(t, ids, "a/method", "foreign interface evidence still satisfies an admitted receiver") + if scoped { + require.Contains(t, ids, "a/dangling") + } else { + require.NotContains(t, ids, "a/dangling", "unbound legacy dangling implementing identity remains valid") + } + require.Zero(t, r.globalQueries, "do not enumerate every implementing owner before candidate admission") + require.NotContains(t, r.outgoingIDs, "a/unrelated", "unrelated implementing types need no adjacency read") + }) + } +} + +func TestDeadCodeWithoutMethodCandidatesDoesNotReadInterfaceEvidence(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{{ID: "a/function", Kind: graph.KindFunction, Name: "unused", FilePath: "a/f.go", Language: "go", RepoPrefix: "a"}}, nil) + r := &demandIfaceReader{Graph: g} + require.Len(t, FindDeadCode(r, nil, nil), 1) + require.Zero(t, r.globalQueries) + require.Empty(t, r.outgoingIDs) +} diff --git a/internal/analysis/deadcode_scope.go b/internal/analysis/deadcode_scope.go new file mode 100644 index 000000000..3ab1d7234 --- /dev/null +++ b/internal/analysis/deadcode_scope.go @@ -0,0 +1,168 @@ +package analysis + +import ( + "iter" + "slices" + + "github.com/zzet/gortex/internal/graph" +) + +func deadCodeRepoSet(prefixes []string) map[string]bool { + if prefixes == nil { + return nil + } + allowed := make(map[string]bool, len(prefixes)) + for _, prefix := range prefixes { + allowed[prefix] = true + } + return allowed +} + +func deadCodeRepoAllows(node *graph.Node, allowed map[string]bool) bool { + return node != nil && (allowed == nil || allowed[node.RepoPrefix]) +} + +func filterDeadCodeRepos(nodes []*graph.Node, prefixes []string) []*graph.Node { + if prefixes == nil { + return nodes + } + allowed := deadCodeRepoSet(prefixes) + var out []*graph.Node + for _, node := range nodes { + if deadCodeRepoAllows(node, allowed) { + out = append(out, node) + } + } + return out +} + +func filterDeadCodeNodeScope(nodes []*graph.Node, scope *graph.NodeScope) []*graph.Node { + if scope == nil { + return nodes + } + var out []*graph.Node + for _, n := range nodes { + if n != nil && scope.Allows(n) { + out = append(out, n) + } + } + return out +} + +func deadCodeFileNodes(g graph.Reader, prefixes []string, scope *graph.NodeScope) iter.Seq[*graph.Node] { + if scope != nil { + if scoped, ok := g.(graph.NodeScopedDeadCodeReader); ok { + return scoped.NodesByKindInScope(graph.KindFile, prefixes, *scope) + } + } + if scoped, ok := g.(graph.ScopedDeadCodeReader); ok { + return func(yield func(*graph.Node) bool) { + for n := range scoped.NodesByKindInRepos(graph.KindFile, prefixes) { + if n != nil && (scope == nil || scope.Allows(n)) && !yield(n) { + return + } + } + } + } + allowed := deadCodeRepoSet(prefixes) + return func(yield func(*graph.Node) bool) { + for node := range g.NodesByKind(graph.KindFile) { + if deadCodeRepoAllows(node, allowed) && (scope == nil || scope.Allows(node)) && !yield(node) { + return + } + } + } +} + +// deadCodeMethodEvidence is request-local and bounded by method candidates, +// their receivers and the interfaces those receivers actually implement. +// Every adjacency read stays on g, preserving overlay/authority masks and +// global interface evidence without a whole-scope implements projection. +func deadCodeMethodEvidence(g graph.Reader, candidates []*graph.Node, incoming map[string][]*graph.Edge, opt FindDeadCodeOptions) (map[string]map[string]bool, map[string][]*graph.Edge) { + var methodIDs []string + usage := incomingUsageKinds(graph.KindMethod) + for _, n := range candidates { + if n == nil || n.Kind != graph.KindMethod || graph.IsStub(n.ID) { + continue + } + if external, _ := n.Meta["external"].(bool); external { + continue + } + if generated, _ := n.Meta["generated"].(bool); generated { + continue + } + used := false + for _, edge := range incoming[n.ID] { + if edge != nil && slices.Contains(usage, edge.Kind) { + used = true + break + } + } + if !used { + methodIDs = append(methodIDs, n.ID) + } + } + if len(methodIDs) == 0 { + return nil, nil + } + methodEdges := g.GetOutEdgesByNodeIDs(methodIDs) + var owners []string + seen := make(map[string]bool) + for _, id := range methodIDs { + for _, edge := range methodEdges[id] { + if edge != nil && edge.Kind == graph.EdgeMemberOf && !seen[edge.To] { + seen[edge.To] = true + owners = append(owners, edge.To) + } + } + } + if len(owners) == 0 { + return nil, methodEdges + } + if opt.NodeScope != nil || opt.RepoPrefixes != nil { + nodes := g.GetNodesByIDs(owners) + allowed := deadCodeRepoSet(opt.RepoPrefixes) + kept := owners[:0] + for _, id := range owners { + n := nodes[id] + if n != nil && deadCodeRepoAllows(n, allowed) && (opt.NodeScope == nil || opt.NodeScope.Allows(n)) { + kept = append(kept, id) + } + } + owners = kept + } + // Unbound legacy reads intentionally admit a dangling implementing type: + // its actual member_of/implements edges can still satisfy a method. A + // scoped read requires concrete ownership metadata above, just as SQL did. + if len(owners) == 0 { + return nil, methodEdges + } + implements := g.GetOutEdgesByNodeIDs(owners) + var interfaces []string + seen = make(map[string]bool) + for _, id := range owners { + for _, edge := range implements[id] { + if edge != nil && edge.Kind == graph.EdgeImplements && !seen[edge.To] { + seen[edge.To] = true + interfaces = append(interfaces, edge.To) + } + } + } + if len(interfaces) == 0 { + return nil, methodEdges + } + nodes := g.GetNodesByIDs(interfaces) + var rows []graph.IfaceImplementsRow + for _, id := range owners { + for _, edge := range implements[id] { + if edge == nil || edge.Kind != graph.EdgeImplements { + continue + } + iface := nodes[edge.To] + if iface != nil && iface.Kind == graph.KindInterface && iface.Meta != nil { + rows = append(rows, graph.IfaceImplementsRow{TypeID: id, IfaceID: iface.ID, IfaceMeta: iface.Meta}) + } + } + } + return buildIfaceRequiredMethodsFromRows(rows), methodEdges +} diff --git a/internal/analysis/deadcode_scoped_test.go b/internal/analysis/deadcode_scoped_test.go new file mode 100644 index 000000000..761de6e50 --- /dev/null +++ b/internal/analysis/deadcode_scoped_test.go @@ -0,0 +1,257 @@ +package analysis + +import ( + "context" + "encoding/json" + "fmt" + "iter" + "path/filepath" + "slices" + + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +type deadCodeReadCounts struct{ candidates, implements, files, queries, allNodes, outgoing, lookups int } +type countedDeadCodeReader struct { + graph.Reader + scoped graph.ScopedDeadCodeReader + counts *deadCodeReadCounts +} + +func (r *countedDeadCodeReader) AllNodes() []*graph.Node { + r.counts.allNodes++ + return r.Reader.AllNodes() +} +func (r *countedDeadCodeReader) GetOutEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { + rows := r.Reader.GetOutEdgesByNodeIDs(ids) + for _, edges := range rows { + r.counts.outgoing += len(edges) + } + return rows +} +func (r *countedDeadCodeReader) GetNodesByIDs(ids []string) map[string]*graph.Node { + rows := r.Reader.GetNodesByIDs(ids) + r.counts.lookups += len(rows) + return rows +} +func (r *countedDeadCodeReader) BindReadContext(ctx context.Context) graph.Reader { + bound := graph.BindReadContext(r.Reader, ctx) + return &countedDeadCodeReader{Reader: bound, scoped: bound.(graph.ScopedDeadCodeReader), counts: r.counts} +} +func (r *countedDeadCodeReader) DeadCodeCandidatesInRepos(k []graph.NodeKind, in map[graph.NodeKind][]graph.EdgeKind, repos []string) []*graph.Node { + rows := r.scoped.DeadCodeCandidatesInRepos(k, in, repos) + r.counts.queries++ + r.counts.candidates += len(rows) + return rows +} +func (r *countedDeadCodeReader) IfaceImplementsRowsInRepos(repos []string) []graph.IfaceImplementsRow { + rows := r.scoped.IfaceImplementsRowsInRepos(repos) + r.counts.queries++ + r.counts.implements += len(rows) + return rows +} +func (r *countedDeadCodeReader) NodesByKindInRepos(k graph.NodeKind, repos []string) iter.Seq[*graph.Node] { + r.counts.queries++ + return func(yield func(*graph.Node) bool) { + for node := range r.scoped.NodesByKindInRepos(k, repos) { + r.counts.files++ + if !yield(node) { + return + } + } + } +} + +func scopedDeadCodeStore(t testing.TB, sqlite bool) graph.Store { + t.Helper() + if !sqlite { + return graph.New() + } + store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "dead-code.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + return store +} + +func seedScopedDeadCode(g graph.Store) { + nodes := []*graph.Node{ + {ID: "a/file", Name: "lib.go", Kind: graph.KindFile, FilePath: "a/lib.go", RepoPrefix: "a", WorkspaceID: "ws-a"}, + {ID: "a/entry", Name: "entry.go", Kind: graph.KindFile, FilePath: "a/entry.go", RepoPrefix: "a", WorkspaceID: "ws-a", Meta: map[string]any{"entry_point": true}}, + {ID: "a/lib.go::unused", Name: "unused", Kind: graph.KindFunction, Language: "go", FilePath: "a/lib.go", StartLine: 2, RepoPrefix: "a", WorkspaceID: "ws-a", ProjectID: "project"}, + {ID: "a/lib.go::used", Name: "used", Kind: graph.KindFunction, Language: "go", FilePath: "a/lib.go", StartLine: 4, RepoPrefix: "a", WorkspaceID: "ws-a"}, + {ID: "a/lib.go::worker", Name: "worker", Kind: graph.KindType, Language: "go", FilePath: "a/lib.go", StartLine: 6, RepoPrefix: "a", WorkspaceID: "ws-a"}, + {ID: "a/lib.go::worker.work", Name: "work", Kind: graph.KindMethod, Language: "go", FilePath: "a/lib.go", StartLine: 8, RepoPrefix: "a", WorkspaceID: "ws-a"}, + {ID: "a/entry.go::boot", Name: "boot", Kind: graph.KindFunction, Language: "go", FilePath: "a/entry.go", RepoPrefix: "a", WorkspaceID: "ws-a"}, + {ID: "b/file", Name: "lib.go", Kind: graph.KindFile, FilePath: "b/lib.go", RepoPrefix: "b", WorkspaceID: "ws-b"}, + {ID: "b/lib.go::caller", Name: "caller", Kind: graph.KindFunction, Language: "go", FilePath: "b/lib.go", RepoPrefix: "b", WorkspaceID: "ws-b"}, + {ID: "b/lib.go::contract", Name: "contract", Kind: graph.KindInterface, Language: "go", FilePath: "b/lib.go", RepoPrefix: "b", WorkspaceID: "ws-b", Meta: map[string]any{"methods": []string{"work"}}}, + } + g.AddBatch(nodes, []*graph.Edge{ + {From: "b/lib.go::caller", To: "a/lib.go::used", Kind: graph.EdgeCalls}, + {From: "a/lib.go::worker.work", To: "a/lib.go::worker", Kind: graph.EdgeMemberOf}, + {From: "a/lib.go::worker", To: "b/lib.go::contract", Kind: graph.EdgeImplements}, + }) +} + +func addUnrelatedDeadCode(g graph.Store, repo string, count int) { + nodes := make([]*graph.Node, 0, count*4) + edges := make([]*graph.Edge, 0, count) + for i := range count { + base := fmt.Sprintf("%s/file%d.go", repo, i) + for _, spec := range []struct { + suffix string + kind graph.NodeKind + }{{"file", graph.KindFile}, {"unused", graph.KindFunction}, {"worker", graph.KindType}, {"contract", graph.KindInterface}} { + node := &graph.Node{ID: base + "::" + spec.suffix, Name: spec.suffix, Kind: spec.kind, FilePath: base, Language: "go", RepoPrefix: repo, WorkspaceID: "other"} + if spec.kind == graph.KindInterface { + node.Meta = map[string]any{"methods": []string{"work"}} + } + nodes = append(nodes, node) + } + edges = append(edges, &graph.Edge{From: base + "::worker", To: base + "::contract", Kind: graph.EdgeImplements}) + } + g.AddBatch(nodes, edges) +} + +func TestDeadCodeScopedRowsIndependentOfOtherRepos(t *testing.T) { + for _, sqlite := range []bool{false, true} { + t.Run(fmt.Sprintf("sqlite=%t", sqlite), func(t *testing.T) { + g := scopedDeadCodeStore(t, sqlite) + seedScopedDeadCode(g) + count := func() deadCodeReadCounts { + counts := &deadCodeReadCounts{} + reader := &countedDeadCodeReader{Reader: g, scoped: g.(graph.ScopedDeadCodeReader), counts: counts} + entries := FindDeadCode(graph.BindReadContext(reader, t.Context()), nil, nil, FindDeadCodeOptions{RepoPrefixes: []string{"a"}}) + require.NotEmpty(t, entries) + require.Equal(t, 2, counts.queries, "request binding retains scoped candidate/file reads; interfaces are demanded by receiver identities") + require.Zero(t, counts.allNodes) + return *counts + } + before := count() + require.Positive(t, before.candidates) + require.Zero(t, before.implements, "do not enumerate all scoped implementers") + require.Positive(t, before.outgoing) + require.Positive(t, before.lookups) + require.Positive(t, before.files) + addUnrelatedDeadCode(g, "unrelated", 1000) + require.Equal(t, before, count(), "a larger unrelated repo must not increase scoped rows (stronger than the 5% gate)") + }) + } +} + +func TestDeadCodeScopedProcessesUseScopedEntryPoints(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "a/root", Name: "entry", Kind: graph.KindFunction, RepoPrefix: "a", FilePath: "a/lib.go", Language: "go"}, + {ID: "a/other", Name: "other", Kind: graph.KindFunction, RepoPrefix: "a", FilePath: "a/lib.go", Language: "go"}, + {ID: "b/root", Name: "entry", Kind: graph.KindFunction, RepoPrefix: "b", FilePath: "b/lib.go", Language: "go"}, + }, nil) + processes := &ProcessResult{Processes: []Process{ + {EntryPoint: "a/root"}, + {EntryPoint: "b/root", Steps: []Step{{ID: "a/other"}}}, + }} + entries := FindDeadCode(g, processes, nil, FindDeadCodeOptions{RepoPrefixes: []string{"a"}}) + require.Len(t, entries, 1) + require.Equal(t, "a/other", entries[0].ID, "only processes whose entry point is in scope may rescue candidates") +} + +// Hides optional capabilities to retain the old whole-reader algorithm as an +// independent oracle, and to exercise composed-reader fallbacks. +type legacyDeadCodeReader struct{ graph.Reader } + +func TestDeadCodeFallbackUsesDeclaredCandidateKinds(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "repo/dead", Name: "dead", Kind: graph.KindFunction, FilePath: "repo/f.go", RepoPrefix: "repo", StartLine: 1, Language: "go"}, + {ID: "repo/live", Name: "live", Kind: graph.KindFunction, FilePath: "repo/f.go", RepoPrefix: "repo", StartLine: 2, Language: "go"}, + {ID: "repo/Owner", Name: "Owner", Kind: graph.KindFunction, FilePath: "repo/f.go", RepoPrefix: "repo", StartLine: 3, Language: "go"}, + {ID: "repo/Owner#local:out@+1", Name: "out", Kind: graph.KindLocal, FilePath: "repo/f.go", RepoPrefix: "repo", StartLine: 4, Language: "go"}, + {ID: "repo/future", Name: "future", Kind: graph.NodeKind("future_structural_kind"), FilePath: "repo/f.go", RepoPrefix: "repo", StartLine: 5, Language: "go"}, + {ID: "repo/variable", Name: "variable", Kind: graph.KindVariable, FilePath: "repo/f.go", RepoPrefix: "repo", StartLine: 6, Language: "go"}, + {ID: "repo/field", Name: "field", Kind: graph.KindField, FilePath: "repo/f.go", RepoPrefix: "repo", StartLine: 7, Language: "go"}, + {ID: "repo/constant", Name: "constant", Kind: graph.KindConstant, FilePath: "repo/f.go", RepoPrefix: "repo", StartLine: 8, Language: "go"}, + }, []*graph.Edge{ + {From: "repo/Owner", To: "repo/live", Kind: graph.EdgeCalls}, + {From: "repo/Owner", To: "repo/Owner#local:out@+1", Kind: graph.EdgeReads}, + {From: "repo/Owner", To: "repo/Owner#local:out@+1", Kind: graph.EdgeReturnsTo}, + }) + for _, opts := range []FindDeadCodeOptions{{}, {IncludeVariables: true}, {IncludeFields: true}, {IncludeConstants: true}} { + fast := FindDeadCode(g, nil, nil, opts) + slow := FindDeadCode(legacyDeadCodeReader{g}, nil, nil, opts) + require.Equal(t, fast, slow, "fallback must use the same declared kinds, including opt-ins") + ids := make([]string, 0, len(slow)) + for _, entry := range slow { + ids = append(ids, entry.ID) + } + require.Contains(t, ids, "repo/dead") + require.NotContains(t, ids, "repo/live") + require.NotContains(t, ids, "repo/Owner#local:out@+1", "intra-function bindings are not declaration candidates") + require.NotContains(t, ids, "repo/future", "unreviewed future kinds cannot silently become candidates") + require.Equal(t, opts.IncludeVariables, slices.Contains(ids, "repo/variable")) + require.Equal(t, opts.IncludeFields, slices.Contains(ids, "repo/field")) + require.Equal(t, opts.IncludeConstants, slices.Contains(ids, "repo/constant")) + } +} + +func TestDeadCodeScopedMatchesPostFiltered(t *testing.T) { + for _, sqlite := range []bool{false, true} { + t.Run(fmt.Sprintf("sqlite=%t", sqlite), func(t *testing.T) { + g := scopedDeadCodeStore(t, sqlite) + seedScopedDeadCode(g) + addUnrelatedDeadCode(g, "unrelated", 20) + var want []DeadCodeEntry + for _, entry := range FindDeadCode(legacyDeadCodeReader{g}, nil, nil) { + if g.GetNode(entry.ID).RepoPrefix == "a" { + want = append(want, entry) + } + } + require.NotEmpty(t, want) + for _, reader := range []graph.Reader{g, legacyDeadCodeReader{g}} { + require.Equal(t, want, FindDeadCode(reader, nil, nil, FindDeadCodeOptions{RepoPrefixes: []string{"a"}})) + } + }) + } +} + +func TestDeadCodeCrossRepoCallerKeepsSymbolAlive(t *testing.T) { + for _, sqlite := range []bool{false, true} { + t.Run(fmt.Sprintf("sqlite=%t", sqlite), func(t *testing.T) { + g := scopedDeadCodeStore(t, sqlite) + seedScopedDeadCode(g) + entries := FindDeadCode(g, nil, nil, FindDeadCodeOptions{RepoPrefixes: []string{"a"}}) + ids := make(map[string]bool) + for _, entry := range entries { + ids[entry.ID] = true + if entry.ID == "a/lib.go::unused" { + require.Equal(t, "a", entry.RepoPrefix) + require.Equal(t, "ws-a", entry.WorkspaceID) + data, err := json.Marshal(entry) + require.NoError(t, err) + require.Equal(t, `{"id":"a/lib.go::unused","name":"unused","kind":"function","file_path":"a/lib.go","start_line":2}`, string(data)) + } + } + require.True(t, ids["a/lib.go::unused"]) + require.False(t, ids["a/lib.go::used"], "its only caller belongs to repo b") + require.False(t, ids["a/lib.go::worker.work"], "the implemented interface belongs to repo b") + require.False(t, ids["a/entry.go::boot"], "the scoped file carries entry-point evidence") + require.Empty(t, FindDeadCode(g, nil, nil, FindDeadCodeOptions{RepoPrefixes: []string{}})) + }) + } +} + +func BenchmarkDeadCodeScopedSixRepo(b *testing.B) { + g := scopedDeadCodeStore(b, true) + seedScopedDeadCode(g) + for _, repo := range []string{"c", "d", "e", "f"} { + addUnrelatedDeadCode(g, repo, 1000) + } + b.ResetTimer() + for b.Loop() { + FindDeadCode(g, nil, nil, FindDeadCodeOptions{RepoPrefixes: []string{"a"}}) + } +} diff --git a/internal/analysis/deadcode_test.go b/internal/analysis/deadcode_test.go index 4f5b28d65..f4a1c4e0f 100644 --- a/internal/analysis/deadcode_test.go +++ b/internal/analysis/deadcode_test.go @@ -3,6 +3,7 @@ package analysis import ( "fmt" "math" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -254,9 +255,9 @@ func TestPropertyDeadCode_Completeness(t *testing.T) { // For every node in the graph, if it meets all dead code criteria, // it must be in the result. for _, node := range tc.Graph.AllNodes() { - // Never-reported kinds (file/package/import + the broader - // noise set: param/closure/module/string/etc). - if neverDeadCodeKinds[node.Kind] { + // Completeness applies only to the declared candidate kinds; + // structural and future kinds are not unreported dead code. + if !slices.Contains(candidateNodeKinds, node.Kind) { continue } @@ -931,9 +932,9 @@ func TestDeadCode_ExportedInsideInternalIsSurfaced(t *testing.T) { // gortex/internal/parser/tsitter/tsitter.go — used by the user as a // dead-code-detection probe and expected to show up. g.AddNode(&graph.Node{ - ID: "gortex/internal/parser/tsitter/tsitter.go::Node.Test", + ID: "gortex/internal/parser/tsitter/tsitter.go::Node.Test", Kind: graph.KindMethod, Name: "Test", - FilePath: "gortex/internal/parser/tsitter/tsitter.go", + FilePath: "gortex/internal/parser/tsitter/tsitter.go", StartLine: 85, EndLine: 85, Language: "go", Meta: map[string]any{"receiver": "Node"}, }) diff --git a/internal/analysis/hotspots_context_test.go b/internal/analysis/hotspots_context_test.go new file mode 100644 index 000000000..480b5345c --- /dev/null +++ b/internal/analysis/hotspots_context_test.go @@ -0,0 +1,88 @@ +package analysis + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +type cancelAtAnalysisCheck struct { + context.Context + cancel context.CancelFunc + after, calls int +} + +func (c *cancelAtAnalysisCheck) Err() error { + c.calls++ + if c.calls == c.after { + c.cancel() + } + return c.Context.Err() +} + +func TestContextualHotspotsAndBetweennessPreserveCompletedRanking(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + for _, g := range []*graph.Graph{pathGraph(20), relayStar(24), pathGraph(betweennessExactThreshold + 1)} { + before := ComputeBetweenness(g) + after, err := ComputeBetweennessContext(ctx, g) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("context changed complete centrality: %v", err) + } + } + g := relayStar(24) + before := FindHotspots(g, nil, 0) + after, err := FindHotspotsContext(ctx, g, nil, 0) + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatalf("context changed complete hotspot ranking: %v", err) + } + cancel() + if result, err := FindHotspotsContext(ctx, g, nil, 0); !errors.Is(err, context.Canceled) || result != nil { + t.Fatal("cancelled ranking became a valid empty result") + } +} + +func TestBetweennessCancellationDiscardsAccumulatedPivots(t *testing.T) { + base, cancel := context.WithCancel(t.Context()) + defer cancel() + ctx := &cancelAtAnalysisCheck{Context: base, cancel: cancel, after: 50} + result, err := ComputeBetweennessContext(ctx, pathGraph(128)) + if !errors.Is(err, context.Canceled) || result != nil || ctx.calls < 50 { + t.Fatalf("partial pivot scores escaped: %v %+v", err, result) + } +} + +func TestBrandesCancellationInsideAdjacencyAndDependencyWork(t *testing.T) { + t.Run("high_degree_adjacency", func(t *testing.T) { + adj := make([][]int, 4097) + for i := 1; i < len(adj); i++ { + adj[0] = append(adj[0], i) + } + scratch := newBrandesScratch(len(adj)) + base, cancel := context.WithCancel(t.Context()) + defer cancel() + ctx := &cancelAtAnalysisCheck{Context: base, cancel: cancel, after: 9} + err := brandesAccumulateContext(ctx, 0, adj, make([]float64, len(adj)), &scratch) + if !errors.Is(err, context.Canceled) || len(scratch.queue) <= 1024 || len(scratch.queue) >= len(adj) { + t.Fatalf("adjacency did not cancel during actual work: err%v queued%d", err, len(scratch.queue)) + } + }) + t.Run("dependency_accumulation", func(t *testing.T) { + adj := make([][]int, 1025) + for i := 0; i < len(adj)-1; i++ { + adj[i] = []int{i + 1} + } + scratch := newBrandesScratch(len(adj)) + score := make([]float64, len(adj)) + base, cancel := context.WithCancel(t.Context()) + defer cancel() + ctx := &cancelAtAnalysisCheck{Context: base, cancel: cancel, after: 10} + err := brandesAccumulateContext(ctx, 0, adj, score, &scratch) + if !errors.Is(err, context.Canceled) || score[1023] == 0 || score[1] != 0 { + t.Fatalf("dependency did not cancel during partial accumulation: err%v tail%f head%f", err, score[1023], score[1]) + } + }) +} diff --git a/internal/graph/graph.go b/internal/graph/graph.go index fc2f35183..350a0d999 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -1840,6 +1840,10 @@ func (g *Graph) AllEdgesLight(kinds ...EdgeKind) []*Edge { // in the disk backend where the equivalent path materialises the full // in-edge map. func (g *Graph) DeadCodeCandidates(allowedNodeKinds []NodeKind, allowedInEdgeKinds map[NodeKind][]EdgeKind) []*Node { + return g.deadCodeCandidates(allowedNodeKinds, allowedInEdgeKinds, nil, OutlineEdgeFilter{}) +} + +func (g *Graph) deadCodeCandidates(allowedNodeKinds []NodeKind, allowedInEdgeKinds map[NodeKind][]EdgeKind, prefixes []string, filter OutlineEdgeFilter) []*Node { if len(allowedNodeKinds) == 0 { return nil } @@ -1855,10 +1859,11 @@ func (g *Graph) DeadCodeCandidates(allowedNodeKinds []NodeKind, allowedInEdgeKin } var out []*Node + filtering := len(filter.ExcludeKinds)+len(filter.ExcludeNodeKinds)+len(filter.ExcludeNodeIDs) > 0 || filter.ExcludeVia != "" for _, k := range allowedNodeKinds { allowed, hasAllow := allowedSet[k] anyKindCounts := !hasAllow || len(allowed) == 0 - for n := range g.NodesByKind(k) { + for n := range g.NodesByKindInRepos(k, prefixes) { if n == nil { continue } @@ -1868,6 +1873,9 @@ func (g *Graph) DeadCodeCandidates(allowedNodeKinds []NodeKind, allowedInEdgeKin if e == nil { continue } + if filtering && !filter.Allows(e, g.GetNode(e.From), n) { + continue + } if anyKindCounts { dead = false break diff --git a/internal/graph/outline_nodes.go b/internal/graph/outline_nodes.go new file mode 100644 index 000000000..f6769817c --- /dev/null +++ b/internal/graph/outline_nodes.go @@ -0,0 +1,30 @@ +package graph + +// ScopedOutlineNodeReader bounds the two node-only lookups an outline needs: +// ranked analyzer membership and exact-name entry points. Neither method may +// answer from a physical store underneath a different selected view. +type ScopedOutlineNodeReader interface { + OutlineNodeIDsInScope(NodeScope, []string) ([]string, error) + OutlineNodesByName(NodeScope, string) ([]*Node, error) +} + +var _ ScopedOutlineNodeReader = (*Graph)(nil) + +func (g *Graph) OutlineNodeIDsInScope(scope NodeScope, ids []string) ([]string, error) { + var out []string + for id, n := range g.GetNodesByIDs(ids) { + if n != nil && scope.Allows(n) { + out = append(out, id) + } + } + return out, nil +} +func (g *Graph) OutlineNodesByName(scope NodeScope, name string) ([]*Node, error) { + var out []*Node + for _, n := range g.FindNodesByName(name) { + if n != nil && scope.Allows(n) { + out = append(out, n) + } + } + return out, nil +} diff --git a/internal/graph/scoped_dead_code.go b/internal/graph/scoped_dead_code.go new file mode 100644 index 000000000..16357a1f8 --- /dev/null +++ b/internal/graph/scoped_dead_code.go @@ -0,0 +1,125 @@ +package graph + +import "iter" + +// DeadCodeCandidateExclusions removes only declarations already ineligible for +// dead-code reporting. This is a conservative accelerator: backends may retain +// legacy metadata they cannot prove ineligible without hydration. Callers must +// still check decoded nodes. It never restricts incoming usage evidence. +type DeadCodeCandidateExclusions struct { + Stubs bool + External bool +} + +func (e DeadCodeCandidateExclusions) Allows(n *Node) bool { + if n == nil || (e.Stubs && IsStub(n.ID)) { + return false + } + if e.External { + external, _ := n.Meta["external"].(bool) + return !external + } + return true +} + +// NodeScopedDeadCodeReader intersects exact node metadata scope with explicit +// repository ownership. Usage evidence remains global and reader-visible. +type NodeScopedDeadCodeReader interface { + DeadCodeCandidatesInScope([]NodeKind, map[NodeKind][]EdgeKind, []string, NodeScope, DeadCodeCandidateExclusions) []*Node + NodesByKindInScope(NodeKind, []string, NodeScope) iter.Seq[*Node] +} + +// FilteredNodeScopedDeadCodeReader lets an adjacency wrapper supply its mask +// without losing the exact workspace/project boundary before usage probes. +type FilteredNodeScopedDeadCodeReader interface { + DeadCodeCandidatesInScopeFiltered([]NodeKind, map[NodeKind][]EdgeKind, []string, NodeScope, OutlineEdgeFilter, DeadCodeCandidateExclusions) ([]*Node, error) +} + +// ScopedDeadCodeReader restricts candidate ownership, never evidence of use: +// incoming edges and implemented interfaces can belong to any repository. +// Nil prefixes mean unscoped; a non-nil empty set matches no nodes. An empty +// ownership prefix is included only when explicitly present in the set. +// Composed readers must preserve their masks instead of forwarding this +// capability to a physical base store. +type ScopedDeadCodeReader interface { + DeadCodeCandidatesInRepos(kinds []NodeKind, incoming map[NodeKind][]EdgeKind, prefixes []string) []*Node + IfaceImplementsRowsInRepos(prefixes []string) []IfaceImplementsRow + NodesByKindInRepos(kind NodeKind, prefixes []string) iter.Seq[*Node] +} + +// FilteredScopedDeadCodeReader applies the existing adjacency-exclusion +// contract to scoped candidate and implements reads. It lets an adjacency +// wrapper use indexed queries without exposing physical, unfiltered verdicts. +type FilteredScopedDeadCodeReader interface { + DeadCodeCandidatesInReposFiltered(kinds []NodeKind, incoming map[NodeKind][]EdgeKind, prefixes []string, filter OutlineEdgeFilter) ([]*Node, error) + IfaceImplementsRowsInReposFiltered(prefixes []string, filter OutlineEdgeFilter) ([]IfaceImplementsRow, error) +} + +var _ ScopedDeadCodeReader = (*Graph)(nil) +var _ FilteredScopedDeadCodeReader = (*Graph)(nil) + +func repoPrefixSet(prefixes []string) map[string]bool { + if prefixes == nil { + return nil + } + allowed := make(map[string]bool, len(prefixes)) + for _, prefix := range prefixes { + allowed[prefix] = true + } + return allowed +} + +func (g *Graph) NodesByKindInRepos(kind NodeKind, prefixes []string) iter.Seq[*Node] { + allowed := repoPrefixSet(prefixes) + return func(yield func(*Node) bool) { + for n := range g.NodesByKind(kind) { + if n != nil && (allowed == nil || allowed[n.RepoPrefix]) && !yield(n) { + return + } + } + } +} + +func (g *Graph) DeadCodeCandidatesInRepos(kinds []NodeKind, incoming map[NodeKind][]EdgeKind, prefixes []string) []*Node { + return g.deadCodeCandidates(kinds, incoming, prefixes, OutlineEdgeFilter{}) +} + +func (g *Graph) DeadCodeCandidatesInReposFiltered(kinds []NodeKind, incoming map[NodeKind][]EdgeKind, prefixes []string, filter OutlineEdgeFilter) ([]*Node, error) { + return g.deadCodeCandidates(kinds, incoming, prefixes, filter), nil +} + +func (g *Graph) IfaceImplementsRowsInRepos(prefixes []string) []IfaceImplementsRow { + if prefixes == nil { + return g.IfaceImplementsRows() + } + return g.ifaceImplementsRowsInRepos(prefixes, OutlineEdgeFilter{}) +} + +func (g *Graph) IfaceImplementsRowsInReposFiltered(prefixes []string, filter OutlineEdgeFilter) ([]IfaceImplementsRow, error) { + return g.ifaceImplementsRowsInRepos(prefixes, filter), nil +} + +func (g *Graph) ifaceImplementsRowsInRepos(prefixes []string, filter OutlineEdgeFilter) []IfaceImplementsRow { + allowed := repoPrefixSet(prefixes) + if allowed != nil && len(allowed) == 0 { + return nil + } + var out []IfaceImplementsRow + for edge := range g.EdgesByKind(EdgeImplements) { + if edge == nil { + continue + } + typ := g.GetNode(edge.From) + if allowed != nil && (typ == nil || !allowed[typ.RepoPrefix]) { + continue + } + iface := g.GetNode(edge.To) + if iface == nil || iface.Kind != KindInterface || iface.Meta == nil || !filter.Allows(edge, typ, iface) { + continue + } + if _, ok := iface.Meta["methods"]; ok { + out = append(out, IfaceImplementsRow{TypeID: edge.From, IfaceID: iface.ID, IfaceMeta: iface.Meta}) + } + } + return out +} diff --git a/internal/graph/store_sqlite/analysis_generation_read.go b/internal/graph/store_sqlite/analysis_generation_read.go index 37e1a65a8..5207fe61b 100644 --- a/internal/graph/store_sqlite/analysis_generation_read.go +++ b/internal/graph/store_sqlite/analysis_generation_read.go @@ -102,7 +102,7 @@ func (s *Store) LoadActiveAnalysisHeader(formatVersion uint32) (graph.AnalysisGe // rather than as a hit. func (s *Store) ensureAnalysisGenerationReadableLocked(generationID int64) error { var one int - err := s.db.QueryRow(` + err := s.db.QueryRowContext(s.readContext(), ` SELECT 1 FROM analysis_active_generation a JOIN analysis_generations g ON g.generation_id = a.generation_id WHERE a.view_gen = ? AND a.slot = 1 AND a.generation_id = ? AND g.state = ?`, @@ -333,7 +333,7 @@ func (s *Store) ListAnalysisCommunitySummaries(generationID int64, limit int, cu if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { return nil, "", err } - rows, err := s.db.Query(` + rows, err := s.db.QueryContext(s.readContext(), ` SELECT community_id, label, hub, parent_id, size, cohesion FROM analysis_communities WHERE generation_id = ? AND community_id > ? ORDER BY community_id LIMIT ?`, generationID, cursorID, limit+1) @@ -378,7 +378,7 @@ func (s *Store) loadAnalysisCommunityFilesLocked(generationID int64, items []gra args = append(args, items[i].ID) positions[items[i].ID] = i } - rows, err := s.db.Query(` + rows, err := s.db.QueryContext(s.readContext(), ` SELECT community_id, file_path FROM analysis_community_files WHERE generation_id = ? AND community_id IN (`+analysisPlaceholders(len(items))+`) ORDER BY community_id, ordinal`, args...) @@ -409,7 +409,7 @@ func (s *Store) AnalysisCommunityMembers(generationID int64, communityID string, if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { return nil, "", err } - rows, err := s.db.Query(` + rows, err := s.db.QueryContext(s.readContext(), ` SELECT id, node_id, community_id, pagerank, authority, hub FROM analysis_nodes WHERE generation_id = ? AND community_id = ? AND node_id > ? diff --git a/internal/graph/store_sqlite/bulk_load.go b/internal/graph/store_sqlite/bulk_load.go index ebf3dec3f..4d69e3b1b 100644 --- a/internal/graph/store_sqlite/bulk_load.go +++ b/internal/graph/store_sqlite/bulk_load.go @@ -121,6 +121,8 @@ var bulkDroppableIndexes = []bulkDroppableIndex{ // through json_each joins, which cannot imply a repo_prefix <> '' partial // predicate, and empty-prefix rows remain part of the Store query contract. {"nodes_by_repo", `CREATE INDEX IF NOT EXISTS nodes_by_repo ON nodes(repo_prefix, view_gen)`}, + {"nodes_by_effective_workspace", nodesByEffectiveWorkspaceDDL}, + {"nodes_deadcode_eligible_workspace", nodesDeadCodeEligibleWorkspaceDDL}, // Resolver warmup selects definitions by exact repository, compatible // language family, and a bounded page of names. Keep the key minimal: kind // is not a query predicate and WITHOUT ROWID secondary indexes already diff --git a/internal/graph/store_sqlite/deadcode_candidate_exclusion_test.go b/internal/graph/store_sqlite/deadcode_candidate_exclusion_test.go new file mode 100644 index 000000000..19fb2479f --- /dev/null +++ b/internal/graph/store_sqlite/deadcode_candidate_exclusion_test.go @@ -0,0 +1,106 @@ +package store_sqlite + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +func TestDeadCodeCandidateExclusionsPreserveRawScopeAndLegacyEvidence(t *testing.T) { + s, _ := openTempStore(t) + ids := []string{"own", "live", "promoted", "legacy", "stdlib::x", "repo::module::x", "repo::MODULE::x", "repo::nested::module::x", "global"} + var nodes []*graph.Node + for _, id := range ids { + n := &graph.Node{ID: id, Kind: graph.KindFunction, WorkspaceID: "ws"} + if id == "promoted" || id == "legacy" { + n.Meta = map[string]any{"external": true} + } + if id == "global" { + n.WorkspaceID = "" + n.FilePath = "external::shared" + } + nodes = append(nodes, n) + } + require.NoError(t, s.AddBatchChecked(nodes, []*graph.Edge{{From: "promoted", To: "live", Kind: graph.EdgeCalls}})) + // A legacy blob-only marker cannot be rejected on promoted column proof. + blob, err := encodeMeta(map[string]any{"external": true}) + require.NoError(t, err) + _, err = s.writerDB.Exec(`UPDATE nodes SET external=NULL, meta=? WHERE id='legacy'`, blob) + require.NoError(t, err) + query := func(exclude graph.DeadCodeCandidateExclusions, filter graph.OutlineEdgeFilter) ([]string, []*graph.Node) { + got, queryErr := s.DeadCodeCandidatesInScopeFiltered([]graph.NodeKind{graph.KindFunction}, map[graph.NodeKind][]graph.EdgeKind{graph.KindFunction: {graph.EdgeCalls}}, nil, graph.NodeScope{WorkspaceID: "ws"}, filter, exclude) + require.NoError(t, queryErr) + var out []string + for _, n := range got { + out = append(out, n.ID) + } + return out, got + } + raw, _ := query(graph.DeadCodeCandidateExclusions{}, graph.OutlineEdgeFilter{}) + require.ElementsMatch(t, []string{"own", "promoted", "legacy", "stdlib::x", "repo::module::x", "repo::MODULE::x", "repo::nested::module::x", "global"}, raw) + exclude := graph.DeadCodeCandidateExclusions{Stubs: true, External: true} + eligible, got := query(exclude, graph.OutlineEdgeFilter{}) + require.ElementsMatch(t, []string{"own", "legacy", "repo::MODULE::x", "repo::nested::module::x", "global"}, eligible) + for _, n := range got { + if n.ID == "legacy" { + require.Equal(t, true, n.Meta["external"]) + require.False(t, exclude.Allows(n), "decoded final eligibility still rejects legacy blob-only markers") + } + } + // The candidate-only exclusion must not remove the flagged caller's + // incoming evidence. An explicit adjacency mask can remove that evidence. + masked, _ := query(exclude, graph.OutlineEdgeFilter{ExcludeNodeIDs: map[string]bool{"promoted": true}}) + require.Contains(t, masked, "live") +} + +// Unlike the hydration control, this measures rejected ownership-bucket work +// inside SQLite: adding globals must not hide behind an unchanged result count. +func TestDeadCodeEligibleWorkspacePlanSkipsRejectedGlobalPopulation(t *testing.T) { + s, _ := openTempStore(t) + nodes := []*graph.Node{{ID: "own", Kind: graph.KindFunction, WorkspaceID: "ws"}} + for i := range 1000 { + nodes = append(nodes, &graph.Node{ID: fmt.Sprint("global-", i), Kind: graph.KindFunction, FilePath: "external::dependency", Meta: map[string]any{"external": true}}) + } + require.NoError(t, s.AddBatchChecked(nodes, nil)) + // Prove rejected globals are absent from the actual B-tree visited by + // candidate pages, rather than only absent from hydrated results. + var eligible int + require.NoError(t, s.db.QueryRow(`SELECT count(*) FROM nodes INDEXED BY nodes_deadcode_eligible_workspace WHERE external IS NOT 1`).Scan(&eligible)) + require.Equal(t, 1, eligible) + scope := graph.NodeScope{WorkspaceID: "ws"} + for _, exclude := range []graph.DeadCodeCandidateExclusions{{}, {External: true, Stubs: true}} { + q, args := deadCodeNodeScopeVerdictPageSQL(0, false, true, &scope, graph.OutlineEdgeFilter{}, exclude) + plan := generationLookupPlan(t, s.db, q, append([]any{int64(0), ""}, append(args, "", "function", 5)...)...) + if exclude.External { + require.Contains(t, plan, "nodes_deadcode_eligible_workspace") + } else { + require.Contains(t, plan, "nodes_by_effective_workspace") + } + require.Contains(t, plan, "=? AND view_gen=? AND kind=? AND id>?") + require.NotContains(t, plan, "SCAN n") + } +} + +func BenchmarkDeadCodeRejectedGlobalScan(b *testing.B) { + for _, globals := range []int{0, 10000} { + b.Run(fmt.Sprint(globals), func(b *testing.B) { + s, err := Open(b.TempDir() + "/scope.db") + require.NoError(b, err) + b.Cleanup(func() { _ = s.Close() }) + nodes := []*graph.Node{{ID: "own", Kind: graph.KindFunction, WorkspaceID: "ws"}} + for i := range globals { + nodes = append(nodes, &graph.Node{ID: fmt.Sprintf("global-%05d", i), Kind: graph.KindFunction, FilePath: "external::dependency", Meta: map[string]any{"external": true}}) + } + require.NoError(b, s.AddBatchChecked(nodes, nil)) + b.ResetTimer() + for b.Loop() { + got, err := s.DeadCodeCandidatesInScopeFiltered([]graph.NodeKind{graph.KindFunction}, nil, nil, graph.NodeScope{WorkspaceID: "ws"}, graph.OutlineEdgeFilter{}, graph.DeadCodeCandidateExclusions{External: true, Stubs: true}) + if err != nil || len(got) != 1 || got[0].ID != "own" { + b.Fatal(len(got), err) + } + } + }) + } +} diff --git a/internal/graph/store_sqlite/deadcode_covering_test.go b/internal/graph/store_sqlite/deadcode_covering_test.go new file mode 100644 index 000000000..6b806c277 --- /dev/null +++ b/internal/graph/store_sqlite/deadcode_covering_test.go @@ -0,0 +1,109 @@ +package store_sqlite + +import ( + "database/sql" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +func TestDeadCodeProvenWorkspaceAvoidsPrimaryNodeSeek(t *testing.T) { + s, _ := openTempStore(t) + // The effective-workspace seek itself proves this nonempty workspace. + // EXPLAIN's COVERING label alone is insufficient for expression indexes: + // inspect executable bytecode for the residual primary-key verification. + q, args := deadCodeNodeScopeVerdictPageSQL(0, false, true, nil, graph.OutlineEdgeFilter{}, graph.DeadCodeCandidateExclusions{External: true}) + values := append([]any{int64(0), "ws"}, append(args, "", "function", 512)...) + rows, err := s.db.Query("EXPLAIN "+q, values...) + require.NoError(t, err) + defer rows.Close() + for rows.Next() { + var addr, p1, p2, p3, p5 int + var opcode string + var p4, comment any + require.NoError(t, rows.Scan(&addr, &opcode, &p1, &p2, &p3, &p4, &p5, &comment)) + require.NotEqual(t, "NotFound", opcode, "candidate-only workspace key still visits primary nodes") + } + require.NoError(t, rows.Err()) +} + +func TestDeadCodeSourceMaskUsesCoveringKindIdentity(t *testing.T) { + s, _ := openTempStore(t) + filter := graph.OutlineEdgeFilter{ExcludeNodeKinds: []graph.NodeKind{graph.KindContract, graph.KindContract}} + q, args := deadCodeNodeScopeVerdictPageSQL(0, false, false, nil, filter) + plan := generationLookupPlan(t, s.db, q, append(args, int64(0), "", "function", 512)...) + require.Contains(t, plan, "SEARCH hidden_source USING COVERING INDEX nodes_by_kind") + require.NotContains(t, plan, "SEARCH source USING PRIMARY KEY") +} + +func TestDeadCodeProvenWorkspaceKeepsIntersectionsAndGlobalUsage(t *testing.T) { + s, _ := openTempStore(t) + nodes := []*graph.Node{ + {ID: "dead", Kind: graph.KindFunction, RepoPrefix: "a", WorkspaceID: "ws", ProjectID: "p"}, + {ID: "live", Kind: graph.KindFunction, RepoPrefix: "a", WorkspaceID: "ws", ProjectID: "p"}, + {ID: "dangling", Kind: graph.KindFunction, RepoPrefix: "a", WorkspaceID: "ws", ProjectID: "p"}, + {ID: "bean", Kind: graph.KindFunction, RepoPrefix: "a", WorkspaceID: "ws", ProjectID: "p"}, + {ID: "wrong-project", Kind: graph.KindFunction, RepoPrefix: "a", WorkspaceID: "ws", ProjectID: "q"}, + {ID: "wrong-repo", Kind: graph.KindFunction, RepoPrefix: "b", WorkspaceID: "ws", ProjectID: "p"}, + {ID: "legacy", Kind: graph.KindFunction, RepoPrefix: "ws", ProjectID: "p"}, + {ID: "global", Kind: graph.KindFunction, FilePath: "external::shared"}, + {ID: "ordinary-unowned", Kind: graph.KindFunction, FilePath: "ordinary.go"}, + {ID: "foreign", Kind: graph.KindFunction, WorkspaceID: "other"}, + {ID: "hidden", Kind: graph.KindContract, WorkspaceID: "other"}, + } + edges := []*graph.Edge{{From: "foreign", To: "live", Kind: graph.EdgeCalls}, {From: "hidden", To: "dead", Kind: graph.EdgeCalls}, {From: "missing", To: "dangling", Kind: graph.EdgeCalls}, {From: "foreign", To: "bean", Kind: graph.EdgeCalls, Meta: map[string]any{"via": "spring.Bean"}}} + require.NoError(t, s.AddBatchChecked(nodes, edges)) + // Another generation must not provide the source kind for this generation. + require.NoError(t, s.AtGeneration(7).AddBatchChecked([]*graph.Node{{ID: "hidden", Kind: graph.KindFunction}}, nil)) + filter := graph.OutlineEdgeFilter{ExcludeNodeKinds: []graph.NodeKind{graph.KindContract, graph.KindContract}, ExcludeVia: "spring.Bean"} + kinds := []graph.NodeKind{graph.KindFunction} + incoming := map[graph.NodeKind][]graph.EdgeKind{graph.KindFunction: {graph.EdgeCalls}} + for _, scope := range []graph.NodeScope{{WorkspaceID: "ws"}, {WorkspaceID: "ws", ProjectID: "p"}, {WorkspaceID: "ws", ProjectID: "p", RepoAllow: map[string]bool{"a": true, "ws": true}}, {WorkspaceID: "ws", ProjectID: "p", RepoAllow: map[string]bool{"a": false}}} { + for _, prefixes := range [][]string{nil, {"a", "ws", ""}, {}} { + got, err := s.DeadCodeCandidatesInScopeFiltered(kinds, incoming, prefixes, scope, filter, graph.DeadCodeCandidateExclusions{External: true, Stubs: true}) + require.NoError(t, err) + all, err := s.DeadCodeCandidatesInReposFiltered(kinds, incoming, prefixes, filter) + require.NoError(t, err) + var want []*graph.Node + for _, n := range all { + if scope.Allows(n) { + want = append(want, n) + } + } + require.Equal(t, want, got, "scope=%+v prefixes=%v", scope, prefixes) + } + } + got, err := s.DeadCodeCandidatesInScopeFiltered(kinds, incoming, nil, graph.NodeScope{WorkspaceID: "ws", ProjectID: "p", RepoAllow: map[string]bool{"a": true, "ws": true}}, filter, graph.DeadCodeCandidateExclusions{External: true}) + require.NoError(t, err) + var ids []string + for _, n := range got { + ids = append(ids, n.ID) + } + require.ElementsMatch(t, []string{"dead", "bean", "legacy", "global"}, ids) + // Dropped secondary indexes retain exact results through the unhinted path. + _, err = s.writerDB.Exec(`DROP INDEX nodes_by_kind; DROP INDEX nodes_deadcode_eligible_workspace`) + require.NoError(t, err) + again, err := s.DeadCodeCandidatesInScopeFiltered(kinds, incoming, nil, graph.NodeScope{WorkspaceID: "ws", ProjectID: "p", RepoAllow: map[string]bool{"a": true, "ws": true}}, filter, graph.DeadCodeCandidateExclusions{External: true}) + require.NoError(t, err) + require.Equal(t, got, again) + _, err = s.writerDB.Exec(`CREATE INDEX nodes_by_kind ON nodes(kind,view_gen)`) + require.NoError(t, err) + _, err = s.writerDB.Exec(nodesDeadCodeEligibleWorkspaceDDL) + require.NoError(t, err) +} + +func TestDeadCodeCoveringUpgradeReplacesExistingKeyAndPreservesAnalysis(t *testing.T) { + path, header := seedIndexMigrationAnalysis(t, 42) + withRawDB(t, path, func(db *sql.DB) { + _, err := db.Exec(`CREATE INDEX nodes_deadcode_eligible_workspace ON nodes(COALESCE(NULLIF(workspace_id,''),repo_prefix),view_gen,kind,id) WHERE external IS NOT 1`) + require.NoError(t, err) + }) + s, err := Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + requireKeptIndexMigrationAnalysis(t, s, header) + var ddl string + require.NoError(t, s.db.QueryRow(`SELECT sql FROM sqlite_schema WHERE name='nodes_deadcode_eligible_workspace'`).Scan(&ddl)) + require.Contains(t, ddl, "id, external, workspace_id, repo_prefix") +} diff --git a/internal/graph/store_sqlite/deadcode_node_scope_test.go b/internal/graph/store_sqlite/deadcode_node_scope_test.go new file mode 100644 index 000000000..fd04176bc --- /dev/null +++ b/internal/graph/store_sqlite/deadcode_node_scope_test.go @@ -0,0 +1,130 @@ +package store_sqlite + +import ( + "database/sql" + "fmt" + "slices" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +func TestDeadCodeNodeScopePreservesMetadataAndGlobalUsage(t *testing.T) { + s, _ := openTempStore(t) + nodes := []*graph.Node{ + {ID: "own", Kind: graph.KindFunction, RepoPrefix: "stale-owner", WorkspaceID: "ws"}, + {ID: "live", Kind: graph.KindFunction, RepoPrefix: "a", WorkspaceID: "ws"}, + {ID: "foreign", Kind: graph.KindFunction, RepoPrefix: "foreign", WorkspaceID: "other"}, + {ID: "unowned", Kind: graph.KindFunction, WorkspaceID: "ws"}, + {ID: "external", Kind: graph.KindFunction, FilePath: "external::dependency"}, + {ID: "legacy", Kind: graph.KindFunction, RepoPrefix: "ws"}, + {ID: "ownfile", Kind: graph.KindFile, RepoPrefix: "stale-owner", WorkspaceID: "ws", Meta: map[string]any{"entry_point": true}}, + {ID: "foreignfile", Kind: graph.KindFile, RepoPrefix: "foreign", WorkspaceID: "other"}, + {ID: "type", Kind: graph.KindType, RepoPrefix: "stale-owner", WorkspaceID: "ws"}, + {ID: "iface", Kind: graph.KindInterface, RepoPrefix: "foreign", WorkspaceID: "other", Meta: map[string]any{"methods": []string{"Work"}}}, + } + require.NoError(t, s.AddBatchChecked(nodes, []*graph.Edge{{From: "foreign", To: "live", Kind: graph.EdgeCalls}, {From: "type", To: "iface", Kind: graph.EdgeImplements}})) + scope := graph.NodeScope{WorkspaceID: "ws"} + kinds := []graph.NodeKind{graph.KindFunction} + incoming := map[graph.NodeKind][]graph.EdgeKind{graph.KindFunction: {graph.EdgeCalls}} + for _, prefixes := range [][]string{nil, {"stale-owner", "", "ws"}, {}} { + got, err := s.DeadCodeCandidatesInScopeFiltered(kinds, incoming, prefixes, scope, graph.OutlineEdgeFilter{}, graph.DeadCodeCandidateExclusions{}) + require.NoError(t, err) + var want []*graph.Node + for _, n := range s.DeadCodeCandidatesInRepos(kinds, incoming, prefixes) { + if scope.Allows(n) { + want = append(want, n) + } + } + require.Equal(t, want, got) + } + got, err := s.DeadCodeCandidatesInScopeFiltered(kinds, incoming, nil, scope, graph.OutlineEdgeFilter{}, graph.DeadCodeCandidateExclusions{}) + require.NoError(t, err) + ids := make([]string, 0, len(got)) + for _, n := range got { + ids = append(ids, n.ID) + } + require.ElementsMatch(t, []string{"own", "unowned", "external", "legacy"}, ids) + require.Len(t, slices.Collect(s.NodesByKindInScope(graph.KindFile, nil, scope)), 1) +} + +// This benchmark exposes residual node-scan growth separately from expensive +// usage probes. It makes no claim that workspace predicates have a new index. +func BenchmarkDeadCodeWorkspaceForeignGrowth(b *testing.B) { + for _, foreign := range []int{0, 10000} { + b.Run(fmt.Sprint(foreign), func(b *testing.B) { + s, err := Open(b.TempDir() + "/scope.db") + require.NoError(b, err) + b.Cleanup(func() { _ = s.Close() }) + nodes := []*graph.Node{{ID: "own", Kind: graph.KindFunction, WorkspaceID: "ws"}} + for i := range foreign { + nodes = append(nodes, &graph.Node{ID: fmt.Sprintf("foreign/%08d", i), Kind: graph.KindMethod, WorkspaceID: "other"}) + } + require.NoError(b, s.AddBatchChecked(nodes, nil)) + b.ResetTimer() + for range b.N { + got, err := s.DeadCodeCandidatesInScopeFiltered([]graph.NodeKind{graph.KindFunction, graph.KindMethod}, nil, nil, graph.NodeScope{WorkspaceID: "ws"}, graph.OutlineEdgeFilter{}, graph.DeadCodeCandidateExclusions{}) + if err != nil || len(got) != 1 { + b.Fatal(len(got), err) + } + } + }) + } +} + +func TestDeadCodeWorkspaceIndexSeeksAndUpgradePreservesGenerations(t *testing.T) { + path := t.TempDir() + "/upgrade.db" + s, err := Open(path) + require.NoError(t, err) + for _, gen := range []int64{0, 4} { + require.NoError(t, s.AtGeneration(gen).AddBatchChecked([]*graph.Node{{ID: "own", Kind: graph.KindFunction, WorkspaceID: "ws", Name: fmt.Sprint(gen)}}, nil)) + } + require.NoError(t, s.Close()) + withRawDB(t, path, func(db *sql.DB) { + _, err := db.Exec(`DROP INDEX nodes_by_effective_workspace; PRAGMA user_version = 40`) + require.NoError(t, err) + }) + s, err = Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + for _, gen := range []int64{0, 4} { + got, err := s.AtGeneration(gen).DeadCodeCandidatesInScopeFiltered([]graph.NodeKind{graph.KindFunction}, nil, nil, graph.NodeScope{WorkspaceID: "ws"}, graph.OutlineEdgeFilter{}, graph.DeadCodeCandidateExclusions{}) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, fmt.Sprint(gen), got[0].Name) + } + scope := graph.NodeScope{WorkspaceID: "ws"} + q, args := deadCodeNodeScopeVerdictPageSQL(0, false, true, &scope, graph.OutlineEdgeFilter{}, graph.DeadCodeCandidateExclusions{}) + plan := generationLookupPlan(t, s.db, q, append([]any{int64(0), "ws"}, append(args, "", "function", 5)...)...) + require.Contains(t, plan, "nodes_by_effective_workspace") + require.Contains(t, plan, "=? AND view_gen=? AND kind=? AND id>?") + require.NotContains(t, plan, "SCAN n") +} + +func TestDeadCodeNodeScopeSurvivesBulkIndexDropAndRecreate(t *testing.T) { + s, _ := openTempStore(t) + require.True(t, s.BeginCoordinatedBulkLoad()) + require.NoError(t, s.AddBatchChecked([]*graph.Node{ + {ID: "own", Kind: graph.KindFunction, RepoPrefix: "a", WorkspaceID: "ws"}, + {ID: "file", Kind: graph.KindFile, RepoPrefix: "a", WorkspaceID: "ws"}, + {ID: "type", Kind: graph.KindType, RepoPrefix: "a", WorkspaceID: "ws"}, + {ID: "iface", Kind: graph.KindInterface, RepoPrefix: "b", WorkspaceID: "other", Meta: map[string]any{"methods": []string{"Work"}}}, + }, []*graph.Edge{{From: "type", To: "iface", Kind: graph.EdgeImplements}})) + check := func() { + for _, exclude := range []graph.DeadCodeCandidateExclusions{{}, {External: true, Stubs: true}} { + nodes, err := s.DeadCodeCandidatesInScopeFiltered([]graph.NodeKind{graph.KindFunction}, nil, nil, graph.NodeScope{WorkspaceID: "ws"}, graph.OutlineEdgeFilter{}, exclude) + require.NoError(t, err) + require.Len(t, nodes, 1) + require.Equal(t, "own", nodes[0].ID) + } + require.Len(t, slices.Collect(s.NodesByKindInScope(graph.KindFile, nil, graph.NodeScope{WorkspaceID: "ws"})), 1) + } + require.False(t, indexNames(t, s.db)["nodes_by_effective_workspace"]) + require.False(t, indexNames(t, s.db)["nodes_deadcode_eligible_workspace"]) + check() + require.NoError(t, s.EndCoordinatedBulkLoad()) + require.True(t, indexNames(t, s.db)["nodes_by_effective_workspace"]) + require.True(t, indexNames(t, s.db)["nodes_deadcode_eligible_workspace"]) + check() +} diff --git a/internal/graph/store_sqlite/hotspot_read_context_test.go b/internal/graph/store_sqlite/hotspot_read_context_test.go new file mode 100644 index 000000000..5878757ad --- /dev/null +++ b/internal/graph/store_sqlite/hotspot_read_context_test.go @@ -0,0 +1,86 @@ +package store_sqlite + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +func TestHotspotPreparationReadsCancelWhileWaitingForActualSQLConnection(t *testing.T) { + reads := map[string]func(*Store, int64) (int, error){ + "candidate_ids": func(s *Store, _ int64) (int, error) { + return len(s.NodeIDsByKinds([]graph.NodeKind{graph.KindFunction})), nil + }, + "fan_counts": func(s *Store, _ int64) (int, error) { + return len(s.NodeFanCounts([]string{"r/A", "r/B"}, []graph.EdgeKind{graph.EdgeCalls}, []graph.EdgeKind{graph.EdgeCalls})), nil + }, + "crossings": func(s *Store, _ int64) (int, error) { + return len(s.CommunityCrossingsByKind([]graph.EdgeKind{graph.EdgeCalls}, map[string]string{"r/A": "a", "r/B": "b"})), nil + }, + "adjacency": func(s *Store, _ int64) (int, error) { + count := 0 + for range s.EdgeAdjacencyForKinds([]graph.EdgeKind{graph.EdgeCalls}, []graph.NodeKind{graph.KindFunction}) { + count++ + } + return count, nil + }, + "survivor_nodes": func(s *Store, _ int64) (int, error) { return len(s.GetNodesByIDs([]string{"r/A", "r/B"})), nil }, + "community_summaries": func(s *Store, generation int64) (int, error) { + rows, _, err := s.ListAnalysisCommunitySummaries(generation, 8, "") + return len(rows), err + }, + "community_members": func(s *Store, generation int64) (int, error) { + rows, _, err := s.AnalysisCommunityMembers(generation, "warm-community", 8, "") + return len(rows), err + }, + } + for name, read := range reads { + t.Run(name, func(t *testing.T) { + s, _ := openTempStore(t) + stopStoreBackgroundReaders(s) + require.NoError(t, s.AddBatchChecked([]*graph.Node{ + {ID: "r/A", Name: "A", RepoPrefix: "r", Kind: graph.KindFunction}, + {ID: "r/B", Name: "B", RepoPrefix: "r", Kind: graph.KindFunction}, + }, []*graph.Edge{{From: "r/A", To: "r/B", Kind: graph.EdgeCalls}})) + generation := buildMinimalAnalysisGeneration(t, s, "warm", 0, true) + count, err := read(s, generation) + require.NoError(t, err) + require.Positive(t, count, "the control must read a valid nonempty fixture before cancellation") + s.db.SetMaxOpenConns(1) + conn, err := s.db.Conn(t.Context()) + require.NoError(t, err) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + t.Cleanup(func() { + cancel() + _ = conn.Close() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Error("actual SQL waiter was not joined") + } + }) + before := s.db.Stats().WaitCount + var actual int + var readErr error + go func() { defer close(done); actual, readErr = read(s.WithReadContext(ctx), generation) }() + require.Eventually(t, func() bool { return s.db.Stats().WaitCount > before }, 3*time.Second, time.Millisecond) + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("cancelled SQL read still waits behind the held connection") + } + require.Zero(t, actual) + if readErr != nil { + require.True(t, errors.Is(readErr, context.Canceled), "unexpected SQL error: %v", readErr) + } + require.NoError(t, conn.Close()) + }) + } +} diff --git a/internal/graph/store_sqlite/outline_nodes.go b/internal/graph/store_sqlite/outline_nodes.go new file mode 100644 index 000000000..48b70d740 --- /dev/null +++ b/internal/graph/store_sqlite/outline_nodes.go @@ -0,0 +1,56 @@ +package store_sqlite + +import ( + "github.com/zzet/gortex/internal/graph" +) + +var _ graph.ScopedOutlineNodeReader = (*Store)(nil) + +func (s *Store) OutlineNodeIDsInScope(scope graph.NodeScope, ids []string) ([]string, error) { + if len(ids) == 0 { + return nil, nil + } + data, ok := projectionJSON(ids) + if !ok { + return nil, nil + } + predicate, args := outlineScopePredicate(scope, "n") + // One primary-key seek per bounded candidate; only IDs cross the driver. + q := `SELECT n.id FROM json_each(?) AS wanted +CROSS JOIN nodes n ON n.id = wanted.value AND n.view_gen = ? WHERE ` + predicate + rows, err := s.db.QueryContext(s.readContext(), q, append([]any{data, s.viewGen}, args...)...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} +func (s *Store) OutlineNodesByName(scope graph.NodeScope, name string) ([]*graph.Node, error) { + predicate, args := outlineScopePredicate(scope, "n") + // Match the old exact-name lookup's row order while applying visibility + // before full payload rows leave SQLite. The name index bounds candidates. + q := `SELECT ` + qualifiedNodeColumns("n", lookupNodeCols) + ` FROM nodes n INDEXED BY nodes_by_name +WHERE n.name = ? AND n.view_gen = ? AND ` + predicate + rows, err := outlineRows(s.readContext(), s.db, outlineQuery{sql: q, args: append([]any{name, s.viewGen}, args...)}) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*graph.Node + for rows.Next() { + n, err := scanNodeCursor(rows) + if err != nil { + continue + } // Same individually undecodable-row contract as node scans. + out = append(out, n) + } + return out, rows.Err() +} diff --git a/internal/graph/store_sqlite/planner_stats.go b/internal/graph/store_sqlite/planner_stats.go index 56bad5ecb..bd1525377 100644 --- a/internal/graph/store_sqlite/planner_stats.go +++ b/internal/graph/store_sqlite/planner_stats.go @@ -45,19 +45,21 @@ var plannerStatsIndexQuery = buildPlannerStatsIndexQuery() // it competes with another on a left prefix, and a missing row there hands the // choice to the default cost model. var plannerStatsIndexesOutside = map[string]string{ - contractSeedCandidateIndexName: "partial covering, kind IN " + contractSeedCandidateKindsSQL + ": checked seed eligibility pins this projection explicitly; generic key-list reads do not imply its candidate-kind predicate", - contractOwnerGenerationIndexName: "partial, kind IN " + contractOwnerKindsSQL + ": contract ownership projection pins this index explicitly; generic key-list reads do not imply its owner-kind predicate", - "edges_by_file": "(file_path, kind): only edges_by_file_generation shares file_path, and the default model prefers the one that keys more of a statement", - "edges_by_file_generation": "(file_path, view_gen): the lazy index, see edges_by_file", - "edges_by_from": "(view_gen, from_id, kind): its rivals edges_by_from_line and edges_by_from_line_kind carry the rows", - "edges_by_to": "(view_gen, to_id, kind): no other index leads with to_id", - "nodes_by_qual": "partial, qual_name <> '': the qualified-name reads only", - "nodes_missing_workspace_slugs": "partial, workspace_id = '' OR project_id = '': the slug backfill only", - "nodes_repo_files": "partial, kind = 'file': the repository file listing only", - "nodes_name_candidates": "partial, name <> '': supplementary reads select it explicitly; competing production name-key reads pin the stronger name or repo/language/name indexes", - "edges_by_unresolved": "partial, is_unresolved = 1: the resolver's unresolved scan only", - "edges_fnvalue_prefixed": "partial, to_id LIKE '%::unresolved::fnvalue::%': the fnvalue pass only", - "edges_external": "partial, the external-call target predicate: the external-call synthesizer only", + contractSeedCandidateIndexName: "partial covering, kind IN " + contractSeedCandidateKindsSQL + ": checked seed eligibility pins this projection explicitly; generic key-list reads do not imply its candidate-kind predicate", + contractOwnerGenerationIndexName: "partial, kind IN " + contractOwnerKindsSQL + ": contract ownership projection pins this index explicitly; generic key-list reads do not imply its owner-kind predicate", + "edges_by_file": "(file_path, kind): only edges_by_file_generation shares file_path, and the default model prefers the one that keys more of a statement", + "edges_by_file_generation": "(file_path, view_gen): the lazy index, see edges_by_file", + "edges_by_from": "(view_gen, from_id, kind): its rivals edges_by_from_line and edges_by_from_line_kind carry the rows", + "edges_by_to": "(view_gen, to_id, kind): no other index leads with to_id", + "nodes_by_qual": "partial, qual_name <> '': the qualified-name reads only", + "nodes_missing_workspace_slugs": "partial, workspace_id = '' OR project_id = '': the slug backfill only", + "nodes_repo_files": "partial, kind = 'file': the repository file listing only", + "nodes_name_candidates": "partial, name <> '': supplementary reads select it explicitly; competing production name-key reads pin the stronger name or repo/language/name indexes", + "nodes_by_effective_workspace": "raw effective-workspace reads pin this index explicitly and must retain external declarations excluded by the eligible-candidate partial key", + "nodes_deadcode_eligible_workspace": "partial, external IS NOT 1: only explicitly eligible workspace dead-code reads imply this predicate and pin this key", + "edges_by_unresolved": "partial, is_unresolved = 1: the resolver's unresolved scan only", + "edges_fnvalue_prefixed": "partial, to_id LIKE '%::unresolved::fnvalue::%': the fnvalue pass only", + "edges_external": "partial, the external-call target predicate: the external-call synthesizer only", } func buildPlannerStatsIndexQuery() string { diff --git a/internal/graph/store_sqlite/schema_analysis_preservation_test.go b/internal/graph/store_sqlite/schema_analysis_preservation_test.go new file mode 100644 index 000000000..149376b71 --- /dev/null +++ b/internal/graph/store_sqlite/schema_analysis_preservation_test.go @@ -0,0 +1,154 @@ +package store_sqlite + +import ( + "database/sql" + "fmt" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +func seedIndexMigrationAnalysis(t *testing.T, version int) (string, graph.AnalysisGenerationHeader) { + t.Helper() + path := filepath.Join(t.TempDir(), "index-upgrade.db") + s, err := Open(path) + require.NoError(t, err) + require.NoError(t, s.AddBatchChecked([]*graph.Node{{ID: "kept-node", Kind: graph.KindFunction, Name: "kept", RepoPrefix: "repo", Meta: map[string]any{"doc": "persisted"}}}, nil)) + buildMinimalAnalysisGeneration(t, s, "kept", 2, true) + header, found, err := s.LoadActiveAnalysisHeader(77) + require.NoError(t, err) + require.True(t, found) + require.NoError(t, s.Close()) + withRawDB(t, path, func(db *sql.DB) { + _, err := db.Exec(`DROP INDEX nodes_deadcode_eligible_workspace`) + require.NoError(t, err) + if version < 41 { + _, err = db.Exec(`DROP INDEX nodes_by_effective_workspace`) + require.NoError(t, err) + } + require.NoError(t, setUserVersion(db, version)) + }) + return path, header +} + +func requireKeptIndexMigrationAnalysis(t *testing.T, s *Store, want graph.AnalysisGenerationHeader) { + t.Helper() + header, found, err := s.LoadActiveAnalysisHeader(77) + require.NoError(t, err) + require.True(t, found, "index-only upgrade must retain active analysis") + var persistedRevision uint64 + require.NoError(t, s.db.QueryRow(`SELECT build_revision FROM analysis_generations WHERE generation_id = ?`, want.GenerationID).Scan(&persistedRevision)) + require.Equal(t, want.GraphRevision, persistedRevision, "index-only upgrade must not rewrite the persisted build revision") + // The public load receipt intentionally rebases to the current process; + // the stored build revision above remains the original publication's. + want.GraphRevision = s.AnalysisMutationRevision() + require.Equal(t, want, header, "all persisted header fields survive; the returned receipt uses the current process revision") + blob, found, err := s.LoadAnalysisBlob(header.GenerationID, graph.AnalysisBlobAdjacency) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, []byte("adjacency-kept"), blob) + nodes, err := s.AnalysisNodeMetrics(header.GenerationID, []string{"kept-node"}) + require.NoError(t, err) + require.Len(t, nodes, 1) + require.Equal(t, "kept-community", nodes[0].CommunityID) + require.Equal(t, "persisted", s.GetNode("kept-node").Meta["doc"]) + version, err := readUserVersion(s.db) + require.NoError(t, err) + require.Equal(t, currentSchemaVersion, version) + require.True(t, indexNames(t, s.db)["nodes_by_effective_workspace"]) + require.True(t, indexNames(t, s.db)["nodes_deadcode_eligible_workspace"]) +} + +func TestIndexOnlyMigrationPreservesActiveAnalysis(t *testing.T) { + for _, version := range []int{40, 41} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + path, header := seedIndexMigrationAnalysis(t, version) + s, err := Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + requireKeptIndexMigrationAnalysis(t, s, header) + }) + } +} + +func TestMixedIndexMigrationStillInvalidatesAnalysis(t *testing.T) { + path, header := seedIndexMigrationAnalysis(t, 39) + s, err := Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + _, found, err := s.LoadActiveAnalysisHeader(77) + require.NoError(t, err) + require.False(t, found, "default v40 plus preserving v41 is still a conservative mixed transition") + var state int + require.NoError(t, s.db.QueryRow(`SELECT state FROM analysis_generations WHERE generation_id = ?`, header.GenerationID).Scan(&state)) + require.Equal(t, analysisGenerationStale, state) + require.Equal(t, "persisted", s.GetNode("kept-node").Meta["doc"]) +} + +func TestPreservingMigrationDoesNotStampBeforeIndexPostconditions(t *testing.T) { + path, header := seedIndexMigrationAnalysis(t, 40) + migrations := append([]schemaMigration(nil), schemaMigrations...) + for i := range migrations { + if migrations[i].version == 41 { + migrations[i].inPlace = func(tx *sql.Tx) error { + if err := createEffectiveWorkspaceIndex(tx); err != nil { + return err + } + // The step commits, then the mandatory core-index tail must fail. This + // tests the final completion stamp, not only migration transaction rollback. + _, err := tx.Exec(`DROP INDEX nodes_by_kind; CREATE VIEW nodes_by_kind AS SELECT 1 AS blocker`) + return err + } + } + } + opened, err := openWith(path, 41, migrations, false) + require.ErrorContains(t, err, "sqlite core index") + require.Nil(t, opened) + withRawDB(t, path, func(db *sql.DB) { + version, err := readUserVersion(db) + require.NoError(t, err) + require.Equal(t, 40, version, "postcondition failure must remain retryable") + var active, ready, index int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM analysis_active_generation WHERE generation_id = ?`, header.GenerationID).Scan(&active)) + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM analysis_generations WHERE generation_id = ? AND state = 1`, header.GenerationID).Scan(&ready)) + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM sqlite_schema WHERE type = 'index' AND name = 'nodes_by_effective_workspace'`).Scan(&index)) + require.Equal(t, 1, active) + require.Equal(t, 1, ready) + require.Equal(t, 1, index, "actual index migration committed before tail refusal") + _, err = db.Exec(`DROP VIEW nodes_by_kind`) + require.NoError(t, err) + }) + retried, err := Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = retried.Close() }) + requireKeptIndexMigrationAnalysis(t, retried, header) +} + +func TestAnalysisPreservationRequiresEveryKnownAppliedStep(t *testing.T) { + neutral := func(version int) schemaMigration { + return schemaMigration{version: version, inPlace: func(*sql.Tx) error { return nil }, preserveAnalysis: true} + } + cases := []struct { + name string + stored, current int + steps []schemaMigration + wipe, stamp, want bool + }{ + {name: "known preserving", stored: 40, current: 41, steps: []schemaMigration{neutral(41)}, stamp: true, want: true}, + {name: "fresh unknown", stored: 0, current: 1, steps: []schemaMigration{neutral(1)}, stamp: true}, + {name: "missing registry step", stored: 39, current: 41, steps: []schemaMigration{neutral(41)}, stamp: true}, + {name: "mixed default", stored: 39, current: 41, steps: []schemaMigration{{version: 40, inPlace: func(*sql.Tx) error { return nil }}, neutral(41)}, stamp: true}, + {name: "rebuild", stored: 40, current: 41, steps: []schemaMigration{neutral(41)}, wipe: true, stamp: true}, + {name: "not applied", stored: 40, current: 41, steps: []schemaMigration{neutral(41)}}, + {name: "empty unknown transition", stored: 40, current: 41, stamp: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + plan := schemaPlan{inPlace: tc.steps, wipe: tc.wipe, stamp: tc.stamp} + require.Equal(t, tc.want, plan.preservesAnalysis(tc.stored, tc.current)) + }) + } + require.Error(t, validateSchemaMigrations(2, []schemaMigration{{version: 2, rebuild: true, preserveAnalysis: true}})) +} diff --git a/internal/graph/store_sqlite/schema_version.go b/internal/graph/store_sqlite/schema_version.go index 2e81316b0..1ec194f5f 100644 --- a/internal/graph/store_sqlite/schema_version.go +++ b/internal/graph/store_sqlite/schema_version.go @@ -35,7 +35,7 @@ import ( // index changes in a way an old on-disk DB would not already have, and append a // matching schemaMigrations entry describing how to bring an older store // forward (in place, or by rebuild). -const currentSchemaVersion = 40 +const currentSchemaVersion = 43 // schemaMigration is one forward step. Exactly one strategy applies: // - rebuild=true: the change introduces structure/data that can only come @@ -50,6 +50,9 @@ type schemaMigration struct { name string inPlace func(tx *sql.Tx) error rebuild bool + // Explicit opt-in for a mechanically additive step that cannot change + // graph or derived-analysis meaning. Defaults remain conservative. + preserveAnalysis bool } // MigrationPhase identifies one observable boundary in an in-place schema @@ -138,6 +141,27 @@ var schemaMigrations = []schemaMigration{ {version: 38, name: "cover exact node declaration reads", inPlace: coverNodeDeclarationIndex}, {version: 39, name: "seek supplementary name candidates by name length", inPlace: seekNameCandidatesByLength}, {version: 40, name: "reconcile SQLite 3.54 name length keys", inPlace: reconcileNameCandidateLengths}, + {version: 41, name: "seek exact effective workspace nodes", inPlace: createEffectiveWorkspaceIndex, preserveAnalysis: true}, + {version: 42, name: "seek eligible workspace dead-code candidates", inPlace: createDeadCodeEligibleWorkspaceIndex, preserveAnalysis: true}, + {version: 43, name: "cover workspace dead-code candidate seeks", inPlace: coverDeadCodeEligibleWorkspaceIndex, preserveAnalysis: true}, +} + +// An existing v42 key must be replaced: IF NOT EXISTS cannot upgrade its shape. +func coverDeadCodeEligibleWorkspaceIndex(tx *sql.Tx) error { + if _, err := tx.Exec(`DROP INDEX IF EXISTS nodes_deadcode_eligible_workspace`); err != nil { + return err + } + return createDeadCodeEligibleWorkspaceIndex(tx) +} + +func createDeadCodeEligibleWorkspaceIndex(tx *sql.Tx) error { + _, err := tx.Exec(nodesDeadCodeEligibleWorkspaceDDL) + return err +} + +func createEffectiveWorkspaceIndex(tx *sql.Tx) error { + _, err := tx.Exec(nodesByEffectiveWorkspaceDDL) + return err } // Rebuild just the secondary index; graph payload, identity and receipts stay @@ -1230,6 +1254,21 @@ type schemaPlan struct { err error // refuse unsupported versions without mutation } +// preservesAnalysis requires a known, complete transition consisting solely +// of explicitly neutral in-place steps. Fresh/pre-versioning stamps, missing +// registry steps and rebuilds never prove compatibility of existing payloads. +func (p schemaPlan) preservesAnalysis(stored, current int) bool { + if p.err != nil || p.wipe || !p.stamp || stored <= 0 || stored >= current || len(p.inPlace) != current-stored { + return false + } + for i, step := range p.inPlace { + if step.version != stored+i+1 || step.inPlace == nil || step.rebuild || !step.preserveAnalysis { + return false + } + } + return true +} + // planSchemaMigrationWith decides how to reconcile a store at the stored // PRAGMA user_version to current, given the migration registry. It mutates // nothing. Open passes (currentSchemaVersion, schemaMigrations); tests pass @@ -1318,6 +1357,9 @@ func validateSchemaMigrations(current int, migs []schemaMigration) error { if (m.inPlace != nil) == m.rebuild { return fmt.Errorf("migration v%d (%s) must set exactly one of inPlace / rebuild", m.version, m.name) } + if m.preserveAnalysis && m.rebuild { + return fmt.Errorf("migration v%d (%s) cannot preserve analysis across a rebuild", m.version, m.name) + } prev = m.version } if prev != current { diff --git a/internal/graph/store_sqlite/scoped_dead_code.go b/internal/graph/store_sqlite/scoped_dead_code.go new file mode 100644 index 000000000..ab01e9c3b --- /dev/null +++ b/internal/graph/store_sqlite/scoped_dead_code.go @@ -0,0 +1,350 @@ +package store_sqlite + +import ( + "context" + "encoding/json" + "iter" + "slices" + "sort" + + "github.com/zzet/gortex/internal/graph" +) + +var _ graph.ScopedDeadCodeReader = (*Store)(nil) +var _ graph.FilteredScopedDeadCodeReader = (*Store)(nil) +var _ graph.NodeScopedDeadCodeReader = (*Store)(nil) +var _ graph.FilteredNodeScopedDeadCodeReader = (*Store)(nil) + +const effectiveWorkspaceSQL = `COALESCE(NULLIF(workspace_id, ''), repo_prefix)` +const nodesByEffectiveWorkspaceDDL = `CREATE INDEX IF NOT EXISTS nodes_by_effective_workspace ON nodes(` + effectiveWorkspaceSQL + `, view_gen, kind, id)` +const nodesDeadCodeEligibleWorkspaceDDL = `CREATE INDEX IF NOT EXISTS nodes_deadcode_eligible_workspace ON nodes(` + effectiveWorkspaceSQL + `, view_gen, kind, id, external, workspace_id, repo_prefix) WHERE external IS NOT 1` + +// Ordinary workspace nodes and the unowned global-external exception occupy +// disjoint effective-workspace buckets. The complete NodeScope predicate still +// checks every row, including project/repo policy and the external path proof. +func deadCodeWorkspaceBuckets(scope *graph.NodeScope, prefixes []string) []string { + if prefixes == nil && scope != nil && scope.WorkspaceID != "" { + return []string{scope.WorkspaceID, ""} + } + return nil +} + +func (s *Store) DeadCodeCandidatesInScope(kinds []graph.NodeKind, incoming map[graph.NodeKind][]graph.EdgeKind, prefixes []string, scope graph.NodeScope, exclude graph.DeadCodeCandidateExclusions) []*graph.Node { + nodes, _ := s.DeadCodeCandidatesInScopeFiltered(kinds, incoming, prefixes, scope, graph.OutlineEdgeFilter{}, exclude) + return nodes +} + +func sortedDeadCodeRepos(prefixes []string) []string { + if prefixes == nil { + return nil + } + out := slices.Clone(prefixes) + slices.Sort(out) + return slices.Compact(out) +} + +// nodes is WITHOUT ROWID: nodes_by_repo(repo_prefix, view_gen) also carries +// the remaining primary-key id. Repo/generation equality makes id a keyset +// seek and supplies ORDER BY without repeatedly sorting the ownership bucket. +func deadCodeScopedVerdictPageSQL(allowed int) string { + q, _ := deadCodeFilteredVerdictPageSQL(allowed, true, graph.OutlineEdgeFilter{}) + return q +} + +func deadCodeFilteredVerdictPageSQL(allowed int, scoped bool, filter graph.OutlineEdgeFilter) (string, []any) { + return deadCodeNodeScopeVerdictPageSQL(allowed, scoped, false, nil, filter) +} + +func deadCodeNodeScopeVerdictPageSQL(allowed int, scoped, workspaceSeek bool, scope *graph.NodeScope, filter graph.OutlineEdgeFilter, exclusions ...graph.DeadCodeCandidateExclusions) (string, []any) { + return deadCodeNodeScopeVerdictPageSQLInWorkspace(allowed, scoped, workspaceSeek, false, scope, filter, exclusions...) +} + +func deadCodeNodeScopeVerdictPageSQLInWorkspace(allowed int, scoped, workspaceSeek, workspaceProven bool, scope *graph.NodeScope, filter graph.OutlineEdgeFilter, exclusions ...graph.DeadCodeCandidateExclusions) (string, []any) { + probe := `NOT EXISTS (SELECT 1 FROM edges e ` + probe += `WHERE e.to_id = n.id AND e.view_gen = n.view_gen` + if allowed > 0 { + probe += ` AND e.kind IN (` + inPlaceholders(allowed) + `)` + } + edgeSQL, edgeArgs := outlineEdgePredicate(filter, "", "n") + viaSQL, viaArgs := outlineViaPredicate(filter.ExcludeVia) + probe += edgeSQL + if len(filter.ExcludeNodeKinds) > 0 { + // nodes_by_kind carries the WITHOUT ROWID identity. Looking for an + // excluded kind avoids decoding/seeking the wide source node per edge. + // Missing/NULL kinds still count as incoming usage, as with LEFT JOIN. + data, _ := json.Marshal(filter.ExcludeNodeKinds) + probe += ` AND NOT EXISTS (SELECT 1 FROM nodes hidden_source INDEXED BY nodes_by_kind WHERE hidden_source.view_gen=e.view_gen AND hidden_source.kind IN (SELECT CAST(value AS TEXT) FROM json_each(?)) AND hidden_source.id=e.from_id)` + edgeArgs = append(edgeArgs, string(data)) + } + probe += viaSQL + `)` + q := `SELECT n.id, ` + probe + ` FROM nodes AS n` + if scoped { + q += ` INDEXED BY nodes_by_repo WHERE n.view_gen = ? AND n.repo_prefix = ?` + } else if workspaceSeek { + index := "nodes_by_effective_workspace" + if len(exclusions) > 0 && exclusions[0].External { + // Only explicit eligible-candidate reads imply this partial key. + // Raw scope reads still include marked external declarations. + index = "nodes_deadcode_eligible_workspace" + } + q += ` INDEXED BY ` + index + ` WHERE n.view_gen = ? AND ` + effectiveWorkspaceSQL + ` = ?` + } else { + q += ` WHERE n.view_gen = ?` + } + args := append(edgeArgs, viaArgs...) + if len(exclusions) > 0 { + q += deadCodeCandidateExclusionSQL(exclusions[0]) + } + if scope != nil { + predicate, scopeArgs := outlineScopePredicateWithWorkspace(*scope, "n", workspaceProven) + q += ` AND ` + predicate + args = append(args, scopeArgs...) + } + q += ` AND n.id > ? AND n.kind = ? ORDER BY n.id LIMIT ?` + return q, args +} + +func deadCodeCandidateExclusionSQL(exclude graph.DeadCodeCandidateExclusions) string { + var q string + if exclude.External { + // NULL includes legacy blob-only metadata. Keep those rows for the + // decoded analyzer check instead of assuming absent promoted data is false. + q += ` AND n.external IS NOT 1` + } + if exclude.Stubs { + // Match graph.StubKind's first two segments, case sensitively. The + // generated is_stub column uses LIKE and cannot distinguish MODULE. + for _, kind := range []string{graph.StubKindStdlib, graph.StubKindBuiltin, graph.StubKindExternalCall, graph.StubKindModule} { + q += ` AND NOT (n.id GLOB '` + kind + `::*' OR (instr(n.id, '::') > 0 AND substr(n.id, instr(n.id, '::') + 2) GLOB '` + kind + `::*'))` + } + } + return q +} + +const deadCodeScopedFilesPageSQL = `SELECT ` + lookupNodeCols + ` FROM nodes INDEXED BY nodes_by_repo +WHERE view_gen = ? AND repo_prefix = ? AND id > ? AND kind = ? ORDER BY id LIMIT ?` + +func (s *Store) DeadCodeCandidatesInRepos(kinds []graph.NodeKind, incoming map[graph.NodeKind][]graph.EdgeKind, prefixes []string) []*graph.Node { + out, _ := s.DeadCodeCandidatesInReposContext(s.readContext(), kinds, incoming, prefixes) + return out +} + +// DeadCodeCandidatesInReposContext seeks only the requested ownership buckets. +// The same-generation incoming-usage probe deliberately has no repo predicate: +// a caller in another repo keeps the candidate alive. +func (s *Store) DeadCodeCandidatesInReposContext(ctx context.Context, kinds []graph.NodeKind, incoming map[graph.NodeKind][]graph.EdgeKind, prefixes []string) ([]*graph.Node, error) { + if prefixes == nil { + return s.DeadCodeCandidatesContext(ctx, kinds, incoming) + } + return s.deadCodeCandidatesInReposFiltered(ctx, kinds, incoming, prefixes, graph.OutlineEdgeFilter{}) +} + +func (s *Store) DeadCodeCandidatesInReposFiltered(kinds []graph.NodeKind, incoming map[graph.NodeKind][]graph.EdgeKind, prefixes []string, filter graph.OutlineEdgeFilter) ([]*graph.Node, error) { + return s.deadCodeCandidatesInReposFiltered(s.readContext(), kinds, incoming, prefixes, filter) +} + +func (s *Store) deadCodeCandidatesInReposFiltered(ctx context.Context, kinds []graph.NodeKind, incoming map[graph.NodeKind][]graph.EdgeKind, prefixes []string, filter graph.OutlineEdgeFilter) ([]*graph.Node, error) { + return s.deadCodeCandidatesInScopeFiltered(ctx, kinds, incoming, prefixes, nil, filter) +} + +func (s *Store) DeadCodeCandidatesInScopeFiltered(kinds []graph.NodeKind, incoming map[graph.NodeKind][]graph.EdgeKind, prefixes []string, scope graph.NodeScope, filter graph.OutlineEdgeFilter, exclude graph.DeadCodeCandidateExclusions) ([]*graph.Node, error) { + return s.deadCodeCandidatesInScopeFiltered(s.readContext(), kinds, incoming, prefixes, &scope, filter, exclude) +} + +func (s *Store) deadCodeCandidatesInScopeFiltered(ctx context.Context, kinds []graph.NodeKind, incoming map[graph.NodeKind][]graph.EdgeKind, prefixes []string, scope *graph.NodeScope, filter graph.OutlineEdgeFilter, exclusions ...graph.DeadCodeCandidateExclusions) ([]*graph.Node, error) { + repos := sortedDeadCodeRepos(prefixes) + if prefixes == nil { + repos = []string{""} // One global pass; no ownership predicate or argument. + } + workspaceBuckets := deadCodeWorkspaceBuckets(scope, prefixes) + if workspaceBuckets != nil { + repos = workspaceBuckets + } + + var out []*graph.Node + for _, kind := range kinds { + var candidates []*graph.Node + allowed := anaDedupeEdgeKinds(incoming[kind]) + for _, prefix := range repos { + workspaceProven := workspaceBuckets != nil && prefix != "" && scope != nil && prefix == scope.WorkspaceID + q, filterArgs := deadCodeNodeScopeVerdictPageSQLInWorkspace(len(allowed), prefixes != nil, workspaceBuckets != nil, workspaceProven, scope, filter, exclusions...) + // SELECT-list exclusions precede generation/ownership and scope. + var scopeArgs []any + if scope != nil { + _, scopeArgs = outlineScopePredicateWithWorkspace(*scope, "n", workspaceProven) + filterArgs = filterArgs[:len(filterArgs)-len(scopeArgs)] + } + after := "" + for { + if err := ctx.Err(); err != nil { + return append(out, candidates...), err + } + args := make([]any, 0, len(allowed)+5) + for _, edgeKind := range allowed { + args = append(args, string(edgeKind)) + } + args = append(args, filterArgs...) + args = append(args, s.viewGen) + if prefixes != nil || workspaceBuckets != nil { + args = append(args, prefix) + } + args = append(args, scopeArgs...) + args = append(args, after, string(kind), deadCodePageSize) + dead, scanned, lastID, err := s.scanDeadCodeVerdictPage(ctx, q, args...) + if err != nil { + return append(out, candidates...), err + } + if len(dead) > 0 { + nodes, err := s.nodesByIDsOrdered(ctx, dead) + candidates = append(candidates, nodes...) + if err != nil { + return append(out, candidates...), err + } + } + if scanned < deadCodePageSize { + break + } + after = lastID + } + } + // Preserve the unscoped kind/id order even when prefix spelling and + // node ids do not share an ordering convention. + sort.Slice(candidates, func(i, j int) bool { return candidates[i].ID < candidates[j].ID }) + out = append(out, candidates...) + } + return out, nil +} + +func (s *Store) IfaceImplementsRowsInRepos(prefixes []string) []graph.IfaceImplementsRow { + out, _ := s.IfaceImplementsRowsInReposContext(s.readContext(), prefixes) + return out +} + +func (s *Store) IfaceImplementsRowsInReposContext(ctx context.Context, prefixes []string) ([]graph.IfaceImplementsRow, error) { + return s.ifaceImplementsRowsInReposFiltered(ctx, prefixes, graph.OutlineEdgeFilter{}) +} + +func (s *Store) IfaceImplementsRowsInReposFiltered(prefixes []string, filter graph.OutlineEdgeFilter) ([]graph.IfaceImplementsRow, error) { + return s.ifaceImplementsRowsInReposFiltered(s.readContext(), prefixes, filter) +} + +func (s *Store) ifaceImplementsRowsInReposFiltered(ctx context.Context, prefixes []string, filter graph.OutlineEdgeFilter) ([]graph.IfaceImplementsRow, error) { + repos := sortedDeadCodeRepos(prefixes) + if prefixes == nil { + repos = []string{""} + } + var out []graph.IfaceImplementsRow + for _, prefix := range repos { + var q string + var args []any + if prefixes == nil { + // Preserve legacy dangling implementing-type tuples in the global view. + q = `SELECT e.from_id, n.id, n.meta FROM edges e +LEFT JOIN nodes implementing ON implementing.id = e.from_id AND implementing.view_gen = e.view_gen +JOIN nodes n ON n.id = e.to_id AND n.view_gen = e.view_gen +WHERE e.kind = ? AND n.kind = ? AND n.meta IS NOT NULL AND e.view_gen = ?` + args = []any{string(graph.EdgeImplements), string(graph.KindInterface), s.viewGen} + } else { + q = `SELECT e.from_id, n.id, n.meta +FROM nodes AS implementing INDEXED BY nodes_by_repo +CROSS JOIN edges e INDEXED BY edges_by_from ON e.from_id = implementing.id AND e.view_gen = implementing.view_gen AND e.kind = ? +JOIN nodes n ON n.id = e.to_id AND n.view_gen = e.view_gen +WHERE implementing.view_gen = ? AND implementing.repo_prefix = ? AND n.kind = ? AND n.meta IS NOT NULL` + args = []any{string(graph.EdgeImplements), s.viewGen, prefix, string(graph.KindInterface)} + } + edgeSQL, edgeArgs := outlineEdgePredicate(filter, "implementing", "n") + viaSQL, viaArgs := outlineViaPredicate(filter.ExcludeVia) + args = append(args, edgeArgs...) + args = append(args, viaArgs...) + rows, err := outlineRows(ctx, s.db, outlineQuery{sql: q + edgeSQL + viaSQL, args: args}) + if err != nil { + return nil, err + } + part, err := scanIfaceImplementsRows(rows) + if err != nil { + return nil, err + } + out = append(out, part...) + } + return out, nil +} + +func (s *Store) NodesByKindInScope(kind graph.NodeKind, prefixes []string, scope graph.NodeScope) iter.Seq[*graph.Node] { + return func(yield func(*graph.Node) bool) { + // File metadata is needed for framework-entrypoint exclusions. Filter + // in SQL before decoding it, retaining the exact selected generation. + predicate, scopeArgs := outlineScopePredicate(scope, "n") + buckets := deadCodeWorkspaceBuckets(&scope, prefixes) + q := `SELECT ` + lookupNodeCols + ` FROM nodes n` + if buckets != nil { + q += ` INDEXED BY nodes_by_effective_workspace` + } + if prefixes != nil { + if len(prefixes) == 0 { + return + } + buckets = sortedDeadCodeRepos(prefixes) + q += ` INDEXED BY nodes_by_repo` + } + q += ` WHERE n.view_gen = ? AND ` + predicate + if prefixes != nil { + q += ` AND n.repo_prefix = ?` + } else if buckets != nil { + q += ` AND ` + effectiveWorkspaceSQL + ` = ?` + } + q += ` AND n.id > ? AND n.kind = ? ORDER BY n.id LIMIT ?` + if buckets == nil { + buckets = []string{""} + } + for _, bucket := range buckets { + after := "" + for { + args := append([]any{s.viewGen}, scopeArgs...) + if prefixes != nil || scope.WorkspaceID != "" { + args = append(args, bucket) + } + args = append(args, after, string(kind), deadCodePageSize) + page, scanned, lastID, err := s.scanNodePage(s.readContext(), q, args...) + if err != nil { + return + } + for _, node := range page { + if !yield(node) { + return + } + } + if scanned < deadCodePageSize { + break + } + after = lastID + } + } + } +} + +func (s *Store) NodesByKindInRepos(kind graph.NodeKind, prefixes []string) iter.Seq[*graph.Node] { + if prefixes == nil { + return s.NodesByKind(kind) + } + return func(yield func(*graph.Node) bool) { + ctx := s.readContext() + for _, prefix := range sortedDeadCodeRepos(prefixes) { + after := "" + for { + page, scanned, lastID, err := s.scanNodePage(ctx, deadCodeScopedFilesPageSQL, s.viewGen, prefix, after, string(kind), deadCodePageSize) + if err != nil { + return + } + for _, node := range page { + if !yield(node) { + return + } + } + if scanned < deadCodePageSize { + break + } + after = lastID + } + } + } +} diff --git a/internal/graph/store_sqlite/scoped_dead_code_test.go b/internal/graph/store_sqlite/scoped_dead_code_test.go new file mode 100644 index 000000000..98672679b --- /dev/null +++ b/internal/graph/store_sqlite/scoped_dead_code_test.go @@ -0,0 +1,119 @@ +package store_sqlite + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +func TestDeadCodeScopedPagesPreserveGenerationAndGlobalIncoming(t *testing.T) { + previous := deadCodePageSize + deadCodePageSize = 5 + t.Cleanup(func() { deadCodePageSize = previous }) + s, _ := openTempStore(t) + writeDeadCodeFixture(t, s, []int64{0, 4}, 90) + for _, generation := range []int64{0, 4} { + nodes := []*graph.Node{{ID: "repo/cross-generation", Kind: graph.KindFunction, RepoPrefix: "repo"}} + for i := range 14 { + nodes = append(nodes, &graph.Node{ID: fmt.Sprintf("repo/file%d", i), Kind: graph.KindFile, RepoPrefix: "repo"}) + } + require.NoError(t, s.AtGeneration(generation).AddBatchChecked(nodes, nil)) + } + require.NoError(t, s.AtGeneration(4).AddBatchChecked([]*graph.Node{ + {ID: "outside/caller", Kind: graph.KindFunction, RepoPrefix: "outside"}, + }, []*graph.Edge{{From: "outside/caller", To: "repo/cross-generation", Kind: graph.EdgeCalls}})) + kinds := []graph.NodeKind{graph.KindFunction, graph.KindMethod, graph.KindType} + allowed := map[graph.NodeKind][]graph.EdgeKind{ + graph.KindFunction: {graph.EdgeCalls}, graph.KindMethod: {graph.EdgeCalls}, graph.KindType: {graph.EdgeReferences}, + } + for _, generation := range []int64{0, 4} { + h := s.AtGeneration(generation) + var want []*graph.Node + for _, node := range legacyDeadCodeCandidates(h, kinds, allowed) { + if node.RepoPrefix == "repo" { + want = append(want, node) + } + } + got, err := h.DeadCodeCandidatesInReposContext(t.Context(), kinds, allowed, []string{"repo", "repo"}) + require.NoError(t, err) + require.Equal(t, want, got, "generation=%d", generation) + crossGenerationDead := slices.ContainsFunc(got, func(n *graph.Node) bool { return n.ID == "repo/cross-generation" }) + require.Equal(t, generation == 0, crossGenerationDead, "only same-generation incoming edges count, regardless of caller repo") + require.Len(t, slices.Collect(h.NodesByKindInRepos(graph.KindFile, []string{"repo"})), 14) + } +} + +func TestDeadCodeScopedPagesSeekWithoutSorting(t *testing.T) { + s, _ := openTempStore(t) + writeDeadCodeFixture(t, s, []int64{0, 4}, 90) + for name, q := range map[string]string{ + "candidate": deadCodeScopedVerdictPageSQL(0), "files": deadCodeScopedFilesPageSQL, + } { + plan := generationLookupPlan(t, s.db, q, int64(0), "repo", "frontier", "function", 5) + require.Contains(t, plan, "nodes_by_repo (repo_prefix=? AND view_gen=? AND id>?)", "%s: %s", name, plan) + require.NotContains(t, strings.ToUpper(plan), "TEMP B-TREE", "%s: %s", name, plan) + require.NotContains(t, plan, "SCAN n", "%s: %s", name, plan) + } + plan := generationLookupPlan(t, s.db, deadCodeScopedVerdictPageSQL(1), "calls", int64(0), "repo", "frontier", "function", 5) + require.Contains(t, plan, "nodes_by_repo (repo_prefix=? AND view_gen=? AND id>?)") + require.NotContains(t, strings.ToUpper(plan), "TEMP B-TREE") +} + +func TestDeadCodeScopedReadContextSurvivesBinding(t *testing.T) { + s, _ := openTempStore(t) + writeDeadCodeFixture(t, s, []int64{0}, 30) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + bound := graph.BindReadContext(s, ctx) + reader, ok := bound.(graph.ScopedDeadCodeReader) + require.True(t, ok) + require.Empty(t, reader.DeadCodeCandidatesInRepos([]graph.NodeKind{graph.KindFunction}, nil, []string{"repo"})) + _, err := s.DeadCodeCandidatesInReposContext(ctx, []graph.NodeKind{graph.KindFunction}, nil, []string{"repo"}) + require.True(t, errors.Is(err, context.Canceled)) + _, err = s.IfaceImplementsRowsInReposContext(ctx, []string{"repo"}) + require.True(t, errors.Is(err, context.Canceled)) + require.Empty(t, slices.Collect(reader.NodesByKindInRepos(graph.KindFile, []string{"repo"}))) +} + +func TestScopedAnalysisReadsSurviveBulkIndexWindow(t *testing.T) { + s, _ := openTempStore(t) + require.NoError(t, s.AddBatchChecked([]*graph.Node{ + {ID: "a/main", Name: "main", Kind: graph.KindFunction, RepoPrefix: "a"}, + {ID: "a/dead", Name: "dead", Kind: graph.KindFunction, RepoPrefix: "a"}, + {ID: "a/file", Kind: graph.KindFile, RepoPrefix: "a"}, + {ID: "a/type", Kind: graph.KindType, RepoPrefix: "a"}, + {ID: "b/caller", Kind: graph.KindFunction, RepoPrefix: "b"}, + {ID: "b/interface", Kind: graph.KindInterface, RepoPrefix: "b", Meta: map[string]any{"methods": []string{"Work"}}}, + }, []*graph.Edge{ + {From: "b/caller", To: "a/main", Kind: graph.EdgeCalls}, + {From: "a/type", To: "b/interface", Kind: graph.EdgeImplements}, + })) + // A cold bulk load deliberately removes secondary indexes. The capability + // remains exact there, with unhinted SQL until index readiness returns. + for _, name := range []string{"nodes_by_repo", "nodes_by_name", "edges_by_from"} { + _, err := s.writerDB.Exec("DROP INDEX " + name) + require.NoError(t, err) + } + nodes, err := s.DeadCodeCandidatesInReposFiltered([]graph.NodeKind{graph.KindFunction}, map[graph.NodeKind][]graph.EdgeKind{graph.KindFunction: {graph.EdgeCalls}}, []string{"a"}, graph.OutlineEdgeFilter{}) + require.NoError(t, err) + require.Len(t, nodes, 1) + require.Equal(t, "a/dead", nodes[0].ID) + rows, err := s.IfaceImplementsRowsInReposFiltered([]string{"a"}, graph.OutlineEdgeFilter{}) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, "b/interface", rows[0].IfaceID) + files := slices.Collect(s.NodesByKindInRepos(graph.KindFile, []string{"a"})) + require.Len(t, files, 1) + require.Equal(t, "a/file", files[0].ID) + mains, err := s.OutlineNodesByName(graph.NodeScope{RepoAllow: map[string]bool{"a": true}}, "main") + require.NoError(t, err) + require.Len(t, mains, 1) + require.Equal(t, "a/main", mains[0].ID) +} diff --git a/internal/graph/store_sqlite/scoped_outline.go b/internal/graph/store_sqlite/scoped_outline.go index 73d25e4aa..68cf8a294 100644 --- a/internal/graph/store_sqlite/scoped_outline.go +++ b/internal/graph/store_sqlite/scoped_outline.go @@ -14,10 +14,16 @@ import ( // outlineScopePredicate is the SQL twin of graph.NodeScope.Allows. GLOB keeps // the external path check case-sensitive, like strings.HasPrefix in Go. func outlineScopePredicate(scope graph.NodeScope, alias string) (string, []any) { + return outlineScopePredicateWithWorkspace(scope, alias, false) +} + +// A nonempty exact effective-workspace seek can discharge only the workspace +// clause. Project and repo intersections still use the original scope. +func outlineScopePredicateWithWorkspace(scope graph.NodeScope, alias string, workspaceProven bool) (string, []any) { var terms []string var args []any global := `(` + alias + `.repo_prefix = '' AND ` + alias + `.workspace_id = '' AND (` + alias + `.file_path GLOB 'external::*' OR ` + alias + `.file_path GLOB 'external-call::*'))` - if scope.WorkspaceID != "" { + if scope.WorkspaceID != "" && !workspaceProven { terms = append(terms, `(`+global+` OR COALESCE(NULLIF(`+alias+`.workspace_id, ''), `+alias+`.repo_prefix) = ?)`) args = append(args, scope.WorkspaceID) } @@ -214,6 +220,10 @@ func outlineRows(ctx context.Context, tx outlineQueryer, query outlineQuery) (*s if isNoSuchIndexErr(err) { unhinted := strings.NewReplacer( " INDEXED BY nodes_by_repo", "", + " INDEXED BY nodes_by_kind", "", + " INDEXED BY nodes_by_effective_workspace", "", + " INDEXED BY nodes_deadcode_eligible_workspace", "", + " INDEXED BY nodes_by_name", "", " INDEXED BY edges_by_from", "", " INDEXED BY edges_by_to", "", ).Replace(query.sql) diff --git a/internal/graph/store_sqlite/store.go b/internal/graph/store_sqlite/store.go index 33f4e11ac..110f85e60 100644 --- a/internal/graph/store_sqlite/store.go +++ b/internal/graph/store_sqlite/store.go @@ -910,8 +910,8 @@ func openWithObserver(path string, current int, migrations []schemaMigration, al // Apply any in-place migration steps. The version stamp is deliberately // deferred until every migration-dependent postcondition below succeeds: - // core/sidecar indexes exist and an analysis snapshot produced against the - // old graph shape is durably invalidated. PRAGMA user_version is the next + // core/sidecar indexes exist and any required analysis invalidation has + // completed. PRAGMA user_version is the next // Open's sole completion marker; publishing it earlier would let a crash or // failure in that tail suppress the required repair forever. // Fresh and pre-versioning (stored==0) stores run the in-place steps too — @@ -939,9 +939,10 @@ func openWithObserver(path string, current int, migrations []schemaMigration, al _ = db.Close() return nil, fmt.Errorf("sqlite sidecar index: %w", err) } - // A schema transition invalidates any generation produced against the old - // graph shape. The v4 migration also drops the unreleased blob-only table. - if stored != current { + // Only a fully known transition whose every step explicitly preserves + // analysis can retain the active generation (e.g. the additive v41 index). + // Older/mixed/unknown graph shapes keep conservative invalidation. + if stored != current && !plan.preservesAnalysis(stored, current) { if _, err := db.Exec(`UPDATE analysis_generations SET state = ? WHERE generation_id IN (SELECT generation_id FROM analysis_active_generation)`, analysisGenerationStale); err != nil { _ = db.Close() return nil, fmt.Errorf("sqlite stale analysis generation after migration: %w", err) @@ -3916,6 +3917,14 @@ func (s *Store) GetNodesByIDs(ids []string) map[string]*graph.Node { if len(ids) == 0 { return nil } + if s.readCtx != nil { + nodes, err := s.GetNodesByIDsContext(s.readCtx, ids) + if err != nil { + analysisProjectionReadError(s.readCtx, err) + return nil + } + return nodes + } // Dedupe + skip empty up front to keep the chunk loop honest. seen := make(map[string]struct{}, len(ids)) uniq := make([]string, 0, len(ids)) diff --git a/internal/graph/store_sqlite/store_aggregators.go b/internal/graph/store_sqlite/store_aggregators.go index 339912158..3a490a8f2 100644 --- a/internal/graph/store_sqlite/store_aggregators.go +++ b/internal/graph/store_sqlite/store_aggregators.go @@ -111,13 +111,14 @@ func (s *Store) InEdgeCountsByKind(kinds []graph.EdgeKind) map[string]int { // NodeIDsByKinds returns the deduplicated IDs of every node whose kind // is in the supplied set. func (s *Store) NodeIDsByKinds(kinds []graph.NodeKind) []string { + ctx := s.readContext() _, args := aggDedupeNodeKinds(kinds) if len(args) == 0 { return nil } q := `SELECT id FROM nodes WHERE kind IN (` + inPlaceholders(len(args)) + `) AND view_gen = ? ORDER BY id` - rows, err := s.db.Query(q, append(args, s.viewGen)...) - panicOnFatal(err) + rows, err := s.db.QueryContext(ctx, q, append(args, s.viewGen)...) + analysisProjectionReadError(ctx, err) if rows == nil { // swallowed teardown-race error: read returns empty (see panicOnFatal) return nil @@ -126,10 +127,10 @@ func (s *Store) NodeIDsByKinds(kinds []graph.NodeKind) []string { var out []string for rows.Next() { var id string - panicOnFatal(rows.Scan(&id)) + analysisProjectionReadError(ctx, rows.Scan(&id)) out = append(out, id) } - panicOnFatal(rows.Err()) + analysisProjectionReadError(ctx, rows.Err()) return out } @@ -678,7 +679,7 @@ func (s *Store) NodesByKindsContext(ctx context.Context, kinds []graph.NodeKind) // cancelled ctx is returned as an error, never a panic; other failures go // through panicOnFatal like every node reader. func (s *Store) scanNodePage(ctx context.Context, q string, args ...any) ([]*graph.Node, int, string, error) { - rows, err := s.db.QueryContext(ctx, q, args...) + rows, err := outlineRows(ctx, s.db, outlineQuery{sql: q, args: args}) if err != nil { if ctx.Err() != nil { return nil, 0, "", ctx.Err() @@ -730,6 +731,7 @@ func nodesByKindsPageSQL(kinds int) string { // kind is in edgeKinds and whose endpoints both have a kind in // nodeKinds; honours early-stop. Empty kinds yields nothing. func (s *Store) EdgeAdjacencyForKinds(edgeKinds []graph.EdgeKind, nodeKinds []graph.NodeKind) iter.Seq[[2]string] { + ctx := s.readContext() _, eArgs := aggDedupeEdgeKinds(edgeKinds) _, nArgs := aggDedupeNodeKinds(nodeKinds) return func(yield func([2]string) bool) { @@ -748,8 +750,8 @@ func (s *Store) EdgeAdjacencyForKinds(edgeKinds []graph.EdgeKind, nodeKinds []gr AND nf.kind IN (` + inPlaceholders(len(nArgs)) + `) AND nt.kind IN (` + inPlaceholders(len(nArgs)) + `) AND e.view_gen = ?` - rows, err := s.db.Query(q, args...) - panicOnFatal(err) + rows, err := s.db.QueryContext(ctx, q, args...) + analysisProjectionReadError(ctx, err) if rows == nil { // swallowed teardown-race error: read returns empty (see panicOnFatal) return @@ -757,12 +759,12 @@ func (s *Store) EdgeAdjacencyForKinds(edgeKinds []graph.EdgeKind, nodeKinds []gr defer rows.Close() for rows.Next() { var from, to string - panicOnFatal(rows.Scan(&from, &to)) + analysisProjectionReadError(ctx, rows.Scan(&from, &to)) if !yield([2]string{from, to}) { return } } - panicOnFatal(rows.Err()) + analysisProjectionReadError(ctx, rows.Err()) } } @@ -819,6 +821,7 @@ func (s *Store) NodeDegreeCounts(ids []string, usageKinds []graph.EdgeKind) []gr // and fan-out (outgoing edges in fanOutKinds) for the supplied id set. // Unknown ids produce no row; duplicates collapse. func (s *Store) NodeFanCounts(ids []string, fanInKinds, fanOutKinds []graph.EdgeKind) []graph.NodeFanRow { + ctx := s.readContext() uniq := dedupeNonEmpty(ids) if len(uniq) == 0 { return nil @@ -853,18 +856,18 @@ func (s *Store) NodeFanCounts(ids []string, fanInKinds, fanOutKinds []graph.Edge args = append(args, outInline...) args = append(args, toAnyArgs(chunk)...) args = append(args, s.viewGen) - rows, err := s.db.Query(q, args...) - panicOnFatal(err) + rows, err := s.db.QueryContext(ctx, q, args...) + analysisProjectionReadError(ctx, err) if rows == nil { // swallowed teardown-race error: read returns empty (see panicOnFatal) return out } for rows.Next() { var r graph.NodeFanRow - panicOnFatal(rows.Scan(&r.NodeID, &r.FanIn, &r.FanOut)) + analysisProjectionReadError(ctx, rows.Scan(&r.NodeID, &r.FanIn, &r.FanOut)) out = append(out, r) } - panicOnFatal(rows.Err()) + analysisProjectionReadError(ctx, rows.Err()) _ = rows.Close() } return out @@ -877,13 +880,14 @@ func (s *Store) NodeFanCounts(ids []string, fanInKinds, fanOutKinds []graph.Edge // community map returns nil. The community comparison runs Go-side // because community membership is not a node column. func (s *Store) CommunityCrossingsByKind(kinds []graph.EdgeKind, nodeToComm map[string]string) map[string]int { + ctx := s.readContext() _, args := aggDedupeEdgeKinds(kinds) if len(args) == 0 || len(nodeToComm) == 0 { return nil } q := `SELECT from_id, to_id FROM edges WHERE kind IN (` + inPlaceholders(len(args)) + `) AND view_gen = ?` - rows, err := s.db.Query(q, append(args, s.viewGen)...) - panicOnFatal(err) + rows, err := s.db.QueryContext(ctx, q, append(args, s.viewGen)...) + analysisProjectionReadError(ctx, err) if rows == nil { // swallowed teardown-race error: read returns empty (see panicOnFatal) return nil @@ -892,7 +896,7 @@ func (s *Store) CommunityCrossingsByKind(kinds []graph.EdgeKind, nodeToComm map[ out := make(map[string]int) for rows.Next() { var from, to string - panicOnFatal(rows.Scan(&from, &to)) + analysisProjectionReadError(ctx, rows.Scan(&from, &to)) fromComm, ok := nodeToComm[from] if !ok { continue @@ -905,7 +909,7 @@ func (s *Store) CommunityCrossingsByKind(kinds []graph.EdgeKind, nodeToComm map[ out[from]++ } } - panicOnFatal(rows.Err()) + analysisProjectionReadError(ctx, rows.Err()) if len(out) == 0 { return nil } diff --git a/internal/graph/store_sqlite/store_analysis.go b/internal/graph/store_sqlite/store_analysis.go index 17b644220..d909b425a 100644 --- a/internal/graph/store_sqlite/store_analysis.go +++ b/internal/graph/store_sqlite/store_analysis.go @@ -122,11 +122,15 @@ func (s *Store) DeadCodeCandidatesContext(ctx context.Context, allowedNodeKinds // the pairing an incoming edge from any other generation answers "used". // With no allowed edge kinds, any incoming edge counts as usage. func deadCodeVerdictPageSQL(allowedEdgeKinds int) string { + return `SELECT n.id, ` + deadCodeIncomingProbeSQL(allowedEdgeKinds) + ` FROM nodes n WHERE n.view_gen = ? AND n.id > ? AND n.kind = ? ORDER BY n.id LIMIT ?` +} + +func deadCodeIncomingProbeSQL(allowedEdgeKinds int) string { probe := `NOT EXISTS (SELECT 1 FROM edges e WHERE e.to_id = n.id AND e.view_gen = n.view_gen)` if allowedEdgeKinds > 0 { probe = `NOT EXISTS (SELECT 1 FROM edges e WHERE e.to_id = n.id AND e.kind IN (` + inPlaceholders(allowedEdgeKinds) + `) AND e.view_gen = n.view_gen)` } - return `SELECT n.id, ` + probe + ` FROM nodes n WHERE n.view_gen = ? AND n.id > ? AND n.kind = ? ORDER BY n.id LIMIT ?` + return probe } // deadCodeVerdictPage runs one verdict page: the dead ids (in id order), how @@ -135,7 +139,11 @@ func (s *Store) deadCodeVerdictPage(ctx context.Context, q string, args ...any) // The probe's edge-kind placeholders sit in the SELECT list, before the // WHERE clause's; reorder the arguments to match. ordered := reorderDeadCodeArgs(args) - rows, err := s.db.QueryContext(ctx, q, ordered...) + return s.scanDeadCodeVerdictPage(ctx, q, ordered...) +} + +func (s *Store) scanDeadCodeVerdictPage(ctx context.Context, q string, args ...any) ([]string, int, string, error) { + rows, err := outlineRows(ctx, s.db, outlineQuery{sql: q, args: args}) if err != nil { if ctx.Err() != nil { return nil, 0, "", ctx.Err() @@ -218,6 +226,11 @@ WHERE e.kind = ? AND n.kind = ? AND n.meta IS NOT NULL AND e.view_gen = ?` if err != nil { return nil } + out, _ := scanIfaceImplementsRows(rows) + return out +} + +func scanIfaceImplementsRows(rows *sql.Rows) ([]graph.IfaceImplementsRow, error) { defer rows.Close() var out []graph.IfaceImplementsRow @@ -244,9 +257,9 @@ WHERE e.kind = ? AND n.kind = ? AND n.meta IS NOT NULL AND e.view_gen = ?` // iteration is different in kind — it truncates the projection — so it // fails the whole call the way a failed Query does. if rows.Err() != nil { - return nil + return nil, rows.Err() } - return out + return out, nil } // --- MemberMethodsByType ------------------------------------------------ diff --git a/internal/graph/store_sqlite/store_generation_read_test.go b/internal/graph/store_sqlite/store_generation_read_test.go index 16801eb90..1edbaa454 100644 --- a/internal/graph/store_sqlite/store_generation_read_test.go +++ b/internal/graph/store_sqlite/store_generation_read_test.go @@ -1288,6 +1288,70 @@ func generationReadProbes() []genProbe { return crossRepoTokens(s.CrossRepoCandidatesForMutation([]graph.EdgeKind{graph.EdgeCalls}, []string{genReadFileA}, []string{genReadFileA})) }}, + {name: "ScopedDeadCodeReads", run: func(t *testing.T, s *Store) []string { + repos := []string{genReadRepo} + out := nodeTokens(s.DeadCodeCandidatesInRepos([]graph.NodeKind{graph.KindFunction}, map[graph.NodeKind][]graph.EdgeKind{graph.KindFunction: {graph.EdgeCalls}}, repos)) + for n := range s.NodesByKindInRepos(graph.KindFile, repos) { + out = append(out, nodeTokens([]*graph.Node{n})...) + } + for _, row := range s.IfaceImplementsRowsInRepos(repos) { + out = append(out, fmt.Sprintf("scoped impl %s->%s methods=%v", row.TypeID, row.IfaceID, row.IfaceMeta["methods"])) + } + return out + }}, + {name: "FilteredScopedDeadCodeReads", run: func(t *testing.T, s *Store) []string { + filter := graph.OutlineEdgeFilter{ExcludeKinds: []graph.EdgeKind{graph.EdgeMatches}, ExcludeVia: "spring.Bean"} + nodes, err := s.DeadCodeCandidatesInReposFiltered([]graph.NodeKind{graph.KindFunction}, map[graph.NodeKind][]graph.EdgeKind{graph.KindFunction: {graph.EdgeCalls}}, []string{genReadRepo}, filter) + if err != nil { + t.Fatal(err) + } + out := nodeTokens(nodes) + rows, err := s.IfaceImplementsRowsInReposFiltered([]string{genReadRepo}, filter) + if err != nil { + t.Fatal(err) + } + for _, row := range rows { + out = append(out, fmt.Sprintf("filtered impl %s->%s methods=%v", row.TypeID, row.IfaceID, row.IfaceMeta["methods"])) + } + return out + }}, + {name: "NodeScopedDeadCodeReads", run: func(t *testing.T, s *Store) []string { + scope := graph.NodeScope{WorkspaceID: genReadRepo} + out := nodeTokens(s.DeadCodeCandidatesInScope([]graph.NodeKind{graph.KindFunction}, map[graph.NodeKind][]graph.EdgeKind{graph.KindFunction: {graph.EdgeCalls}}, nil, scope, graph.DeadCodeCandidateExclusions{Stubs: true, External: true})) + for n := range s.NodesByKindInScope(graph.KindFile, nil, scope) { + out = append(out, nodeTokens([]*graph.Node{n})...) + } + return out + }}, + {name: "FilteredNodeScopedDeadCodeReads", run: func(t *testing.T, s *Store) []string { + scope := graph.NodeScope{WorkspaceID: genReadRepo} + filter := graph.OutlineEdgeFilter{ExcludeKinds: []graph.EdgeKind{graph.EdgeMatches}, ExcludeVia: "spring.Bean"} + nodes, err := s.DeadCodeCandidatesInScopeFiltered([]graph.NodeKind{graph.KindFunction}, map[graph.NodeKind][]graph.EdgeKind{graph.KindFunction: {graph.EdgeCalls}}, nil, scope, filter, graph.DeadCodeCandidateExclusions{Stubs: true, External: true}) + if err != nil { + t.Fatal(err) + } + out := nodeTokens(nodes) + return out + }}, + {name: "ScopedOutlineNodes", run: func(t *testing.T, s *Store) []string { + scope := graph.NodeScope{RepoAllow: map[string]bool{genReadRepo: true}} + ids, err := s.OutlineNodeIDsInScope(scope, genReadProbeIDs()) + if err != nil { + t.Fatal(err) + } + var out []string + for _, id := range ids { + out = append(out, "outline id "+id) + } + for _, name := range genReadProbeNames() { + nodes, err := s.OutlineNodesByName(scope, name) + if err != nil { + t.Fatal(err) + } + out = append(out, nodeTokens(nodes)...) + } + return out + }}, {name: "DeadCodeCandidates", run: func(t *testing.T, s *Store) []string { return nodeTokens(s.DeadCodeCandidates( []graph.NodeKind{graph.KindFunction}, @@ -2104,6 +2168,11 @@ func generationCapabilityChecklist() []capabilityCase { {iface: (*graph.FileIdentityBatchEvicter)(nil), skip: skipWrite, writeFence: "TestFileIdentityEvictionIsGenerationScoped"}, {iface: (*graph.FileEditingContext)(nil), probe: "FileEditingContext"}, {iface: (*graph.FileImportAggregator)(nil), probe: "FileImportCounts"}, + {iface: (*graph.ScopedDeadCodeReader)(nil), probe: "ScopedDeadCodeReads"}, + {iface: (*graph.FilteredScopedDeadCodeReader)(nil), probe: "FilteredScopedDeadCodeReads"}, + {iface: (*graph.NodeScopedDeadCodeReader)(nil), probe: "NodeScopedDeadCodeReads"}, + {iface: (*graph.FilteredNodeScopedDeadCodeReader)(nil), probe: "FilteredNodeScopedDeadCodeReads"}, + {iface: (*graph.ScopedOutlineNodeReader)(nil), probe: "ScopedOutlineNodes"}, {iface: (*graph.ScopedOutlineAggregator)(nil), probe: "ScopedOutlineCounts"}, {iface: (*graph.FilteredScopedOutlineAggregator)(nil), probe: "ScopedOutlineCountsFiltered"}, {iface: (*graph.FileImporters)(nil), probe: "FileImporters"}, diff --git a/internal/graph/storetest/scoped_dead_code.go b/internal/graph/storetest/scoped_dead_code.go new file mode 100644 index 000000000..4323b224a --- /dev/null +++ b/internal/graph/storetest/scoped_dead_code.go @@ -0,0 +1,169 @@ +package storetest + +import ( + "fmt" + "slices" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func testScopedDeadCodeReader(t *testing.T, factory Factory) { + s := factory(t) + reader, ok := s.(graph.ScopedDeadCodeReader) + if !ok { + t.Skip("backend does not implement graph.ScopedDeadCodeReader") + } + for _, node := range []*graph.Node{ + {ID: "a-dead", Kind: graph.KindFunction, RepoPrefix: "a"}, + {ID: "a-live", Kind: graph.KindFunction, RepoPrefix: "a"}, + {ID: "a-weak", Kind: graph.KindFunction, RepoPrefix: "a"}, + {ID: "a-missing-caller", Kind: graph.KindFunction, RepoPrefix: "a"}, + {ID: "a-type", Kind: graph.KindType, RepoPrefix: "a"}, + {ID: "a-iface", Kind: graph.KindInterface, RepoPrefix: "a", Meta: map[string]any{"methods": []string{"Work"}}}, + {ID: "a-file", Kind: graph.KindFile, RepoPrefix: "a"}, + {ID: "b-caller", Kind: graph.KindFunction, RepoPrefix: "b"}, + {ID: "b-type", Kind: graph.KindType, RepoPrefix: "b"}, + {ID: "b-iface", Kind: graph.KindInterface, RepoPrefix: "b", Meta: map[string]any{"methods": []string{"Work"}}}, + {ID: "b-file", Kind: graph.KindFile, RepoPrefix: "b"}, + {ID: "unowned-dead", Kind: graph.KindFunction}, + {ID: "unowned-file", Kind: graph.KindFile}, + } { + s.AddNode(node) + } + for _, edge := range []*graph.Edge{ + {From: "b-caller", To: "a-live", Kind: graph.EdgeCalls}, + {From: "b-caller", To: "a-weak", Kind: graph.EdgeReads}, + {From: "missing-caller", To: "a-missing-caller", Kind: graph.EdgeCalls}, + {From: "a-type", To: "b-iface", Kind: graph.EdgeImplements}, + {From: "b-type", To: "a-iface", Kind: graph.EdgeImplements}, + {From: "missing-type", To: "a-iface", Kind: graph.EdgeImplements}, + } { + s.AddEdge(edge) + } + kinds := []graph.NodeKind{graph.KindFunction} + incoming := map[graph.NodeKind][]graph.EdgeKind{graph.KindFunction: {graph.EdgeCalls}} + for _, tc := range []struct { + name string + prefixes []string + candidates []string + files []string + implements []string + }{ + {"one repo", []string{"a"}, []string{"a-dead", "a-weak"}, []string{"a-file"}, []string{"a-type:b-iface"}}, + {"other repo", []string{"b"}, []string{"b-caller"}, []string{"b-file"}, []string{"b-type:a-iface"}}, + {"union deduplicates prefixes", []string{"b", "a", "a"}, []string{"a-dead", "a-weak", "b-caller"}, []string{"a-file", "b-file"}, []string{"a-type:b-iface", "b-type:a-iface"}}, + {"empty matches nothing", []string{}, nil, nil, nil}, + {"unowned is explicit", []string{""}, []string{"unowned-dead"}, []string{"unowned-file"}, nil}, + {"nil preserves unscoped", nil, []string{"a-dead", "a-weak", "b-caller", "unowned-dead"}, []string{"a-file", "b-file", "unowned-file"}, []string{"a-type:b-iface", "b-type:a-iface", "missing-type:a-iface"}}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := sortNodeIDs(reader.DeadCodeCandidatesInRepos(kinds, incoming, tc.prefixes)); !slices.Equal(got, tc.candidates) { + t.Fatalf("candidates = %v, want %v (cross-repo/dangling incoming usage must survive)", got, tc.candidates) + } + if got := sortNodeIDs(slices.Collect(reader.NodesByKindInRepos(graph.KindFile, tc.prefixes))); !slices.Equal(got, tc.files) { + t.Fatalf("files = %v, want %v", got, tc.files) + } + var rows []string + for _, row := range reader.IfaceImplementsRowsInRepos(tc.prefixes) { + rows = append(rows, row.TypeID+":"+row.IfaceID) + if fmt.Sprint(row.IfaceMeta["methods"]) != "[Work]" { + t.Fatalf("interface metadata lost: %#v", row) + } + } + slices.Sort(rows) + if !slices.Equal(rows, tc.implements) { + t.Fatalf("implements = %v, want %v (interface targets may be outside scope)", rows, tc.implements) + } + }) + } +} + +// Filtered reads must equal walking the exact adjacency mask, including absent +// source endpoints and foreign interface metadata. This is also the contract +// used by the request-side contract-core wrapper. +func testFilteredScopedDeadCodeReader(t *testing.T, factory Factory) { + s := factory(t) + reader, ok := s.(graph.FilteredScopedDeadCodeReader) + if !ok { + t.Skip("backend does not implement filtered scoped dead-code reads") + } + for _, n := range []*graph.Node{ + {ID: "a-used", Kind: graph.KindFunction, RepoPrefix: "a"}, + {ID: "a-via", Kind: graph.KindFunction, RepoPrefix: "a"}, + {ID: "a-missing", Kind: graph.KindFunction, RepoPrefix: "a"}, + {ID: "a-type", Kind: graph.KindType, RepoPrefix: "a"}, + {ID: "b-caller", Kind: graph.KindFunction, RepoPrefix: "b"}, + {ID: "b-iface", Kind: graph.KindInterface, RepoPrefix: "b", Meta: map[string]any{"methods": []string{"Work"}}}, + } { + s.AddNode(n) + } + for _, e := range []*graph.Edge{ + {From: "b-caller", To: "a-used", Kind: graph.EdgeCalls}, + {From: "b-caller", To: "a-via", Kind: graph.EdgeCalls, Meta: map[string]any{"via": "spring.Bean"}}, + {From: "missing", To: "a-missing", Kind: graph.EdgeCalls}, + {From: "a-type", To: "b-iface", Kind: graph.EdgeImplements}, + {From: "missing-type", To: "b-iface", Kind: graph.EdgeImplements}, + } { + s.AddEdge(e) + } + for _, prefixes := range [][]string{nil, {"a"}, {"b"}, {}, {""}} { + for _, filter := range []graph.OutlineEdgeFilter{ + {}, {ExcludeKinds: []graph.EdgeKind{graph.EdgeCalls}}, + {ExcludeNodeKinds: []graph.NodeKind{graph.KindFunction}}, + {ExcludeNodeKinds: []graph.NodeKind{graph.KindType}}, + {ExcludeNodeIDs: map[string]bool{"b-caller": true, "missing-type": true}}, + {ExcludeVia: "spring.Bean"}, + } { + allowed := func(n *graph.Node) bool { + if prefixes == nil { + return true + } + return n != nil && slices.Contains(prefixes, n.RepoPrefix) + } + var expected []*graph.Node + for n := range s.NodesByKind(graph.KindFunction) { + if !allowed(n) { + continue + } + live := false + for _, e := range s.GetInEdges(n.ID) { + if e.Kind == graph.EdgeCalls && filter.Allows(e, s.GetNode(e.From), n) { + live = true + break + } + } + if !live { + expected = append(expected, n) + } + } + got, err := reader.DeadCodeCandidatesInReposFiltered([]graph.NodeKind{graph.KindFunction}, map[graph.NodeKind][]graph.EdgeKind{graph.KindFunction: {graph.EdgeCalls}}, prefixes, filter) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(sortNodeIDs(got), sortNodeIDs(expected)) { + t.Fatalf("filtered candidates scope=%v filter=%+v got=%v want=%v", prefixes, filter, sortNodeIDs(got), sortNodeIDs(expected)) + } + var expectedRows []string + for e := range s.EdgesByKind(graph.EdgeImplements) { + from, to := s.GetNode(e.From), s.GetNode(e.To) + if allowed(from) && filter.Allows(e, from, to) { + expectedRows = append(expectedRows, e.From+":"+e.To) + } + } + rows, err := reader.IfaceImplementsRowsInReposFiltered(prefixes, filter) + if err != nil { + t.Fatal(err) + } + var gotRows []string + for _, row := range rows { + gotRows = append(gotRows, row.TypeID+":"+row.IfaceID) + } + slices.Sort(gotRows) + slices.Sort(expectedRows) + if !slices.Equal(gotRows, expectedRows) { + t.Fatalf("filtered implements scope=%v filter=%+v got=%v want=%v", prefixes, filter, gotRows, expectedRows) + } + } + } +} diff --git a/internal/graph/storetest/scoped_outline.go b/internal/graph/storetest/scoped_outline.go index 690368d7c..cdb3f8cdb 100644 --- a/internal/graph/storetest/scoped_outline.go +++ b/internal/graph/storetest/scoped_outline.go @@ -2,7 +2,9 @@ package storetest import ( "reflect" + "slices" "sort" + "strings" "testing" "github.com/zzet/gortex/internal/graph" @@ -43,6 +45,11 @@ func RunScopedOutline(t *testing.T, factory Factory) { {From: "a/main", To: "b/main", Kind: graph.EdgeCalls}, {From: "a/main", To: "no-file", Kind: graph.EdgeImports}, } + for _, n := range nodes { + if strings.Contains(n.ID, "main") { + n.Name = "main" + } + } s.AddBatch(nodes, edges) ref := graph.New() ref.AddBatch(nodes, edges) @@ -60,6 +67,31 @@ func RunScopedOutline(t *testing.T, factory Factory) { "mixed-allow": {RepoAllow: map[string]bool{"a": true, "b": false}}, } { t.Run(name, func(t *testing.T) { + if nodeReader, ok := s.(graph.ScopedOutlineNodeReader); ok { + ids := make([]string, 0, len(nodes)+1) + for _, n := range nodes { + ids = append(ids, n.ID) + } + ids = append(ids, "missing") + gotIDs, err := nodeReader.OutlineNodeIDsInScope(scope, ids) + if err != nil { + t.Fatal(err) + } + wantIDs, _ := ref.OutlineNodeIDsInScope(scope, ids) + slices.Sort(gotIDs) + slices.Sort(wantIDs) + if !slices.Equal(gotIDs, wantIDs) { + t.Fatalf("scoped membership=%v want %v", gotIDs, wantIDs) + } + gotNames, err := nodeReader.OutlineNodesByName(scope, "main") + if err != nil { + t.Fatal(err) + } + wantNames, _ := ref.OutlineNodesByName(scope, "main") + if !slices.Equal(sortNodeIDs(gotNames), sortNodeIDs(wantNames)) { + t.Fatalf("scoped name IDs=%v want %v", sortNodeIDs(gotNames), sortNodeIDs(wantNames)) + } + } got, err := ag.ScopedOutlineCounts(scope) if err != nil { t.Fatal(err) diff --git a/internal/graph/storetest/storetest.go b/internal/graph/storetest/storetest.go index a3c842680..ff28e9042 100644 --- a/internal/graph/storetest/storetest.go +++ b/internal/graph/storetest/storetest.go @@ -151,6 +151,8 @@ func RunConformance(t *testing.T, factory Factory, semantics Semantics) { t.Run("DistinctExternalTargets", func(t *testing.T) { testDistinctExternalTargets(t, factory) }) t.Run("SymbolBundleSearcher", func(t *testing.T) { testSymbolBundleSearcher(t, factory) }) t.Run("DeadCodeCandidator", func(t *testing.T) { testDeadCodeCandidator(t, factory) }) + t.Run("ScopedDeadCodeReader", func(t *testing.T) { testScopedDeadCodeReader(t, factory) }) + t.Run("FilteredScopedDeadCodeReader", func(t *testing.T) { testFilteredScopedDeadCodeReader(t, factory) }) t.Run("IfaceImplementsScanner", func(t *testing.T) { testIfaceImplementsScanner(t, factory) }) t.Run("NodeDegreeAggregator", func(t *testing.T) { testNodeDegreeAggregator(t, factory) }) t.Run("NodeFanAggregator", func(t *testing.T) { testNodeFanAggregator(t, factory) }) diff --git a/internal/mcp/analysis_lazy.go b/internal/mcp/analysis_lazy.go index feb792c60..2405d8a92 100644 --- a/internal/mcp/analysis_lazy.go +++ b/internal/mcp/analysis_lazy.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "errors" "fmt" @@ -253,6 +254,13 @@ func (s *Server) ensureNodeMetricsMaterialized() bool { } func (s *Server) ensureCommunitiesMaterialized() bool { + return s.ensureCommunitiesMaterializedContext(context.Background()) +} + +func (s *Server) ensureCommunitiesMaterializedContext(ctx context.Context) bool { + if ctx.Err() != nil { + return false + } s.analysisMu.RLock() ready := s.analysisSnapshotCurrentLocked() && s.communities != nil s.analysisMu.RUnlock() @@ -260,7 +268,9 @@ func (s *Server) ensureCommunitiesMaterialized() bool { return true } - s.analysisMaterializeMu.Lock() + if !lockAnalysisPreparation(ctx, &s.analysisMaterializeMu) { + return false + } defer s.analysisMaterializeMu.Unlock() s.analysisMu.RLock() ready = s.analysisSnapshotCurrentLocked() && s.communities != nil @@ -272,6 +282,13 @@ func (s *Server) ensureCommunitiesMaterialized() bool { if !ok { return false } + if ctx.Done() != nil { + if reader, ok := query.(graph.Reader); ok { + if bound, ok := graph.BindReadContext(reader, ctx).(graph.AnalysisQueryStore); ok { + query = bound + } + } + } result := &analysis.CommunityResult{ Communities: make([]analysis.Community, 0, header.CommunityCount), @@ -280,9 +297,14 @@ func (s *Server) ensureCommunitiesMaterialized() bool { } cursor := "" for { + if ctx.Err() != nil { + return false + } summaries, next, err := query.ListAnalysisCommunitySummaries(header.GenerationID, analysisGenerationQueryPage, cursor) if err != nil { - s.logAnalysisMaterializationError("communities", err) + if ctx.Err() == nil { + s.logAnalysisMaterializationError("communities", err) + } return false } for _, summary := range summaries { @@ -293,9 +315,14 @@ func (s *Server) ensureCommunitiesMaterialized() bool { } memberCursor := "" for { + if ctx.Err() != nil { + return false + } members, memberNext, memberErr := query.AnalysisCommunityMembers(header.GenerationID, summary.ID, analysisGenerationQueryPage, memberCursor) if memberErr != nil { - s.logAnalysisMaterializationError("community_members", memberErr) + if ctx.Err() == nil { + s.logAnalysisMaterializationError("community_members", memberErr) + } return false } for _, member := range members { @@ -314,7 +341,15 @@ func (s *Server) ensureCommunitiesMaterialized() bool { } cursor = next } - return s.installMaterializedGeneration(header, func() { s.communities = result }) + if ctx.Err() != nil { + return false + } + installed := s.installMaterializedGeneration(header, func() { + if ctx.Err() == nil { + s.communities = result + } + }) + return installed && ctx.Err() == nil } func (s *Server) ensureProcessesMaterialized() bool { diff --git a/internal/mcp/analysis_preparation_context.go b/internal/mcp/analysis_preparation_context.go new file mode 100644 index 000000000..dca7fedeb --- /dev/null +++ b/internal/mcp/analysis_preparation_context.go @@ -0,0 +1,43 @@ +package mcp + +import ( + "context" + "sync" + "time" +) + +// Cancelled preparation owns no scoring/materialization work while another +// lazy caller holds the lock. Legacy callers retain ordinary mutex coalescing. +// The caller owns mu only on success; no worker is detached from its owner. +func lockAnalysisPreparation(ctx context.Context, mu *sync.Mutex) bool { + if ctx.Done() == nil { + mu.Lock() + return true + } + if ctx.Err() != nil { + return false + } + if mu.TryLock() { + if ctx.Err() != nil { + mu.Unlock() + return false + } + return true + } + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return false + case <-ticker.C: + if mu.TryLock() { + if ctx.Err() != nil { + mu.Unlock() + return false + } + return true + } + } + } +} diff --git a/internal/mcp/contract_core_capability_table_test.go b/internal/mcp/contract_core_capability_table_test.go index 215b0ab42..388355afa 100644 --- a/internal/mcp/contract_core_capability_table_test.go +++ b/internal/mcp/contract_core_capability_table_test.go @@ -190,6 +190,7 @@ func contractCoreCapabilityTable() []contractCoreCapRow { {"CrossRepoEdgeAggregator", capOf[graph.CrossRepoEdgeAggregator], capDropped, capReasonEdges}, {"ExtractCandidatesScanner", capOf[graph.ExtractCandidatesScanner], capDropped, capReasonEdges}, {"FileImportAggregator", capOf[graph.FileImportAggregator], capDropped, capReasonEdges}, + {"ScopedOutlineNodeReader", capOf[graph.ScopedOutlineNodeReader], capSelected, "node-only membership and scoped exact-name reads retain selected-view visibility"}, {"ScopedOutlineAggregator", capOf[graph.ScopedOutlineAggregator], capDropped, capReasonEdges + " (scopedOutlineCountsFor uses the private filtered adapter instead)"}, {"FilteredScopedOutlineAggregator", capOf[graph.FilteredScopedOutlineAggregator], capDropped, "served only by the private outline adapter, which supplies this wrapper's exact exclusions"}, {"FileImporters", capOf[graph.FileImporters], capDropped, capReasonEdges}, @@ -197,6 +198,10 @@ func contractCoreCapabilityTable() []contractCoreCapRow { {"FileSubGraphCountReader", capOf[graph.FileSubGraphCountReader], capDropped, capReasonEdges}, {"ThrowerErrorSurfacer", capOf[graph.ThrowerErrorSurfacer], capDropped, capReasonEdges}, // Probed by internal/analysis on readers mcp builds from readerFor. + {"ScopedDeadCodeReader", capOf[graph.ScopedDeadCodeReader], capWrapper, "recomputes scoped candidates and interface evidence through exact adjacency exclusions"}, + {"NodeScopedDeadCodeReader", capOf[graph.NodeScopedDeadCodeReader], capWrapper, "intersects exact metadata scope before dead-code usage work with the selected adjacency mask"}, + {"FilteredNodeScopedDeadCodeReader", capOf[graph.FilteredNodeScopedDeadCodeReader], capDropped, "private exact-scope adapter receives this wrapper's exclusions, never a raw backend query"}, + {"FilteredScopedDeadCodeReader", capOf[graph.FilteredScopedDeadCodeReader], capDropped, "private adapter supplies this wrapper's exact exclusions; never exposed as raw filtered backend"}, {"DeadCodeCandidator", capOf[graph.DeadCodeCandidator], capDropped, capReasonEdges + " (zero-inbound candidates; FindDeadCode walks filtered edges)"}, {"InDegreeForNodes", capOf[graph.InDegreeForNodes], capDropped, capReasonEdges + " (in-degrees count hidden endpoints)"}, {"IfaceImplementsScanner", capOf[graph.IfaceImplementsScanner], capDropped, capReasonEdges + " (implements rows)"}, diff --git a/internal/mcp/contract_core_deadcode.go b/internal/mcp/contract_core_deadcode.go new file mode 100644 index 000000000..ce419d3a8 --- /dev/null +++ b/internal/mcp/contract_core_deadcode.go @@ -0,0 +1,158 @@ +package mcp + +import ( + "iter" + + "github.com/zzet/gortex/internal/graph" +) + +var _ graph.ScopedDeadCodeReader = (*contractCoreEdges)(nil) + +// This is the same adjacency contract as filterBatch, supplied explicitly to +// backend queries. Raw candidate/interface capabilities cannot cross the mask. +func (r *contractCoreEdges) deadCodeFilter() graph.OutlineEdgeFilter { + filter := graph.OutlineEdgeFilter{ + ExcludeKinds: []graph.EdgeKind{graph.EdgeMatches, graph.EdgeBridges, graph.EdgeHandlesRoute, graph.EdgeReadsConfig}, + ExcludeNodeIDs: r.contractIDs, + ExcludeVia: "spring.Bean", + } + if r.contractIDs == nil { + filter.ExcludeNodeKinds = contractCoreOwnedKinds + } + return filter +} + +func (r *contractCoreEdges) NodesByKindInRepos(kind graph.NodeKind, prefixes []string) iter.Seq[*graph.Node] { + if scoped, ok := r.Reader.(graph.ScopedDeadCodeReader); ok { + return scoped.NodesByKindInRepos(kind, prefixes) + } + return func(yield func(*graph.Node) bool) { + if prefixes != nil && len(prefixes) == 0 { + return + } + for n := range r.NodesByKind(kind) { + if r.ctx != nil && r.ctx.Err() != nil { + return + } + if n != nil && deadCodePrefixAllowed(n.RepoPrefix, prefixes) && !yield(n) { + return + } + } + } +} + +func deadCodePrefixAllowed(prefix string, prefixes []string) bool { + if prefixes == nil { + return true + } + for _, allowed := range prefixes { + if allowed == prefix { + return true + } + } + return false +} + +func (r *contractCoreEdges) DeadCodeCandidatesInRepos(kinds []graph.NodeKind, incoming map[graph.NodeKind][]graph.EdgeKind, prefixes []string) []*graph.Node { + if scoped, ok := r.Reader.(graph.FilteredScopedDeadCodeReader); ok { + nodes, err := scoped.DeadCodeCandidatesInReposFiltered(kinds, incoming, prefixes, r.deadCodeFilter()) + recordContractCoreReadError(r.ctx, err) + return nodes + } + // Composed readers without the filtered query keep their own nodes and + // expand usage through this mask. Bound each adjacency read to one batch. + var out []*graph.Node + for _, kind := range kinds { + var batch []*graph.Node + flush := func() { + ids := make([]string, 0, len(batch)) + for _, n := range batch { + ids = append(ids, n.ID) + } + edges := r.GetInEdgesByNodeIDs(ids) + for _, n := range batch { + used := false + for _, edge := range edges[n.ID] { + if edge == nil { + continue + } + if len(incoming[kind]) == 0 { + used = true + break + } + for _, allowed := range incoming[kind] { + if edge.Kind == allowed { + used = true + break + } + } + if used { + break + } + } + if !used { + out = append(out, n) + } + } + batch = batch[:0] + } + for n := range r.NodesByKindInRepos(kind, prefixes) { + if r.ctx != nil && r.ctx.Err() != nil { + return out + } + batch = append(batch, n) + if len(batch) == 256 { + flush() + } + } + if len(batch) > 0 { + flush() + } + } + return out +} + +func (r *contractCoreEdges) IfaceImplementsRowsInRepos(prefixes []string) []graph.IfaceImplementsRow { + if scoped, ok := r.Reader.(graph.FilteredScopedDeadCodeReader); ok { + rows, err := scoped.IfaceImplementsRowsInReposFiltered(prefixes, r.deadCodeFilter()) + recordContractCoreReadError(r.ctx, err) + return rows + } + if prefixes != nil && len(prefixes) == 0 { + return nil + } + // This compatibility path is used only when the exact selected view cannot + // supply the filtered query. Both evidence and endpoints come from that view. + var out []graph.IfaceImplementsRow + var batch []*graph.Edge + flush := func() { + ids := make([]string, 0, len(batch)*2) + for _, edge := range batch { + ids = append(ids, edge.From, edge.To) + } + nodes := r.GetNodesByIDs(ids) + for _, edge := range batch { + typ, iface := nodes[edge.From], nodes[edge.To] + if prefixes != nil && (typ == nil || !deadCodePrefixAllowed(typ.RepoPrefix, prefixes)) { + continue + } + if iface == nil || iface.Kind != graph.KindInterface { + continue + } + if _, ok := iface.Meta["methods"]; ok { + out = append(out, graph.IfaceImplementsRow{TypeID: edge.From, IfaceID: edge.To, IfaceMeta: iface.Meta}) + } + } + batch = batch[:0] + } + for edge := range r.EdgesByKind(graph.EdgeImplements) { + batch = append(batch, edge) + if len(batch) == 256 { + flush() + } + } + if len(batch) > 0 { + flush() + } + return out +} diff --git a/internal/mcp/contract_core_deadcode_scope.go b/internal/mcp/contract_core_deadcode_scope.go new file mode 100644 index 000000000..3ccd7bdc0 --- /dev/null +++ b/internal/mcp/contract_core_deadcode_scope.go @@ -0,0 +1,39 @@ +package mcp + +import ( + "iter" + + "github.com/zzet/gortex/internal/graph" +) + +var _ graph.NodeScopedDeadCodeReader = (*contractCoreEdges)(nil) + +func (r *contractCoreEdges) DeadCodeCandidatesInScope(kinds []graph.NodeKind, incoming map[graph.NodeKind][]graph.EdgeKind, prefixes []string, scope graph.NodeScope, exclude graph.DeadCodeCandidateExclusions) []*graph.Node { + if backend, ok := r.Reader.(graph.FilteredNodeScopedDeadCodeReader); ok { + nodes, err := backend.DeadCodeCandidatesInScopeFiltered(kinds, incoming, prefixes, scope, r.deadCodeFilter(), exclude) + recordContractCoreReadError(r.ctx, err) + return nodes + } + // The exact selected reader keeps its own adjacency mask. A composed view + // without SQL support remains correct without tunnelling to its base store. + var nodes []*graph.Node + for _, n := range r.DeadCodeCandidatesInRepos(kinds, incoming, prefixes) { + if n != nil && scope.Allows(n) && exclude.Allows(n) { + nodes = append(nodes, n) + } + } + return nodes +} + +func (r *contractCoreEdges) NodesByKindInScope(kind graph.NodeKind, prefixes []string, scope graph.NodeScope) iter.Seq[*graph.Node] { + if backend, ok := r.Reader.(graph.NodeScopedDeadCodeReader); ok { + return backend.NodesByKindInScope(kind, prefixes, scope) + } + return func(yield func(*graph.Node) bool) { + for n := range r.NodesByKindInRepos(kind, prefixes) { + if n != nil && scope.Allows(n) && !yield(n) { + return + } + } + } +} diff --git a/internal/mcp/contract_core_deadcode_test.go b/internal/mcp/contract_core_deadcode_test.go new file mode 100644 index 000000000..5c4a68003 --- /dev/null +++ b/internal/mcp/contract_core_deadcode_test.go @@ -0,0 +1,141 @@ +package mcp + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" +) + +type deadCodeFilteredSpy struct { + graph.Reader + backend graph.FilteredScopedDeadCodeReader + queries *int + allNodes *int +} + +func (r *deadCodeFilteredSpy) AllNodes() []*graph.Node { + (*r.allNodes)++ + return r.Reader.AllNodes() +} + +func (r *deadCodeFilteredSpy) DeadCodeCandidatesInReposFiltered(kinds []graph.NodeKind, incoming map[graph.NodeKind][]graph.EdgeKind, repos []string, filter graph.OutlineEdgeFilter) ([]*graph.Node, error) { + (*r.queries)++ + return r.backend.DeadCodeCandidatesInReposFiltered(kinds, incoming, repos, filter) +} +func (r *deadCodeFilteredSpy) IfaceImplementsRowsInReposFiltered(repos []string, filter graph.OutlineEdgeFilter) ([]graph.IfaceImplementsRow, error) { + (*r.queries)++ + return r.backend.IfaceImplementsRowsInReposFiltered(repos, filter) +} +func (r *deadCodeFilteredSpy) BindReadContext(ctx context.Context) graph.Reader { + bound := graph.BindReadContext(r.Reader, ctx) + return &deadCodeFilteredSpy{Reader: bound, backend: bound.(graph.FilteredScopedDeadCodeReader), queries: r.queries, allNodes: r.allNodes} +} + +func TestContractCoreScopedDeadCodePreservesMaskAndSelectedView(t *testing.T) { + store := openContractCoreCapStore(t) + store.AddBatch([]*graph.Node{ + {ID: "a/dead", Name: "dead", Kind: graph.KindFunction, RepoPrefix: "a", FilePath: "a/f.go", StartLine: 1, Language: "go"}, + {ID: "a/bean", Name: "bean", Kind: graph.KindFunction, RepoPrefix: "a", FilePath: "a/f.go", StartLine: 2, Language: "go"}, + {ID: "a/contract", Name: "contractTarget", Kind: graph.KindFunction, RepoPrefix: "a", FilePath: "a/f.go", StartLine: 3, Language: "go"}, + {ID: "a/live", Name: "live", Kind: graph.KindFunction, RepoPrefix: "a", FilePath: "a/f.go", StartLine: 4, Language: "go"}, + {ID: "b/caller", Name: "caller", Kind: graph.KindFunction, RepoPrefix: "b", FilePath: "b/f.go", StartLine: 1, Language: "go"}, + {ID: "contract", Kind: graph.KindContract, RepoPrefix: "b"}, + {ID: "a/type", Name: "privateType", Kind: graph.KindType, RepoPrefix: "a", FilePath: "a/f.go", StartLine: 5, Language: "go"}, + {ID: "b/interface", Name: "foreignInterface", Kind: graph.KindInterface, RepoPrefix: "b", Meta: map[string]any{"methods": []string{"Work"}}}, + }, []*graph.Edge{ + {From: "b/caller", To: "a/live", Kind: graph.EdgeCalls}, + {From: "b/caller", To: "a/bean", Kind: graph.EdgeCalls, Meta: map[string]any{"via": "spring.Bean"}}, + {From: "contract", To: "a/contract", Kind: graph.EdgeCalls}, + {From: "a/type", To: "b/interface", Kind: graph.EdgeImplements}, + }) + for _, ids := range []map[string]bool{nil, {"contract": true, "b/interface": true}, {}} { + for _, repos := range [][]string{nil, {"a"}, {"b"}, {}} { + ctx := withContractCoreReadErrors(t.Context()) + queries := 0 + allNodes := 0 + spy := &deadCodeFilteredSpy{Reader: store, backend: store, queries: &queries, allNodes: &allNodes} + fast := graph.BindReadContext(newContractCoreEdges(spy, ctx, ids), ctx) + // The old consumer sees no optional shortcuts and must walk the same + // selected reader's filtered adjacency. It is independent of SQL predicates. + slow := legacyOutlineReader{newContractCoreEdges(legacyOutlineReader{store}, ctx, ids)} + opts := analysis.FindDeadCodeOptions{RepoPrefixes: repos} + got := analysis.FindDeadCode(fast, nil, nil, opts) + want := analysis.FindDeadCode(slow, nil, nil, opts) + require.Equal(t, want, got, "scope=%v contract IDs=%v", repos, ids) + if repos == nil || len(repos) > 0 { + require.Positive(t, queries, "binding must keep indexed filtered queries") + } else { + require.Zero(t, queries, "explicit empty ownership does no query work") + } + require.Zero(t, allNodes, "nil scope is an indexed unscoped query, not an AllNodes fallback") + if repos == nil || slices.Contains(repos, "a") { + deadIDs := make([]string, 0, len(got)) + for _, entry := range got { + deadIDs = append(deadIDs, entry.ID) + } + require.Contains(t, deadIDs, "a/dead") + require.Contains(t, deadIDs, "a/bean", "masked incoming edge cannot keep a symbol alive") + require.NotContains(t, deadIDs, "a/live", "a real foreign caller still proves use") + if ids == nil || ids["contract"] { + require.Contains(t, deadIDs, "a/contract", "masked contract endpoint cannot prove use") + } else { + require.NotContains(t, deadIDs, "a/contract", "an explicitly visible caller must remain authoritative") + } + } + require.NoError(t, contractCoreReadError(ctx)) + // Also exercise a composed view that hides the backend capability. It + // must use its own nodes rather than tunnelling to the physical store. + fallback := newContractCoreEdges(legacyOutlineReader{store}, ctx, ids) + require.Equal(t, want, analysis.FindDeadCode(fallback, nil, nil, opts)) + } + } +} + +func TestContractCoreScopedDeadCodeRetainsCancellationEvidence(t *testing.T) { + store := openContractCoreCapStore(t) + ctx, cancel := context.WithCancel(withContractCoreReadErrors(t.Context())) + cancel() + reader := graph.BindReadContext(newContractCoreEdges(store, ctx, nil), ctx).(graph.ScopedDeadCodeReader) + require.Empty(t, reader.DeadCodeCandidatesInRepos([]graph.NodeKind{graph.KindFunction}, nil, []string{"a"})) + require.True(t, errors.Is(contractCoreReadError(ctx), context.Canceled)) + require.Empty(t, reader.IfaceImplementsRowsInRepos([]string{"a"})) + require.Empty(t, slices.Collect(reader.NodesByKindInRepos(graph.KindFile, []string{"a"}))) +} + +func TestContractCoreExactWorkspaceDeadCodeKeepsMasksAndFallback(t *testing.T) { + store := openContractCoreCapStore(t) + store.AddBatch([]*graph.Node{ + {ID: "own/dead", Name: "privateDead", Kind: graph.KindFunction, WorkspaceID: "ws", RepoPrefix: "stale-owner", FilePath: "own.go", Language: "go"}, + {ID: "own/live", Name: "privateLive", Kind: graph.KindFunction, WorkspaceID: "ws", RepoPrefix: "a", FilePath: "own.go", Language: "go"}, + {ID: "own/masked", Name: "privateMasked", Kind: graph.KindFunction, WorkspaceID: "ws", RepoPrefix: "a", FilePath: "own.go", Language: "go"}, + {ID: "foreign/caller", Name: "caller", Kind: graph.KindFunction, WorkspaceID: "foreign", RepoPrefix: "b", FilePath: "foreign.go", Language: "go"}, + }, []*graph.Edge{{From: "foreign/caller", To: "own/live", Kind: graph.EdgeCalls}, {From: "foreign/caller", To: "own/masked", Kind: graph.EdgeMatches}}) + scope := graph.NodeScope{WorkspaceID: "ws"} + for _, prefixes := range [][]string{nil, {"a"}, {}} { + ctx := withContractCoreReadErrors(t.Context()) + fast := graph.BindReadContext(newContractCoreEdges(store, ctx, nil), ctx) + fallback := graph.BindReadContext(newContractCoreEdges(legacyOutlineReader{store}, ctx, nil), ctx) + opts := analysis.FindDeadCodeOptions{NodeScope: &scope, RepoPrefixes: prefixes} + got := analysis.FindDeadCode(fast, nil, nil, opts) + require.Equal(t, analysis.FindDeadCode(fallback, nil, nil, opts), got) + ids := make([]string, 0, len(got)) + for _, e := range got { + ids = append(ids, e.ID) + } + if prefixes == nil { + require.ElementsMatch(t, []string{"own/dead", "own/masked"}, ids) + } + if len(prefixes) == 1 { + require.Equal(t, []string{"own/masked"}, ids) + } + if prefixes != nil && len(prefixes) == 0 { + require.Empty(t, got) + } + require.NoError(t, contractCoreReadError(ctx)) + } +} diff --git a/internal/mcp/deadcode_candidate_eligibility_test.go b/internal/mcp/deadcode_candidate_eligibility_test.go new file mode 100644 index 000000000..8b99e717d --- /dev/null +++ b/internal/mcp/deadcode_candidate_eligibility_test.go @@ -0,0 +1,91 @@ +package mcp + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +type eligibilityCandidateSpy struct { + *store_sqlite.Store + rows *int +} + +func (r *eligibilityCandidateSpy) BindReadContext(ctx context.Context) graph.Reader { + return &eligibilityCandidateSpy{Store: r.WithReadContext(ctx), rows: r.rows} +} +func (r *eligibilityCandidateSpy) DeadCodeCandidatesInScopeFiltered(k []graph.NodeKind, in map[graph.NodeKind][]graph.EdgeKind, repos []string, scope graph.NodeScope, filter graph.OutlineEdgeFilter, exclude graph.DeadCodeCandidateExclusions) ([]*graph.Node, error) { + nodes, err := r.Store.DeadCodeCandidatesInScopeFiltered(k, in, repos, scope, filter, exclude) + *r.rows += len(nodes) + return nodes, err +} +func TestDeadCodeKnownIneligibleGlobalsDoNotGrowCandidateHydration(t *testing.T) { + s, store := outlineFixture(t) + store.AddBatch([]*graph.Node{ + {ID: "own-private", Kind: graph.KindFunction, Name: "_unused_own", Language: "python", FilePath: "own/f.py", RepoPrefix: "own", WorkspaceID: "ws"}, + {ID: "own-live", Kind: graph.KindFunction, Name: "_live_own", Language: "python", FilePath: "own/f.py", RepoPrefix: "own", WorkspaceID: "ws"}, + {ID: "own::module::stub", Kind: graph.KindFunction, Name: "stub", Language: "python", FilePath: "own/f.py", RepoPrefix: "own", WorkspaceID: "ws"}, + {ID: "own::MODULE::source", Kind: graph.KindFunction, Name: "_case_sensitive", Language: "python", FilePath: "own/f.py", RepoPrefix: "own", WorkspaceID: "ws"}, + {ID: "global-unmarked", Kind: graph.KindFunction, Name: "_global_own", Language: "python", FilePath: "external::shared"}, + {ID: "ext::caller", Kind: graph.KindFunction, Name: "caller", Language: "python", FilePath: "external::caller", Meta: map[string]any{"external": true}}, + }, []*graph.Edge{{From: "ext::caller", To: "own-live", Kind: graph.EdgeCalls}}) + run := func() (string, int) { + rows := 0 + ctx := outlineBoundContext(s, "eligibility", "ws", nil) + reader := newContractCoreEdges(&eligibilityCandidateSpy{Store: store, rows: &rows}, ctx, nil) + ctx = withRequestView(ctx, &requestView{reader: reader}) + result, err := s.handleFindDeadCode(ctx, makeReq("find_dead_code", map[string]any{"format": "json"})) + require.NoError(t, err) + body := toolResultText(result) + require.Contains(t, body, "own-private") + require.Contains(t, body, "own::MODULE::source", "identity checks are case sensitive") + require.Contains(t, body, "global-unmarked", "global visibility is not an ownership-based exclusion") + require.NotContains(t, body, "own-live", "a flagged foreign caller remains valid usage evidence") + return body, rows + } + before, rowsBefore := run() + nodes := make([]*graph.Node, 1000) + for i := range nodes { + nodes[i] = &graph.Node{ID: fmt.Sprintf("ext::dependency::%04d", i), Kind: graph.KindFunction, Name: "dependency", FilePath: "external::dependency", Meta: map[string]any{"external": true}} + } + store.AddBatch(nodes, nil) + after, rowsAfter := run() + require.JSONEq(t, before, after) + require.Equal(t, rowsBefore, rowsAfter, "already rejected globals must not reach candidate hydration/usage work") +} + +func TestDeadCodeCandidateExclusionsKeepSelectedReaderEvidence(t *testing.T) { + _, store := outlineFixture(t) + require.NoError(t, store.AddBatchChecked([]*graph.Node{ + {ID: "own", Kind: graph.KindFunction, WorkspaceID: "ws"}, + {ID: "foreign", Kind: graph.KindFunction, FilePath: "external::caller", Meta: map[string]any{"external": true}}, + {ID: "repo::module::stub", Kind: graph.KindFunction, WorkspaceID: "ws"}, + }, []*graph.Edge{{From: "foreign", To: "own", Kind: graph.EdgeCalls}})) + exclude := graph.DeadCodeCandidateExclusions{Stubs: true, External: true} + for _, masked := range []map[string]bool{nil, {"foreign": true}} { + ctx := withContractCoreReadErrors(t.Context()) + fast := newContractCoreEdges(store, ctx, masked).(graph.NodeScopedDeadCodeReader) + fallback := newContractCoreEdges(legacyOutlineReader{store}, ctx, masked).(graph.NodeScopedDeadCodeReader) + kinds := []graph.NodeKind{graph.KindFunction} + incoming := map[graph.NodeKind][]graph.EdgeKind{graph.KindFunction: {graph.EdgeCalls}} + scope := graph.NodeScope{WorkspaceID: "ws"} + got := fast.DeadCodeCandidatesInScope(kinds, incoming, nil, scope, exclude) + require.Equal(t, fallback.DeadCodeCandidatesInScope(kinds, incoming, nil, scope, exclude), got) + var ids []string + for _, n := range got { + ids = append(ids, n.ID) + } + require.NotContains(t, ids, "foreign") + require.NotContains(t, ids, "repo::module::stub") + if masked != nil { + require.Contains(t, ids, "own") + } else { + require.NotContains(t, ids, "own") + } + require.NoError(t, contractCoreReadError(ctx)) + } +} diff --git a/internal/mcp/deadcode_processes_test.go b/internal/mcp/deadcode_processes_test.go new file mode 100644 index 000000000..abfbd57be --- /dev/null +++ b/internal/mcp/deadcode_processes_test.go @@ -0,0 +1,105 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "slices" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" +) + +type scopedDeadCodeProcessQuery struct { + graph.AnalysisQueryStore + rows []graph.AnalysisProcessSummary + steps map[string][]graph.AnalysisProcessStep + stepReads map[string]int + fail error + cancel context.CancelFunc +} + +func (q *scopedDeadCodeProcessQuery) ListAnalysisProcessSummaries(_ int64, limit int, cursor string) ([]graph.AnalysisProcessSummary, string, error) { + start := 0 + if cursor != "" { + start = slices.IndexFunc(q.rows, func(row graph.AnalysisProcessSummary) bool { return row.ID == cursor }) + 1 + } + end := min(start+limit, len(q.rows)) + next := "" + if end < len(q.rows) { + next = q.rows[end-1].ID + } + if q.cancel != nil { + q.cancel() + } + return q.rows[start:end], next, nil +} +func (q *scopedDeadCodeProcessQuery) AnalysisProcessSteps(_ int64, id string, limit, cursor int) ([]graph.AnalysisProcessStep, int, error) { + q.stepReads[id]++ + if q.fail != nil { + return nil, cursor, q.fail + } + steps := q.steps[id] + start := cursor + 1 + end := min(start+limit, len(steps)) + if start >= len(steps) { + return nil, cursor, nil + } + return steps[start:end], end - 1, nil +} + +type deadCodeProcessNodeSpy struct { + graph.Reader + t *testing.T + batches, scalar int +} + +func (r *deadCodeProcessNodeSpy) GetNode(id string) *graph.Node { + r.scalar++ + return r.Reader.GetNode(id) +} +func (r *deadCodeProcessNodeSpy) GetNodesByIDs(ids []string) map[string]*graph.Node { + r.batches++ + require.LessOrEqual(r.t, len(ids), analysisGenerationQueryPage) + return r.Reader.GetNodesByIDs(ids) +} + +func TestDeadCodeGenerationProcessesReadOnlyAdmittedSteps(t *testing.T) { + g := graph.New() + query := &scopedDeadCodeProcessQuery{steps: make(map[string][]graph.AnalysisProcessStep), stepReads: make(map[string]int)} + for i := 0; i < 1100; i++ { + repo := "b" + if i == 0 { + repo = "a" + } + id := fmt.Sprintf("process%04d", i) + entry := fmt.Sprintf("entry%04d", i) + g.AddNode(&graph.Node{ID: entry, RepoPrefix: repo}) + query.rows = append(query.rows, graph.AnalysisProcessSummary{ID: id, Name: id, EntryPoint: entry, StepCount: 2, Score: .5, Files: []string{"file.go"}}) + query.steps[id] = []graph.AnalysisProcessStep{{NodeID: entry, Ordinal: 0}, {NodeID: "foreign-step", Ordinal: 1, Depth: 1}} + } + g.AddNode(&graph.Node{ID: "foreign-step", Name: "foreignStep", Kind: graph.KindFunction, RepoPrefix: "b", FilePath: "b/f.go", Language: "go"}) + s := &Server{graph: g, session: newSessionState()} + ctx := withRepoAllow(t.Context(), map[string]bool{"a": true}) + spy := &deadCodeProcessNodeSpy{Reader: g, t: t} + result, err := s.readDeadCodeProcesses(ctx, spy, query, graph.AnalysisGenerationHeader{GenerationID: 7}) + require.NoError(t, err) + require.Len(t, result.Processes, 1) + require.Equal(t, analysis.Process{ID: "process0000", Name: "process0000", EntryPoint: "entry0000", StepCount: 2, Score: .5, Files: []string{"file.go"}, Steps: []analysis.Step{{ID: "entry0000"}, {ID: "foreign-step", Depth: 1}}}, result.Processes[0]) + withoutProcess := analysis.FindDeadCode(g, nil, nil) + require.Contains(t, withoutProcess[0].ID, "foreign-step", "fixture: the foreign node needs process reachability evidence") + require.Empty(t, analysis.FindDeadCode(g, result, nil), "foreign steps of an admitted process remain reachable") + require.Len(t, query.stepReads, 1, "foreign entry points must never cause step reads") + require.Equal(t, 3, spy.batches) + require.Zero(t, spy.scalar) + sentinel := errors.New("process query unavailable") + query.fail = sentinel + _, err = s.readDeadCodeProcesses(ctx, spy, query, graph.AnalysisGenerationHeader{GenerationID: 7}) + require.ErrorIs(t, err, sentinel, "transient query errors must not become partial/full-hydrated success") + cancelled, cancel := context.WithCancel(ctx) + query.cancel = cancel + _, err = s.readDeadCodeProcesses(cancelled, spy, query, graph.AnalysisGenerationHeader{GenerationID: 7}) + require.ErrorIs(t, err, context.Canceled) +} diff --git a/internal/mcp/deadcode_scope.go b/internal/mcp/deadcode_scope.go new file mode 100644 index 000000000..859dd7d7d --- /dev/null +++ b/internal/mcp/deadcode_scope.go @@ -0,0 +1,152 @@ +package mcp + +import ( + "context" + "errors" + "sort" + "strings" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" +) + +func (s *Server) deadCodeRepoPrefixes(ctx context.Context) []string { + narrow := repoAllowFromContext(ctx) + opts, active := s.scopeOptionsWithRepoNarrow(ctx, narrow) + if !active { + return nil + } + if strings.HasPrefix(opts.WorkspaceID, unresolvedWorkspacePrefix) { + return []string{} + } + if len(opts.RepoAllow) == 0 { + // A workspace slug is not a repository prefix. Without a resolved + // repo set, retain the metadata-filtered fallback rather than guess + // ownership from a registry that may be mid-untrack/reconciliation. + return nil + } + prefixes := make([]string, 0, len(opts.RepoAllow)+1) + for prefix, allowed := range opts.RepoAllow { + if allowed { + prefixes = append(prefixes, prefix) + } + } + // Unlike outline's NodeScope, dead_code's explicit analyze repo narrow + // excludes the unowned bucket. A session ceiling alone still admits it. + if len(narrow) == 0 && !opts.RepoAllow[""] { + prefixes = append(prefixes, "") + } + sort.Strings(prefixes) + return prefixes +} + +func (s *Server) deadCodeProcesses(ctx context.Context, reader graph.Reader) (*analysis.ProcessResult, error) { + if s.scopeFiltersActive(ctx) { + if query, header, current := s.activeAnalysisQueryForAnswer(); current { + // Query the published base analysis, but admit entry points through the + // selected view. Steps of an admitted process remain global evidence. + if selected, ok := query.(graph.Reader); ok { + if bound, ok := graph.BindReadContext(selected, ctx).(graph.AnalysisQueryStore); ok { + query = bound + } + } + result, err := s.readDeadCodeProcesses(ctx, reader, query, header) + if errors.Is(err, graph.ErrAnalysisGenerationInactive) { + return nil, nil + } + if err != nil { + return nil, err + } + _, after, current := s.activeAnalysisQueryForAnswer() + if !current || after.GenerationID != header.GenerationID || after.GraphRevision != header.GraphRevision { + return nil, nil + } + return result, nil + } + } + + processes := s.getProcesses() + if processes == nil || !s.scopeFiltersActive(ctx) { + return processes, nil + } + ids := make([]string, 0, len(processes.Processes)) + for _, process := range processes.Processes { + ids = append(ids, process.EntryPoint) + } + nodes := reader.GetNodesByIDs(ids) + filtered := *processes + filtered.Processes = nil + for _, process := range processes.Processes { + if s.analyzeNodeVisible(ctx, nodes[process.EntryPoint]) { + filtered.Processes = append(filtered.Processes, process) + } + } + return &filtered, nil +} + +// readDeadCodeProcesses pages compact headers for admission and never reads +// steps of a foreign-entry process. No scalar endpoint lookups or full-process +// compatibility map is needed by the dead-code consumer. +func (s *Server) readDeadCodeProcesses(ctx context.Context, reader graph.Reader, query graph.AnalysisQueryStore, header graph.AnalysisGenerationHeader) (*analysis.ProcessResult, error) { + result := &analysis.ProcessResult{Truncated: header.ProcessesTruncated, TruncationReason: header.ProcessesTruncationReason} + cursor := "" + for { + if err := ctx.Err(); err != nil { + return nil, err + } + rows, next, err := query.ListAnalysisProcessSummaries(header.GenerationID, analysisGenerationQueryPage, cursor) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + ids := make([]string, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.EntryPoint) + } + var nodes map[string]*graph.Node + if checked, ok := reader.(interface { + GetNodesByIDsContext(context.Context, []string) (map[string]*graph.Node, error) + }); ok { + nodes, err = checked.GetNodesByIDsContext(ctx, ids) + if err != nil { + return nil, err + } + } else { + nodes = graph.BindReadContext(reader, ctx).GetNodesByIDs(ids) + } + if err := ctx.Err(); err != nil { + return nil, err + } + for _, row := range rows { + if !s.analyzeNodeVisible(ctx, nodes[row.EntryPoint]) { + continue + } + process := analysis.Process{ID: row.ID, Name: row.Name, EntryPoint: row.EntryPoint, StepCount: row.StepCount, Score: row.Score, Truncated: row.Truncated, Files: append([]string(nil), row.Files...)} + ordinal := -1 + for { + if err := ctx.Err(); err != nil { + return nil, err + } + steps, nextOrdinal, err := query.AnalysisProcessSteps(header.GenerationID, row.ID, analysisGenerationQueryPage, ordinal) + if err != nil { + return nil, err + } + for _, step := range steps { + process.Steps = append(process.Steps, analysis.Step{ID: step.NodeID, Depth: step.Depth}) + } + if len(steps) == 0 || nextOrdinal <= ordinal { + break + } + ordinal = nextOrdinal + } + result.Processes = append(result.Processes, process) + } + if len(rows) == 0 || next == "" || next == cursor { + break + } + cursor = next + } + return result, ctx.Err() +} diff --git a/internal/mcp/deadcode_scope_test.go b/internal/mcp/deadcode_scope_test.go new file mode 100644 index 000000000..8357e9569 --- /dev/null +++ b/internal/mcp/deadcode_scope_test.go @@ -0,0 +1,86 @@ +package mcp + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" +) + +type deadCodeLookupCountingReader struct { + *outlineCountingReader + scalarLookups *int +} + +func (r *deadCodeLookupCountingReader) GetNode(id string) *graph.Node { + (*r.scalarLookups)++ + return r.Store.GetNode(id) +} +func (r *deadCodeLookupCountingReader) BindReadContext(ctx context.Context) graph.Reader { + return &deadCodeLookupCountingReader{ + outlineCountingReader: &outlineCountingReader{Store: r.WithReadContext(ctx), source: r.counters()}, + scalarLookups: r.scalarLookups, + } +} + +func TestDeadCodeHandlerOwnershipFilterNeedsNoScalarLookup(t *testing.T) { + s, store := outlineFixture(t) + store.AddBatch([]*graph.Node{ + {ID: "a-private", Name: "privatea", Kind: graph.KindFunction, FilePath: "a/private.go", Language: "go", RepoPrefix: "a", WorkspaceID: "ws"}, + {ID: "b-private", Name: "privateb", Kind: graph.KindFunction, FilePath: "b/private.go", Language: "go", RepoPrefix: "b", WorkspaceID: "ws"}, + {ID: "unowned-private", Name: "privateunowned", Kind: graph.KindFunction, FilePath: "shared/private.go", Language: "go", WorkspaceID: "ws"}, + }, nil) + for _, tc := range []struct { + name, workspace string + ceiling, narrow map[string]bool + unbound bool + }{ + {name: "session ceiling includes unowned", ceiling: map[string]bool{"a": true}}, + {name: "workspace ceiling", workspace: "ws", ceiling: map[string]bool{"a": true, "b": true}}, + {name: "explicit narrow excludes unowned", workspace: "ws", ceiling: map[string]bool{"a": true, "b": true}, narrow: map[string]bool{"a": true}}, + {name: "disjoint narrow", ceiling: map[string]bool{"a": true}, narrow: map[string]bool{"b": true}}, + {name: "workspace only preserves fallback", workspace: "ws"}, + {name: "unbound explicit repo", narrow: map[string]bool{"a": true}, unbound: true}, + {name: "unbound", unbound: true}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + if !tc.unbound { + ctx = outlineBoundContext(s, tc.name, tc.workspace, tc.ceiling) + } + if tc.narrow != nil { + ctx = withRepoAllow(ctx, tc.narrow) + } + // Independent old algorithm: whole reader, followed by the exact + // original node lookup/session/narrow filter. + want := analysis.FindDeadCode(legacyOutlineReader{store}, s.getProcesses(), nil) + if s.scopeFiltersActive(ctx) { + kept := make([]analysis.DeadCodeEntry, 0, len(want)) + for _, entry := range want { + if s.analyzeNodeVisible(ctx, store.GetNode(entry.ID)) { + kept = append(kept, entry) + } + } + want = kept + } + scalarLookups := 0 + counter := &outlineCountingReader{Store: store} + reader := &deadCodeLookupCountingReader{outlineCountingReader: counter, scalarLookups: &scalarLookups} + ctx = withRequestView(ctx, &requestView{reader: reader}) + result, err := s.handleFindDeadCode(ctx, makeReq("find_dead_code", map[string]any{"format": "json"})) + require.NoError(t, err) + payload := resultJSON(t, result) + gotJSON, err := json.Marshal(payload["dead_code"]) + require.NoError(t, err) + wantJSON, err := json.Marshal(want) + require.NoError(t, err) + require.JSONEq(t, string(wantJSON), string(gotJSON)) + require.Zero(t, scalarLookups, "ownership metadata must replace per-result GetNode") + require.Zero(t, counter.allNodes, "read-context wrapping must preserve server-side capabilities") + }) + } +} diff --git a/internal/mcp/deadcode_workspace_pushdown_test.go b/internal/mcp/deadcode_workspace_pushdown_test.go new file mode 100644 index 000000000..bb28c8c74 --- /dev/null +++ b/internal/mcp/deadcode_workspace_pushdown_test.go @@ -0,0 +1,54 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +type workspaceDeadCodeSpy struct { + *store_sqlite.Store + foreign *bool +} + +func (r *workspaceDeadCodeSpy) BindReadContext(ctx context.Context) graph.Reader { + return &workspaceDeadCodeSpy{Store: r.WithReadContext(ctx), foreign: r.foreign} +} +func (r *workspaceDeadCodeSpy) DeadCodeCandidatesInReposFiltered(k []graph.NodeKind, in map[graph.NodeKind][]graph.EdgeKind, repos []string, filter graph.OutlineEdgeFilter) ([]*graph.Node, error) { + nodes, err := r.Store.DeadCodeCandidatesInReposFiltered(k, in, repos, filter) + for _, n := range nodes { + if n.WorkspaceID == "foreign" { + *r.foreign = true + } + } + return nodes, err +} +func TestDeadCodeWorkspaceFiltersBeforeCandidateAdjacency(t *testing.T) { + s, store := outlineFixture(t) + store.AddBatch([]*graph.Node{ + {ID: "own-private", Name: "privateOwn", Kind: graph.KindFunction, RepoPrefix: "stale-owner", WorkspaceID: "ws", FilePath: "own.go", Language: "go"}, + {ID: "foreign-private", Name: "privateForeign", Kind: graph.KindMethod, RepoPrefix: "foreign", WorkspaceID: "foreign", FilePath: "foreign.go", Language: "go"}, + }, nil) + foreign := false + reader := newContractCoreEdges(&workspaceDeadCodeSpy{Store: store, foreign: &foreign}, t.Context(), nil) + ctx := outlineBoundContext(s, "workspace-only", "ws", nil) + ctx = withRequestView(ctx, &requestView{reader: reader}) + result, err := s.handleFindDeadCode(ctx, makeReq("find_dead_code", map[string]any{"format": "json"})) + require.NoError(t, err) + require.Contains(t, toolResultText(result), "own-private", "registry-independent workspace metadata remains authoritative") + require.NotContains(t, toolResultText(result), "foreign-private") + require.False(t, foreign, "foreign candidates must be excluded before the method adjacency pass, not only from final output") +} + +func (r *workspaceDeadCodeSpy) DeadCodeCandidatesInScopeFiltered(k []graph.NodeKind, in map[graph.NodeKind][]graph.EdgeKind, repos []string, scope graph.NodeScope, filter graph.OutlineEdgeFilter, exclude graph.DeadCodeCandidateExclusions) ([]*graph.Node, error) { + nodes, err := r.Store.DeadCodeCandidatesInScopeFiltered(k, in, repos, scope, filter, exclude) + for _, n := range nodes { + if n.WorkspaceID == "foreign" { + *r.foreign = true + } + } + return nodes, err +} diff --git a/internal/mcp/outline_cache.go b/internal/mcp/outline_cache.go new file mode 100644 index 000000000..65a019daf --- /dev/null +++ b/internal/mcp/outline_cache.go @@ -0,0 +1,337 @@ +package mcp + +import ( + "encoding/json" + "slices" + "strings" + "sync" + "unsafe" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +const ( + outlineCacheEntries = 32 + outlineCachePayloadBytes = 64 << 10 + outlineCacheIdentityBytes = 4 << 10 +) + +type outlineResponseKey struct { + reader *store_sqlite.Store + revision uint64 + analysisReader *store_sqlite.Store + analysisRevision uint64 + epoch, scopeEpoch uint64 + generation int64 + ready bool + scope, mask string +} + +const outlineSummaryEntries = 16 + +type outlineResponseCache struct { + proof outlineResponseKey + proofSet bool + summaryKeys []outlineResponseKey + summaries map[outlineResponseKey]outlinePreparedSummary + mu sync.Mutex + scopeEpoch uint64 + keys []outlineResponseKey + values map[outlineResponseKey]map[string]any +} + +// Only known concrete stores and the exact contract-core mask are cacheable. +// A composed/overlay reader may change without the base revision advancing. +// No capability probe tunnels through those readers to the physical store. +func (s *Server) outlineCacheKey(reader graph.Reader, scope graph.NodeScope, bound bool) (outlineResponseKey, bool) { + if !bound { + return outlineResponseKey{}, false + } + key := outlineResponseKey{} + if wrapped, ok := reader.(contractCoreWrapped); ok { + core := wrapped.contractCore() + reader = core.Reader + if core.contractIDs == nil { + key.mask = "kinds" + } else { + ids := make([]string, 0, len(core.contractIDs)) + for id, excluded := range core.contractIDs { + if excluded { + ids = append(ids, id) + } + } + slices.Sort(ids) + data, _ := json.Marshal(ids) + key.mask = "ids:" + string(data) + } + } + store, ok := reader.(*store_sqlite.Store) + if !ok { + return outlineResponseKey{}, false + } + analysisStore, ok := s.graph.(*store_sqlite.Store) + if !ok { + return outlineResponseKey{}, false + } + // Both clocks are complete mutation clocks, not counts. The exact handle + // fixes payload generation; rebinding another view changes the key too. + key.reader, key.revision = store, store.MutationRevision() + key.analysisReader, key.analysisRevision = analysisStore, analysisStore.MutationRevision() + writer, _ := s.analysisGenerationBackends() + if writer == nil || !s.analysisMu.TryRLock() { + return outlineResponseKey{}, false + } + key.epoch = s.analysisEpoch + key.generation = s.analysisGeneration.GenerationID + key.ready = s.analysisGenerationReady + current := s.analysisEpoch > 0 && s.communitiesToken.analysisRevision == writer.AnalysisMutationRevision() && s.communitiesToken.edgeIdentity == s.graph.EdgeIdentityRevisions() + hotReady := s.hotspotsReady + s.analysisMu.RUnlock() + // Do not turn a transient failed/lazy materialization into a permanent + // empty answer. Before the first analysis, absence retains legacy behavior. + if current && !hotReady { + return outlineResponseKey{}, false + } + key.scope = outlineScopeIdentity(scope) + if len(key.scope) > outlineCacheIdentityBytes || len(key.mask) > outlineCacheIdentityBytes { + return outlineResponseKey{}, false + } + s.outlineResponses.mu.Lock() + key.scopeEpoch = s.outlineResponses.scopeEpoch + s.outlineResponses.mu.Unlock() + return key, true +} + +func outlineScopeIdentity(scope graph.NodeScope) string { + repos := make([]string, 0, len(scope.RepoAllow)) + for repo, allowed := range scope.RepoAllow { + if allowed { + repos = append(repos, repo) + } + } + slices.Sort(repos) + data, _ := json.Marshal(struct { + Workspace, Project string + Narrow bool + Repos []string + }{scope.WorkspaceID, scope.ProjectID, len(scope.RepoAllow) > 0, repos}) + return string(data) +} + +func (c *outlineResponseCache) clear() { + c.mu.Lock() + c.scopeEpoch++ + c.keys = nil + c.values = nil + c.summaryKeys = nil + c.summaries = nil + c.proofSet = false + c.mu.Unlock() +} +func (c *outlineResponseCache) get(key outlineResponseKey) (map[string]any, bool) { + c.mu.Lock() + payload, ok := c.values[key] + c.mu.Unlock() + if !ok { + return nil, false + } + return cloneOutlinePayload(payload), true +} +func (c *outlineResponseCache) put(key outlineResponseKey, payload map[string]any) { + data, err := json.Marshal(payload) + if err != nil || len(data) > outlineCachePayloadBytes { + return + } + owned := cloneOutlinePayload(payload) + c.mu.Lock() + defer c.mu.Unlock() + if !c.prepareProofLocked(key) { + return + } + if _, ok := c.values[key]; ok { + return + } + if len(c.keys) == outlineCacheEntries { + delete(c.values, c.keys[0]) + copy(c.keys, c.keys[1:]) + c.keys = c.keys[:len(c.keys)-1] + } + if c.values == nil { + c.values = make(map[outlineResponseKey]map[string]any) + } + c.keys = append(c.keys, key) + c.values[key] = owned +} + +// prepareProofLocked drops old derived state and rejects a delayed producer +// from an older mutation/analysis epoch instead of letting it evict fresh data. +func (c *outlineResponseCache) prepareProofLocked(key outlineResponseKey) bool { + if key.scopeEpoch != c.scopeEpoch { + return false + } + if c.proofSet { + old := c.proof + if old.reader == key.reader && old.analysisReader == key.analysisReader && (key.revision < old.revision || key.analysisRevision < old.analysisRevision || key.epoch < old.epoch) { + return false + } + if old.reader != key.reader || old.revision != key.revision || old.analysisReader != key.analysisReader || old.analysisRevision != key.analysisRevision || old.epoch != key.epoch || old.generation != key.generation || old.ready != key.ready { + c.keys = nil + c.values = nil + c.summaryKeys = nil + c.summaries = nil + } + } + c.proof = key + c.proofSet = true + return true +} + +type outlinePreparedSummary struct { + imports []map[string]any // Complete displayed top ten, not an incomplete graph aggregate API. + counts graph.OutlineCounts + communities map[string]any // nil means unavailable: retain the exact live fallback. +} + +func (c *outlineResponseCache) getSummary(key outlineResponseKey) (outlinePreparedSummary, bool) { + c.mu.Lock() + summary, ok := c.summaries[key] + c.mu.Unlock() + if !ok { + return outlinePreparedSummary{}, false + } + out := outlinePreparedSummary{counts: cloneOutlineCounts(summary.counts)} + out.imports = cloneOutlineValue(summary.imports).([]map[string]any) + if summary.communities != nil { + out.communities = cloneOutlinePayload(summary.communities) + } + return out, true +} +func (c *outlineResponseCache) putSummary(key outlineResponseKey, counts graph.OutlineCounts, communities map[string]any) (int, bool) { + imports := make(map[string]int, len(counts.FileImports)) + for _, row := range counts.FileImports { + imports[row.FilePath] = row.Count + } + displayed := rankImportedFiles(imports, outlineMostImportedLimit) + // Only outlines consume this private preparation. Retain the complete final + // top-ten section; do not retain all candidate paths or change graph APIs. + counts.FileImports = nil + bytes := outlineSummaryOwnedBytes(counts) + data, err := json.Marshal(displayed) + if err != nil { + return 0, false + } + bytes += len(data) + 4096 + + if communities != nil { + data, err := json.Marshal(communities) + if err != nil { + return 0, false + } + bytes += len(data) + 2048 // fixed section: at most five small maps and scalar leaves. + } + if bytes > outlineCachePayloadBytes { + return 0, false + } + owned := outlinePreparedSummary{counts: cloneOutlineCounts(counts), imports: cloneOutlineValue(displayed).([]map[string]any)} + if communities != nil { + owned.communities = cloneOutlinePayload(communities) + } + c.mu.Lock() + defer c.mu.Unlock() + if !c.prepareProofLocked(key) { + return 0, false + } + if _, exists := c.summaries[key]; exists { + return bytes, true + } + if len(c.summaryKeys) == outlineSummaryEntries { + return 0, false + } + if c.summaries == nil { + c.summaries = make(map[outlineResponseKey]outlinePreparedSummary) + } + c.summaryKeys = append(c.summaryKeys, key) + c.summaries[key] = owned + return bytes, true +} + +// A conservative owned-allocation envelope, including exact-length row slice, +// cloned string allocation rounding, and map storage (128 bytes per entry is +// above the Go map slot/bucket share). Reject before cloning, never truncate. +func outlineSummaryOwnedBytes(counts graph.OutlineCounts) int { + bytes := int(unsafe.Sizeof(counts)) + 64 + add := func(n int) bool { + if n > outlineCachePayloadBytes-bytes { + bytes = outlineCachePayloadBytes + 1 + return false + } + bytes += n + return true + } + for language := range counts.ByLanguage { + if !add(128 + len(language) + 16) { + return bytes + } + } + for _, row := range counts.FileImports { + if !add(int(unsafe.Sizeof(row)) + len(row.FilePath) + 16) { + return bytes + } + } + return bytes +} + +func cloneOutlineCounts(counts graph.OutlineCounts) graph.OutlineCounts { + out := counts + out.ByLanguage = make(map[string]int, len(counts.ByLanguage)) + for language, count := range counts.ByLanguage { + out.ByLanguage[strings.Clone(language)] = count + } + if counts.FileImports != nil { + out.FileImports = make([]graph.FileImportCountRow, len(counts.FileImports)) + for i, row := range counts.FileImports { + out.FileImports[i] = graph.FileImportCountRow{FilePath: strings.Clone(row.FilePath), Count: row.Count} + } + } + return out +} + +// The payload has one small, fixed map/list vocabulary. Copy it before +// formatting/degradation and own every retained string: backend substrings or +// request decorations must not retain large buffers or mutate another reply. +func cloneOutlinePayload(payload map[string]any) map[string]any { + out := make(map[string]any, len(payload)) + for key, value := range payload { + out[key] = cloneOutlineValue(value) + } + return out +} +func cloneOutlineValue(value any) any { + switch v := value.(type) { + case map[string]any: + return cloneOutlinePayload(v) + case []map[string]any: + if v == nil { + return v + } + out := make([]map[string]any, len(v)) + for i, item := range v { + out[i] = cloneOutlinePayload(item) + } + return out + case []outlineLanguage: + if v == nil { + return v + } + out := slices.Clone(v) + for i := range out { + out[i].Name = strings.Clone(out[i].Name) + } + return out + case string: + return strings.Clone(v) + default: + return v // The remaining payload leaves are immutable scalar numbers. + } +} diff --git a/internal/mcp/outline_cache_test.go b/internal/mcp/outline_cache_test.go new file mode 100644 index 000000000..a771c6b6c --- /dev/null +++ b/internal/mcp/outline_cache_test.go @@ -0,0 +1,128 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +func TestOutlineCacheProofUsesMutationScopeAndAnalysisIdentity(t *testing.T) { + s, store := outlineFixture(t) + scope := graph.NodeScope{WorkspaceID: "ws", ProjectID: "p", RepoAllow: map[string]bool{"a": true, "b": false}} + first, ok := s.outlineCacheKey(store, scope, true) + require.True(t, ok) + reorder := scope + reorder.RepoAllow = map[string]bool{"b": false, "a": true} + same, ok := s.outlineCacheKey(store, reorder, true) + require.True(t, ok) + require.Equal(t, first, same) + for _, other := range []graph.NodeScope{ + {WorkspaceID: "other", ProjectID: "p", RepoAllow: scope.RepoAllow}, + {WorkspaceID: "ws", ProjectID: "other", RepoAllow: scope.RepoAllow}, + {WorkspaceID: "ws", ProjectID: "p", RepoAllow: map[string]bool{"b": true}}, + {WorkspaceID: "ws", ProjectID: "p", RepoAllow: map[string]bool{"a": false}}, + } { + key, ok := s.outlineCacheKey(store, other, true) + require.True(t, ok) + require.NotEqual(t, first, key) + } + // An unchanged row count and unchanged edge count do not prove freshness. + n := store.GetNode("a-main") + n.Name = "renamed" + store.AddNode(n) + metadata, ok := s.outlineCacheKey(store, scope, true) + require.True(t, ok) + require.NotEqual(t, first, metadata) + s.analysisMu.Lock() + s.analysisEpoch++ + s.analysisMu.Unlock() + epoch, ok := s.outlineCacheKey(store, scope, true) + require.True(t, ok) + require.NotEqual(t, metadata, epoch) + s.InvalidateSessionScopes() + retrack, ok := s.outlineCacheKey(store, scope, true) + require.True(t, ok) + require.NotEqual(t, epoch, retrack) + // Unknown revision domains and composed visibility never inherit a base key. + for _, r := range []graph.Reader{legacyOutlineReader{store}, graph.New(), graph.NewOverlaidView(store, graph.NewOverlayLayer())} { + _, ok := s.outlineCacheKey(r, scope, true) + require.False(t, ok) + } + _, ok = s.outlineCacheKey(store, scope, false) + require.False(t, ok) + for _, ids := range []map[string]bool{nil, {}, {"a-main": true}} { + key, ok := s.outlineCacheKey(newContractCoreEdges(store, t.Context(), ids), scope, true) + require.True(t, ok) + require.NotEqual(t, retrack, key) + } + // Retargeting even to another generation of the same physical store cannot + // reuse a result from the old payload handle. + view, ok := s.outlineCacheKey(store.AtGeneration(7), scope, true) + require.True(t, ok) + require.NotEqual(t, retrack, view) +} + +func TestOutlineCacheOwnsAndBoundsPayloads(t *testing.T) { + c := outlineResponseCache{} + key := outlineResponseKey{scope: "a"} + payload := map[string]any{ + "summary": map[string]any{"languages": []outlineLanguage{{Name: "go", Nodes: 3}}}, + "hotspots": []map[string]any{{"name": "before"}}, + } + c.put(key, payload) + payload["hotspots"].([]map[string]any)[0]["name"] = "outside mutation" + first, ok := c.get(key) + require.True(t, ok) + require.Equal(t, "before", first["hotspots"].([]map[string]any)[0]["name"]) + first["summary"].(map[string]any)["languages"].([]outlineLanguage)[0].Name = "changed" + first["hotspots"].([]map[string]any)[0]["name"] = "response decoration" + second, ok := c.get(key) + require.True(t, ok) + require.Equal(t, "before", second["hotspots"].([]map[string]any)[0]["name"]) + require.Equal(t, "go", second["summary"].(map[string]any)["languages"].([]outlineLanguage)[0].Name) + for i := 0; i < outlineCacheEntries+3; i++ { + c.put(outlineResponseKey{scope: fmt.Sprint(i)}, payload) + } + require.Len(t, c.keys, outlineCacheEntries) + require.Len(t, c.values, outlineCacheEntries) + c.put(outlineResponseKey{scope: "oversized"}, map[string]any{"name": strings.Repeat("x", outlineCachePayloadBytes)}) + _, ok = c.get(outlineResponseKey{scope: "oversized"}) + require.False(t, ok) + c.put(outlineResponseKey{revision: 1, scope: "next"}, payload) + require.Len(t, c.keys, 1, "old revision history must be dropped") + c.clear() + c.put(key, payload) + require.Empty(t, c.keys, "a pre-untrack producer cannot republish after scope invalidation") +} + +func TestOutlineCacheReformatsAndInvalidatesRealReplies(t *testing.T) { + s, store := outlineFixture(t) + ctx := outlineBoundContext(s, "cache", "ws", map[string]bool{"a": true}) + initial := outlineJSON(t, s, ctx, store) + require.Len(t, s.outlineResponses.keys, 1) + require.Equal(t, initial, outlineJSON(t, s, ctx, store)) + // Request-specific formatting is applied after fetching an owned payload. + result, err := s.handleGetRepoOutline(ctx, makeReq("get_repo_outline", map[string]any{"format": "json", "fields": "summary"})) + require.NoError(t, err) + filtered := resultJSON(t, result) + require.Contains(t, filtered, "summary") + require.Empty(t, filtered["hotspots"].([]any)[0], "fields selects list-record fields while preserving the outline sections") + require.Equal(t, initial, outlineJSON(t, s, ctx, store), "field selection must not mutate a cached payload") + n := store.GetNode("a-main") + n.Name = "noLongerMain" + store.AddNode(n) + updated := outlineJSON(t, s, ctx, store) + require.NotEqual(t, initial, updated) + require.Len(t, s.outlineResponses.keys, 1) + cancelled, cancel := context.WithCancel(ctx) + cancel() + _, err = s.handleGetRepoOutline(cancelled, makeReq("get_repo_outline", map[string]any{"format": "json"})) + require.ErrorIs(t, err, context.Canceled) + require.Len(t, s.outlineResponses.keys, 1) + s.InvalidateSessionScopes() + require.Empty(t, s.outlineResponses.keys) +} diff --git a/internal/mcp/outline_generation.go b/internal/mcp/outline_generation.go index 324beb70e..a28af353f 100644 --- a/internal/mcp/outline_generation.go +++ b/internal/mcp/outline_generation.go @@ -21,7 +21,7 @@ func (s *Server) outlineGenerationCommunities(ctx context.Context, reader graph. if !ok { return nil, false, nil } - _, header, current := s.activeAnalysisQuery() + _, header, current := s.activeAnalysisQueryForAnswer() if !current { return nil, false, nil } @@ -53,7 +53,7 @@ func (s *Server) outlineGenerationCommunities(ctx context.Context, reader graph. } // Pages must all describe the same still-current publication. Never mix // a partition read before a mutation with nodes from a subsequent revision. - _, after, current := s.activeAnalysisQuery() + _, after, current := s.activeAnalysisQueryForAnswer() if !current || after.GenerationID != header.GenerationID { return nil, false, nil } diff --git a/internal/mcp/outline_hotspots.go b/internal/mcp/outline_hotspots.go new file mode 100644 index 000000000..68d4bb2a0 --- /dev/null +++ b/internal/mcp/outline_hotspots.go @@ -0,0 +1,43 @@ +package mcp + +import ( + "context" + "time" + + "github.com/zzet/gortex/internal/analysis" +) + +// warmOutlineHotspots reuses the one existing global scorer only after an +// analysis publication succeeded and still matches the graph. The duration is +// reported separately: preparing steady-state outlines moves this work into +// the explicit analysis pass, it does not make the work disappear. +func (s *Server) warmOutlineHotspots() time.Duration { + return s.warmOutlineHotspotsContext(context.Background()) +} + +func (s *Server) warmOutlineHotspotsContext(ctx context.Context) time.Duration { + if ctx.Err() != nil || !s.analysisSnapshotStillCurrent() { + return 0 + } + started := time.Now() + _ = s.getHotspotsContext(ctx) + return time.Since(started) +} + +// outlineHotspots reuses a published ranking without whole-corpus COUNTs. +// The complete analysis mutation clock and edge-identity token are the same +// proof already used by communityTokenForAnswer. Unsupported sources retain +// the existing producer/currentness path and its conservative counting. +func (s *Server) outlineHotspots() []analysis.HotspotEntry { + writer, _ := s.analysisGenerationBackends() + if writer != nil { + s.analysisMu.RLock() + if s.analysisEpoch > 0 && s.hotspotsReady && s.communitiesToken.analysisRevision == writer.AnalysisMutationRevision() && s.communitiesToken.edgeIdentity == s.graph.EdgeIdentityRevisions() { + result := s.hotspots + s.analysisMu.RUnlock() + return result + } + s.analysisMu.RUnlock() + } + return s.getHotspots() +} diff --git a/internal/mcp/outline_hotspots_test.go b/internal/mcp/outline_hotspots_test.go new file mode 100644 index 000000000..26090ae60 --- /dev/null +++ b/internal/mcp/outline_hotspots_test.go @@ -0,0 +1,71 @@ +package mcp + +import ( + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" +) + +func TestAnalysisWarmsOneCurrentOutlineRankingAndReportsCost(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "0") + store := seedAnalysisWiringStore(t) + s, _ := newAnalysisWiringServer(t, store) + logs, entries := observer.New(zap.InfoLevel) + s.logger = zap.New(logs) + var builds atomic.Int32 + s.hotspotsFn = func(graph.Reader, *analysis.CommunityResult, float64) []analysis.HotspotEntry { + builds.Add(1) + return []analysis.HotspotEntry{{ID: "pkg/overlay.go::OverlayAlpha"}} + } + require.Nil(t, s.outlineHotspots(), "before-first-analysis behavior stays empty") + require.Zero(t, builds.Load()) + s.RunAnalysis() + require.EqualValues(t, 1, builds.Load()) + require.Len(t, s.outlineHotspots(), 1) + require.EqualValues(t, 1, builds.Load(), "outline must reuse the exact global producer") + s.RunAnalysis() + require.EqualValues(t, 1, builds.Load(), "a current/no-op pass must not rescore") + complete := entries.FilterMessage("mcp: analysis pass complete").All() + require.Len(t, complete, 1) + require.Contains(t, complete[0].ContextMap(), "hotspots_warmup") +} + +func TestOutlineWarmRankingRejectsSupersededGraph(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "0") + store := seedAnalysisWiringStore(t) + s, _ := newAnalysisWiringServer(t, store) + var builds atomic.Int32 + s.hotspotsFn = func(graph.Reader, *analysis.CommunityResult, float64) []analysis.HotspotEntry { + builds.Add(1) + writeOverlayNode(t, s, "superseding-write") + return []analysis.HotspotEntry{{ID: "stale"}} + } + s.RunAnalysis() + require.EqualValues(t, 1, builds.Load()) + require.Nil(t, s.outlineHotspots(), "a write during global scoring must reject publication") + s.analysisMu.RLock() + ready := s.hotspotsReady + s.analysisMu.RUnlock() + require.False(t, ready) + require.Zero(t, s.warmOutlineHotspots(), "never warm an already stale snapshot") + require.EqualValues(t, 1, builds.Load()) +} + +func TestOutlineRankingReusesRevisionProofWithoutCorpusCounts(t *testing.T) { + store := seedAnalysisWiringStore(t) + s, _ := newAnalysisWiringServer(t, store) + s.graph = store + s.RunAnalysis() + counter := &countingAnalysisStore{Store: s.graph} + s.graph = counter + // The backend still supplies the same complete revision clock, so the + // retained rank needs neither NodeCount nor EdgeCount to establish liveness. + _ = s.outlineHotspots() + require.Zero(t, counter.counts.Load()) +} diff --git a/internal/mcp/outline_routing_test.go b/internal/mcp/outline_routing_test.go new file mode 100644 index 000000000..311ac2e20 --- /dev/null +++ b/internal/mcp/outline_routing_test.go @@ -0,0 +1,60 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// This drives the actual router and tool middleware, rather than injecting a +// Store in the request context. Primary checkout sessions are the production +// scoped-query path; automatic sibling stacks retain their own exact fallback. +func TestOutlinePrimaryRoutingPreservesStableCacheAndScopedQueries(t *testing.T) { + v := newViewStack(t) + t.Cleanup(v.srv.DrainBackground) + installContractCoreKindTestRuntime(t, v.srv) + v.srv.RunAnalysis() + var first outlineResponseKey + for round := 0; round < 2; round++ { + result, err := v.callWithView(t, v.repoRoot, "get_repo_outline", map[string]any{"format": "json"}, func(ctx context.Context) (*mcp.CallToolResult, error) { + reader := v.srv.readerFor(ctx) + core, ok := reader.(contractCoreWrapped) + require.True(t, ok) + require.Same(t, v.store, core.contractCore().Reader, "primary routing must keep the shared Store handle") + require.Implements(t, (*graph.ScopedDeadCodeReader)(nil), reader) + require.Implements(t, (*graph.FilteredScopedDeadCodeReader)(nil), core.contractCore().Reader) + _, bound := v.srv.sessionScopeOptions(ctx) + require.True(t, bound) + opts, _ := v.srv.scopeOptionsWithRepoNarrow(ctx, repoAllowFromContext(ctx)) + key, cacheable := v.srv.outlineCacheKey(reader, graph.NodeScope{WorkspaceID: opts.WorkspaceID, ProjectID: opts.ProjectID, RepoAllow: opts.RepoAllow}, bound) + require.True(t, cacheable) + if round == 0 { + first = key + } else { + require.Equal(t, first, key, "per-request core wrappers must not churn the identity") + } + return v.srv.handleGetRepoOutline(ctx, makeReq("get_repo_outline", map[string]any{"format": "json"})) + }) + require.NoError(t, err) + require.False(t, result.IsError) + require.Len(t, v.srv.outlineResponses.keys, 1) + } + result, err := v.callWithView(t, v.worktreeRoot, "get_repo_outline", map[string]any{"format": "json"}, func(ctx context.Context) (*mcp.CallToolResult, error) { + reader := v.srv.readerFor(ctx) + core, ok := reader.(contractCoreWrapped) + require.True(t, ok) + require.NotSame(t, v.store, core.contractCore().Reader) + opts, bound := v.srv.sessionScopeOptions(ctx) + require.True(t, bound) + _, cacheable := v.srv.outlineCacheKey(reader, graph.NodeScope{WorkspaceID: opts.WorkspaceID, ProjectID: opts.ProjectID, RepoAllow: opts.RepoAllow}, bound) + require.False(t, cacheable, "a routed stack cannot inherit its physical store's visibility or revision proof") + require.Implements(t, (*graph.ScopedDeadCodeReader)(nil), reader, "the mask must still expose its exact-reader fallback") + return v.srv.handleGetRepoOutline(ctx, makeReq("get_repo_outline", map[string]any{"format": "json"})) + }) + require.NoError(t, err) + require.False(t, result.IsError) +} diff --git a/internal/mcp/outline_summary.go b/internal/mcp/outline_summary.go new file mode 100644 index 000000000..ec2c82cea --- /dev/null +++ b/internal/mcp/outline_summary.go @@ -0,0 +1,136 @@ +package mcp + +import ( + "context" + "slices" + "time" + + "github.com/zzet/gortex/internal/graph" +) + +const outlineSummaryWarmupLimit = time.Minute + +type outlineSummaryWarmup struct { + elapsed time.Duration + scopes, ready, bytes int +} + +// knownOutlineScopes is registry-derived, never borrowed from an active +// session/project. Deterministic workspace scopes are preferred; exact repo +// narrows fill remaining slots. Unknown/custom scopes use the normal query. +func (s *Server) knownOutlineScopes() []graph.NodeScope { + if s.multiIndexer == nil { + return nil + } + prefixes := s.multiIndexer.RepoPrefixes() + slices.Sort(prefixes) + var workspaces, narrows []graph.NodeScope + seen := make(map[string]bool) + add := func(dst *[]graph.NodeScope, scope graph.NodeScope) { + identity := outlineScopeIdentity(scope) + if !seen[identity] { + seen[identity] = true + *dst = append(*dst, scope) + } + } + for _, prefix := range prefixes { + root, ok := s.multiIndexer.RepoRoot(prefix) + if !ok { + continue + } + ws, _, owner, ok := s.multiIndexer.ScopeForCWD(root) + if !ok || ws == "" || owner != prefix { + continue + } + add(&workspaces, graph.NodeScope{WorkspaceID: ws}) + add(&narrows, graph.NodeScope{WorkspaceID: ws, RepoAllow: map[string]bool{prefix: true}}) + } + scopes := append(workspaces, narrows...) + if len(scopes) > outlineSummaryEntries { + scopes = scopes[:outlineSummaryEntries] + } + return scopes +} + +// warmOutlineSummaries prepares only the fixed known scopes after explicit +// analysis. It never runs from an outline miss or an edit callback. Results +// remain exact only for this complete mutation/analysis proof: a write or scope +// reset invalidates them, and the next request takes the original COUNT path. +func (s *Server) warmOutlineSummaries(parent context.Context) outlineSummaryWarmup { + var metrics outlineSummaryWarmup + if parent.Err() != nil { + return metrics + } + if !s.analysisSnapshotStillCurrent() { + return metrics + } + scopes := s.knownOutlineScopes() + if len(scopes) == 0 { + return metrics + } + reader := s.readerFor(parent) + first, ok := s.outlineCacheKey(reader, scopes[0], true) + // Only canonical/default adjacency, never arbitrary identity masks. + if !ok || (first.mask != "" && first.mask != "kinds") { + return metrics + } + started := time.Now() + ctx, cancel := context.WithTimeout(parent, outlineSummaryWarmupLimit) + defer cancel() + done := make(chan struct{}) + stopped := make(chan struct{}) + defer func() { close(done); <-stopped }() + go func() { + defer close(stopped) + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ctx.Done(): + return + case <-ticker.C: + if s.editLaneBusy() || first.reader.MutationRevision() != first.revision || first.analysisReader.MutationRevision() != first.analysisRevision { + cancel() + return + } + } + } + }() + metrics.scopes = len(scopes) + for _, scope := range scopes { + if ctx.Err() != nil || s.editLaneBusy() { + break + } + key, ok := s.outlineCacheKey(reader, scope, true) + if !ok || key.revision != first.revision || key.analysisRevision != first.analysisRevision || key.epoch != first.epoch || key.scopeEpoch != first.scopeEpoch { + break + } + counts, supported, err := scopedOutlineCountsFor(graph.BindReadContext(reader, ctx), scope) + if err != nil || !supported { + break + } + var communitySection map[string]any + communities, available, err := s.outlineGenerationCommunities(ctx, reader, scope) + if err != nil { + break + } + if available { + communitySection = map[string]any{"count": 0} + if communities != nil { + communitySection = outlineCommunitySection(communities.Communities, communities.Modularity) + } + } + after, ok := s.outlineCacheKey(reader, scope, true) + if !ok || after != key || ctx.Err() != nil { + break + } + if bytes, stored := s.outlineResponses.putSummary(key, counts, communitySection); stored { + metrics.ready++ + metrics.bytes += bytes + } + } + metrics.elapsed = time.Since(started) + return metrics +} diff --git a/internal/mcp/outline_summary_test.go b/internal/mcp/outline_summary_test.go new file mode 100644 index 000000000..b8af82f1b --- /dev/null +++ b/internal/mcp/outline_summary_test.go @@ -0,0 +1,128 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + "testing" + "unsafe" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +func TestOutlineSummaryPreparationHasFixedScopesAndNoResponseWarmup(t *testing.T) { + v := newViewStack(t) + t.Cleanup(v.srv.DrainBackground) + installContractCoreKindTestRuntime(t, v.srv) + scopes := v.srv.knownOutlineScopes() + require.NotEmpty(t, scopes) + require.Equal(t, scopes, v.srv.knownOutlineScopes(), "capture is deterministic and independent of session/project state") + for _, scope := range scopes { + require.Empty(t, scope.ProjectID) + } + v.srv.RunAnalysis() + require.Empty(t, v.srv.outlineResponses.keys, "explicit analysis prepares aggregates, never an outline response") + require.Len(t, v.srv.outlineResponses.summaryKeys, len(scopes)) + for _, scope := range scopes { + key, ok := v.srv.outlineCacheKey(v.srv.readerFor(t.Context()), scope, true) + require.True(t, ok) + prepared, ok := v.srv.outlineResponses.getSummary(key) + require.True(t, ok) + exact, supported, err := scopedOutlineCountsFor(v.srv.readerFor(t.Context()), scope) + require.True(t, supported) + require.NoError(t, err) + require.Equal(t, exact.TotalNodes, prepared.counts.TotalNodes) + require.Equal(t, exact.TotalEdges, prepared.counts.TotalEdges) + require.Equal(t, exact.ByLanguage, prepared.counts.ByLanguage) + counts := make(map[string]int) + for _, row := range exact.FileImports { + counts[row.FilePath] = row.Count + } + require.Equal(t, rankImportedFiles(counts, outlineMostImportedLimit), prepared.imports) + require.NotNil(t, prepared.communities) + } + // A cancelled caller or active edit lane does no aggregate SQL work and + // schedules no background retry. The analysis snapshot itself stays valid. + v.srv.outlineResponses.clear() + cancelled, cancel := context.WithCancel(t.Context()) + cancel() + require.Zero(t, v.srv.warmOutlineSummaries(cancelled).ready) + v.srv.DrainBackground() + previous := editLaneBusyForTest + editLaneBusyForTest = func() bool { return true } + t.Cleanup(func() { editLaneBusyForTest = previous }) + require.Zero(t, v.srv.warmOutlineSummaries(t.Context()).ready) + editLaneBusyForTest = previous + require.Empty(t, v.srv.outlineResponses.summaryKeys) + require.Positive(t, v.srv.warmOutlineSummaries(t.Context()).ready) + // A real same-store mutation invalidates every prepared scope. Requests may + // compute an exact response, but must not rebuild/queue aggregate preparation. + v.store.AddNode(&graph.Node{ID: "repo/new", Name: "new", Kind: graph.KindFunction, RepoPrefix: "repo", WorkspaceID: "main-ws", FilePath: "repo/new.go"}) + for _, scope := range scopes { + key, ok := v.srv.outlineCacheKey(v.srv.readerFor(t.Context()), scope, true) + require.True(t, ok) + _, ready := v.srv.outlineResponses.getSummary(key) + require.False(t, ready) + } + require.Zero(t, v.srv.warmOutlineSummaries(context.Background()).ready, "stale analysis cannot prepare scopes") + v.srv.InvalidateSessionScopes() + require.Empty(t, v.srv.outlineResponses.summaryKeys) +} + +func TestOutlinePreparedSectionsOwnAndBoundExactFinalRankings(t *testing.T) { + cache := outlineResponseCache{} + counts := graph.OutlineCounts{TotalNodes: 100, TotalEdges: 99, ByLanguage: map[string]int{"go": 100}} + for i := 0; i < 100; i++ { + counts.FileImports = append(counts.FileImports, graph.FileImportCountRow{FilePath: fmt.Sprintf("repo/%03d.go", i), Count: i / 2}) + } + community := map[string]any{"count": 2, "modularity": .5, "top": []map[string]any{{"label": "original"}}} + key := outlineResponseKey{scope: "ready"} + bytes, ok := cache.putSummary(key, counts, community) + require.True(t, ok) + require.LessOrEqual(t, bytes, outlineCachePayloadBytes) + counts.ByLanguage["go"] = 0 + community["top"].([]map[string]any)[0]["label"] = "mutated" + first, ok := cache.getSummary(key) + require.True(t, ok) + require.Equal(t, 100, first.counts.ByLanguage["go"]) + require.Len(t, first.imports, outlineMostImportedLimit) + require.Equal(t, "repo/098.go", first.imports[0]["path"], "equal counts keep ascending canonical path order") + require.Empty(t, first.counts.FileImports, "retain the exact final ranking, not all candidate paths") + first.imports[0]["path"] = "changed" + first.communities["top"].([]map[string]any)[0]["label"] = "changed" + second, ok := cache.getSummary(key) + require.True(t, ok) + require.Equal(t, "repo/098.go", second.imports[0]["path"]) + require.Equal(t, "original", second.communities["top"].([]map[string]any)[0]["label"]) + _, ok = cache.putSummary(outlineResponseKey{scope: "too-large"}, graph.OutlineCounts{ByLanguage: map[string]int{strings.Repeat("x", outlineCachePayloadBytes): 1}}, nil) + require.False(t, ok) + // A delayed old epoch cannot republish after either a fresh revision or a + // scope reset; this is the same guard used by response publication. + fresh := outlineResponseKey{scope: "fresh", revision: 2, epoch: 2} + _, ok = cache.putSummary(fresh, graph.OutlineCounts{}, nil) + require.True(t, ok) + _, ok = cache.putSummary(key, graph.OutlineCounts{}, nil) + require.False(t, ok) + require.Len(t, cache.summaryKeys, 1) + cache.clear() + _, ok = cache.putSummary(fresh, graph.OutlineCounts{}, nil) + require.False(t, ok) +} + +func TestOutlineSummaryOwnsSmallSubstringsFromLargeCandidates(t *testing.T) { + source := strings.Repeat("q", 1<<20) + path := source[100:120] + language := source[200:203] + counts := graph.OutlineCounts{ByLanguage: map[string]int{language: 1}, FileImports: []graph.FileImportCountRow{{FilePath: path, Count: 1}}} + cache := outlineResponseCache{} + _, ok := cache.putSummary(outlineResponseKey{}, counts, nil) + require.True(t, ok) + retained := cache.summaries[outlineResponseKey{}] + for key := range retained.counts.ByLanguage { + require.Equal(t, language, key) + require.True(t, unsafe.StringData(language) != unsafe.StringData(key)) + } + require.Equal(t, path, retained.imports[0]["path"]) + require.True(t, unsafe.StringData(path) != unsafe.StringData(retained.imports[0]["path"].(string))) +} diff --git a/internal/mcp/outline_warmup_context_test.go b/internal/mcp/outline_warmup_context_test.go new file mode 100644 index 000000000..8638009e8 --- /dev/null +++ b/internal/mcp/outline_warmup_context_test.go @@ -0,0 +1,131 @@ +package mcp + +import ( + "context" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +type heldWarmHotspotStore struct { + *store_sqlite.Store + owner *Server + ctx context.Context + entered chan struct{} + release chan struct{} + once *sync.Once +} + +func (s *heldWarmHotspotStore) BindReadContext(ctx context.Context) graph.Reader { + return &heldWarmHotspotStore{Store: s.WithReadContext(ctx), owner: s.owner, ctx: ctx, entered: s.entered, release: s.release, once: s.once} +} + +func (s *heldWarmHotspotStore) NodeIDsByKinds(kinds []graph.NodeKind) []string { + // The ordinary analysis publication must finish first. Only the actual + // contextual hotspot scorer over that installed generation is held. + if s.ctx != nil && s.ctx.Done() != nil && s.owner.analysisSnapshotStillCurrent() { + s.once.Do(func() { close(s.entered) }) + select { + case <-s.ctx.Done(): + case <-s.release: + } + return nil + } + return s.Store.NodeIDsByKinds(kinds) +} + +func TestBackgroundCloseCancelsActualOutlineWarmupBeforeRankingPublication(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "0") + s, view := newAnalysisWiringServer(t, seedAnalysisWiringStore(t)) + logs, entries := observer.New(zap.InfoLevel) + s.logger = zap.New(logs) + entered, release := make(chan struct{}), make(chan struct{}) + s.graph = &heldWarmHotspotStore{Store: view, owner: s, entered: entered, release: release, once: &sync.Once{}} + pass := make(chan struct{}) + t.Cleanup(func() { s.BeginBackgroundClose(); close(release); s.DrainBackground(); <-pass }) + go func() { defer close(pass); s.RunAnalysis() }() + awaitMaintenanceDone(t, entered) + require.True(t, s.analysisPassActive.Load()) + s.BeginBackgroundClose() + drained := make(chan struct{}) + go func() { s.DrainBackground(); close(drained) }() + awaitMaintenanceDone(t, pass) + awaitMaintenanceDone(t, drained) + s.analysisMu.RLock() + ready := s.hotspotsReady + s.analysisMu.RUnlock() + require.False(t, ready, "cancelled/empty scorer must not install a ready ranking") + require.False(t, s.analysisPassActive.Load()) + require.Empty(t, entries.FilterMessage("mcp: analysis pass complete").All(), "cancelled preparation is not settled bootstrap") +} + +type observedPreparationContext struct { + context.Context + calls atomic.Int32 + waiting chan struct{} +} + +func (c *observedPreparationContext) Done() <-chan struct{} { + if c.calls.Add(1) == 2 { + close(c.waiting) + } + return c.Context.Done() +} + +func TestOutlineWarmupCancellationDoesNotWaitForLazyHotspotBuild(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "0") + s, _ := newAnalysisWiringServer(t, seedAnalysisWiringStore(t)) + s.RunAnalysis() + s.analysisMu.Lock() + s.hotspotsReady = false + s.analysisMu.Unlock() + entered, release, lazyDone, warmDone := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + var builds atomic.Int32 + s.hotspotsFn = func(graph.Reader, *analysis.CommunityResult, float64) []analysis.HotspotEntry { + builds.Add(1) + close(entered) + <-release + return []analysis.HotspotEntry{{ID: "complete-lazy-ranking"}} + } + base, cancel := context.WithCancel(t.Context()) + ctx := &observedPreparationContext{Context: base, waiting: make(chan struct{})} + warmStarted := false + t.Cleanup(func() { + cancel() + unblock() + awaitMaintenanceDone(t, lazyDone) + if warmStarted { + awaitMaintenanceDone(t, warmDone) + } + }) + go func() { defer close(lazyDone); _ = s.getHotspots() }() + awaitMaintenanceDone(t, entered) + warmStarted = true + go func() { defer close(warmDone); _ = s.warmOutlineHotspotsContext(ctx) }() + awaitMaintenanceDone(t, ctx.waiting) // Own context actually reached contended wait. + cancel() + awaitMaintenanceDone(t, warmDone) + select { + case <-lazyDone: + t.Fatal("cancelled warmup replaced joining the actual lazy owner") + default: + } + s.analysisMu.RLock() + ready := s.hotspotsReady + s.analysisMu.RUnlock() + require.False(t, ready) + unblock() + awaitMaintenanceDone(t, lazyDone) + require.EqualValues(t, 1, builds.Load(), "cancelled waiter must not restart scoring") + require.Equal(t, []analysis.HotspotEntry{{ID: "complete-lazy-ranking"}}, s.getHotspots(), "successful lazy publication stays unchanged") +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 76d4a1337..ce4ec8a14 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -208,16 +208,18 @@ type Server struct { communitiesToken communityCacheToken // hotspots is the lazily-built default-threshold (mean + 2*stddev) // ranking. FindHotspots' inner ComputeBetweenness pass is too expensive - // to put on every daemon cold-start path; RunAnalysis invalidates this - // cache and the first hotspot-consuming request rebuilds it once for that - // analysis epoch. Reads/cache state are guarded by analysisMu; the separate + // to repeat per request; a successful explicit RunAnalysis warms the same + // default ranking once for its published epoch. Legacy/lazy callers can + // still rebuild it on first demand. Reads/cache state are guarded by + // analysisMu; the separate // build mutex coalesces concurrent first requests without blocking unrelated // analysis snapshots while betweenness runs. - hotspots []analysis.HotspotEntry - hotspotsReady bool - hotspotsOwned bool // compact slice and cloned strings, guarded by analysisMu - hotspotsBuildMu sync.Mutex - analysisEpoch uint64 + hotspots []analysis.HotspotEntry + hotspotsReady bool + hotspotsOwned bool // compact slice and cloned strings, guarded by analysisMu + hotspotsBuildMu sync.Mutex + outlineResponses outlineResponseCache + analysisEpoch uint64 // analysisRun tracks the background on-demand analysis pass so a tool // call can report "running, retry in Ns" instead of blocking for // minutes on a whole-graph computation. @@ -2532,6 +2534,7 @@ func (s *Server) InvalidateSessionScopes() { ss.resetScopeLocked() ss.mu.Unlock() } + s.outlineResponses.clear() s.retireDeadPendingSourceIncarnations() } @@ -3068,6 +3071,14 @@ func (s *Server) runAnalysisOwned(ctx context.Context, background bool) { if ctx.Err() != nil { return // An interrupted attempt is not a settled bootstrap receipt. } + hotspotsWarmup := s.warmOutlineHotspotsContext(ctx) + if ctx.Err() != nil { + return + } + outlineWarmup := s.warmOutlineSummaries(ctx) + if ctx.Err() != nil { + return + } // The graph was just rebuilt, so the lazy-enrichment ledger — symbol // IDs whose incoming refs were confirmed against the *previous* graph @@ -3112,6 +3123,11 @@ func (s *Server) runAnalysisOwned(ctx context.Context, background bool) { zap.Duration("adjacency", analysisMetrics.adjacency), zap.Duration("auto_concepts", analysisMetrics.autoConcepts), zap.Duration("hits", analysisMetrics.hits), + zap.Duration("hotspots_warmup", hotspotsWarmup), + zap.Duration("outline_summary_warmup", outlineWarmup.elapsed), + zap.Int("outline_summary_scopes", outlineWarmup.scopes), + zap.Int("outline_summary_ready", outlineWarmup.ready), + zap.Int("outline_summary_owned_estimate_bytes", outlineWarmup.bytes), zap.Int("attempts", analysisMetrics.attempts), zap.Int("superseded", analysisMetrics.superseded), zap.String("superseded_at", analysisMetrics.supersededAt), @@ -3355,6 +3371,13 @@ func (s *Server) getHITS() *analysis.HITSResult { // the stale result is discarded and rebuilt against the new communities. // The returned slice is shared and must not be mutated by the caller. func (s *Server) getHotspots() []analysis.HotspotEntry { + return s.getHotspotsContext(context.Background()) +} + +func (s *Server) getHotspotsContext(ctx context.Context) []analysis.HotspotEntry { + if ctx.Err() != nil { + return nil + } s.analysisMu.RLock() if !s.analysisSnapshotCurrentLocked() { s.analysisMu.RUnlock() @@ -3367,9 +3390,14 @@ func (s *Server) getHotspots() []analysis.HotspotEntry { } s.analysisMu.RUnlock() - s.hotspotsBuildMu.Lock() + if !lockAnalysisPreparation(ctx, &s.hotspotsBuildMu) { + return nil + } defer s.hotspotsBuildMu.Unlock() for { + if ctx.Err() != nil { + return nil + } s.analysisMu.RLock() if !s.analysisSnapshotCurrentLocked() { s.analysisMu.RUnlock() @@ -3383,7 +3411,10 @@ func (s *Server) getHotspots() []analysis.HotspotEntry { s.analysisMu.RUnlock() // Coalesced waiters recheck the ranking before hydrating a partition. // A retained result therefore needs no full community map at all. - materialized := s.ensureCommunitiesMaterialized() + materialized := s.ensureCommunitiesMaterializedContext(ctx) + if ctx.Err() != nil { + return nil + } if !materialized && s.hotspotsFn == nil { return nil } @@ -3402,14 +3433,26 @@ func (s *Server) getHotspots() []analysis.HotspotEntry { continue } - build := analysis.FindHotspots + var reader graph.Reader = s.graph + if ctx.Done() != nil { + reader = graph.BindReadContext(reader, ctx) + } + var hotspots []analysis.HotspotEntry if s.hotspotsFn != nil { - build = s.hotspotsFn + hotspots = s.hotspotsFn(reader, communities, 0) + } else { + var err error + hotspots, err = analysis.FindHotspotsContext(ctx, reader, communities, 0) + if err != nil { + return nil + } + } + if ctx.Err() != nil { + return nil } - hotspots := build(s.graph, communities, 0) s.analysisMu.Lock() - if s.analysisEpoch == epoch && s.communitiesToken == token && s.analysisSnapshotCurrentLocked() { + if ctx.Err() == nil && s.analysisEpoch == epoch && s.communitiesToken == token && s.analysisSnapshotCurrentLocked() { s.hotspots = hotspots s.hotspotsReady = true s.hotspotsOwned = false diff --git a/internal/mcp/tool_deadline_test.go b/internal/mcp/tool_deadline_test.go index e9a5f3278..54e634bb4 100644 --- a/internal/mcp/tool_deadline_test.go +++ b/internal/mcp/tool_deadline_test.go @@ -3,6 +3,8 @@ package mcp import ( "context" "encoding/json" + "os" + "os/exec" "strings" "testing" "time" @@ -350,37 +352,56 @@ func TestFailsFastWhenTooManyHandlersAreStuck(t *testing.T) { // scheduled FreeOSMemory can land on top of the scan and nothing schedules // a release when it finishes. func TestResourceAndPromptHandlersJoinTheActivityBracket(t *testing.T) { + const childEnv = "GORTEX_TEST_ACTIVITY_BRACKET_CHILD" + if os.Getenv(childEnv) != "1" { + // The tracker is process-wide: other tests can still own background + // work. Reuse the bounded test-process pattern for exact ownership. + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], + "-test.run=^TestResourceAndPromptHandlersJoinTheActivityBracket$", "-test.count=1") + cmd.Env = append(os.Environ(), childEnv+"=1") + output, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "activity-bracket subprocess failed (context=%v):\n%s", ctx.Err(), output) + return + } + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "0") + // A legitimate non-MCP owner reproduces the old assertion's false leak: + // global activity must remain nonzero after our own bracket has ended. + runtimeactivity.Begin("analysis") + defer runtimeactivity.End("analysis") + require.EqualValues(t, 1, runtimeactivity.Current().Active) s := &Server{logger: zap.NewNop()} - var duringResource int64 + var duringResource runtimeactivity.Snapshot resource := s.boundResourceHandler("gortex://probe", func(context.Context, mcplib.ReadResourceRequest) ([]mcplib.ResourceContents, error) { - duringResource = runtimeactivity.Current().Active + duringResource = runtimeactivity.Current() return nil, nil }) if _, err := resource(context.Background(), mcplib.ReadResourceRequest{}); err != nil { t.Fatalf("resource handler: %v", err) } - if duringResource == 0 { - t.Error("resource handler ran outside the activity bracket") - } - if got := runtimeactivity.Current().Active; got != 0 { - t.Errorf("activity leaked after the resource returned: %d", got) - } - - var duringPrompt int64 + assert.EqualValues(t, 1, duringResource.ByKind["mcp"], "resource owns its bracket independently of background activity") + assert.EqualValues(t, 2, duringResource.Active) + afterResource := runtimeactivity.Current() + assert.Zero(t, afterResource.ByKind["mcp"], "resource bracket ended before return") + assert.EqualValues(t, 1, afterResource.ByKind["analysis"], "unrelated owner stays active") + assert.EqualValues(t, 1, afterResource.Active, "nonzero process activity is not an owned resource leak") + + var duringPrompt runtimeactivity.Snapshot prompt := s.boundPromptHandler("probe", func(context.Context, mcplib.GetPromptRequest) (*mcplib.GetPromptResult, error) { - duringPrompt = runtimeactivity.Current().Active + duringPrompt = runtimeactivity.Current() return &mcplib.GetPromptResult{}, nil }) if _, err := prompt(context.Background(), mcplib.GetPromptRequest{}); err != nil { t.Fatalf("prompt handler: %v", err) } - if duringPrompt == 0 { - t.Error("prompt handler ran outside the activity bracket") - } - if got := runtimeactivity.Current().Active; got != 0 { - t.Errorf("activity leaked after the prompt returned: %d", got) - } + assert.EqualValues(t, 1, duringPrompt.ByKind["mcp"], "prompt owns its bracket independently of background activity") + assert.EqualValues(t, 2, duringPrompt.Active) + afterPrompt := runtimeactivity.Current() + assert.Zero(t, afterPrompt.ByKind["mcp"], "prompt bracket ended before return") + assert.EqualValues(t, 1, afterPrompt.ByKind["analysis"], "unrelated owner stays active") + assert.EqualValues(t, 1, afterPrompt.Active, "nonzero process activity is not an owned prompt leak") } diff --git a/internal/mcp/tools_enhancements.go b/internal/mcp/tools_enhancements.go index bfaf2486e..6dc75a5ef 100644 --- a/internal/mcp/tools_enhancements.go +++ b/internal/mcp/tools_enhancements.go @@ -2571,19 +2571,34 @@ func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest } reader := s.readerFor(ctx) - // The whole-store candidate read is bound to the request, so an - // abandoned call stops within one page of its deadline. - entries := analysis.FindDeadCode(graph.BindReadContext(reader, ctx), s.getProcesses(), nil, opts) + opts.RepoPrefixes = s.deadCodeRepoPrefixes(ctx) + if scope, active := s.scopeOptionsWithRepoNarrow(ctx, repoAllowFromContext(ctx)); active { + opts.NodeScope = &graph.NodeScope{WorkspaceID: scope.WorkspaceID, ProjectID: scope.ProjectID, RepoAllow: scope.RepoAllow} + } + // Keep scope capabilities on the context-bound request reader. Unsupported + // composed readers retain their exact visibility-aware fallback. + var processes *analysis.ProcessResult + if opts.RepoPrefixes == nil || len(opts.RepoPrefixes) > 0 { + var err error + processes, err = s.deadCodeProcesses(ctx, reader) + if err != nil { + return nil, err + } + } + entries := analysis.FindDeadCode(graph.BindReadContext(reader, ctx), processes, nil, opts) + if err := ctx.Err(); err != nil { + return nil, err + } - // dead_code reads the whole graph directly, bypassing the scoped-node - // accessors, so narrow its rows to the session workspace + optional - // repo allow-set here. This also closes the latent cross-workspace - // leak for this kind. Strict no-op for an unbound session with no - // RepoAllow. Counts below are computed after this filter. + // Ownership travels with each candidate. Preserve the original workspace + // and repo policy without fetching every surviving node again. if s.scopeFiltersActive(ctx) { kept := make([]analysis.DeadCodeEntry, 0, len(entries)) for _, e := range entries { - if s.analyzeNodeVisible(ctx, reader.GetNode(e.ID)) { + if s.analyzeNodeVisible(ctx, &graph.Node{ + ID: e.ID, RepoPrefix: e.RepoPrefix, WorkspaceID: e.WorkspaceID, + ProjectID: e.ProjectID, FilePath: e.FilePath, + }) { kept = append(kept, e) } } diff --git a/internal/mcp/tools_outline.go b/internal/mcp/tools_outline.go index 078e2e30f..fbf8b6866 100644 --- a/internal/mcp/tools_outline.go +++ b/internal/mcp/tools_outline.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "errors" "maps" "sort" @@ -9,6 +10,7 @@ import ( "github.com/zzet/gortex/internal/analysis" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" ) // handleGetRepoOutline returns a single-call narrative overview of the @@ -23,13 +25,6 @@ import ( // follows up with smart_context, find_usages, etc. on specific symbols // surfaced here. func (s *Server) handleGetRepoOutline(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - const ( - topCommunitiesN = 5 - topHotspotsN = 5 - topMostImportedN = 10 - topEntryPointsN = 10 - topLanguagesN = 5 - ) // Bound sessions aggregate the request reader's scope in the backend. // Keep unbound sessions on the cached Stats / EdgeCount path. @@ -42,12 +37,64 @@ func (s *Server) handleGetRepoOutline(ctx context.Context, req mcp.CallToolReque // an overlay-active caller sees its own buffers. Hoisted once: the // outline touches the reader from several sections. reader := s.readerFor(ctx) + if err := ctx.Err(); err != nil { + return nil, err + } + key, cacheable := s.outlineCacheKey(reader, graph.NodeScope{WorkspaceID: opts.WorkspaceID, ProjectID: opts.ProjectID, RepoAllow: opts.RepoAllow}, bound) + if cacheable { + if payload, ok := s.outlineResponses.get(key); ok { + if after, valid := s.outlineCacheKey(reader, graph.NodeScope{WorkspaceID: opts.WorkspaceID, ProjectID: opts.ProjectID, RepoAllow: opts.RepoAllow}, bound); valid && after == key && ctx.Err() == nil { + return s.respondJSONOrTOON(ctx, req, payload) + } + } + } + payload, err := s.buildRepoOutline(ctx, reader, opts, bound) + if err != nil { + return nil, err + } + if cacheable { + if after, valid := s.outlineCacheKey(reader, graph.NodeScope{WorkspaceID: opts.WorkspaceID, ProjectID: opts.ProjectID, RepoAllow: opts.RepoAllow}, bound); valid && after == key && ctx.Err() == nil && contractCoreReadError(ctx) == nil { + s.outlineResponses.put(key, payload) + } + } + return s.respondJSONOrTOON(ctx, req, payload) +} + +const outlineMostImportedLimit = 10 + +type outlineLanguage struct { + Name string `json:"name"` + Nodes int `json:"nodes"` +} + +func (s *Server) buildRepoOutline(ctx context.Context, reader graph.Reader, opts query.QueryOptions, bound bool) (map[string]any, error) { + const ( + topHotspotsN = 5 + topEntryPointsN = 10 + topLanguagesN = 5 + ) + + scope := graph.NodeScope{WorkspaceID: opts.WorkspaceID, ProjectID: opts.ProjectID, RepoAllow: opts.RepoAllow} + var prepared *outlinePreparedSummary + if key, ok := s.outlineCacheKey(reader, scope, bound); ok { + if counts, ready := s.outlineResponses.getSummary(key); ready { + if after, valid := s.outlineCacheKey(reader, scope, bound); valid && after == key { + prepared = &counts + } + } + } + reader = graph.BindReadContext(reader, ctx) + + scopedNodes, _ := graph.BindReadContext(contractCoreSelectedReader(reader), ctx).(graph.ScopedOutlineNodeReader) + var candidateErr error var scoped []*graph.Node var inScope map[string]bool var aggregated *graph.OutlineCounts if bound { - if counts, ok, err := scopedOutlineCountsFor(reader, graph.NodeScope{ + if prepared != nil { + aggregated = &prepared.counts + } else if counts, ok, err := scopedOutlineCountsFor(reader, graph.NodeScope{ WorkspaceID: opts.WorkspaceID, ProjectID: opts.ProjectID, RepoAllow: opts.RepoAllow, }); err != nil { return nil, err @@ -78,8 +125,16 @@ func (s *Server) handleGetRepoOutline(ctx context.Context, req mcp.CallToolReque } } if len(missing) > 0 { - for id, n := range reader.GetNodesByIDs(missing) { - candidateScope[id] = n != nil && opts.ScopeAllows(n) + if scopedNodes != nil { + ids, err := scopedNodes.OutlineNodeIDsInScope(scope, missing) + candidateErr = errors.Join(candidateErr, err) + for _, id := range ids { + candidateScope[id] = true + } + } else { + for id, n := range reader.GetNodesByIDs(missing) { + candidateScope[id] = n != nil && opts.ScopeAllows(n) + } } } } @@ -103,10 +158,7 @@ func (s *Server) handleGetRepoOutline(ctx context.Context, req mcp.CallToolReque // Language breakdown — computed from the scoped node set so the // counts reflect only the session's workspace. - type langEntry struct { - Name string `json:"name"` - Nodes int `json:"nodes"` - } + langCounts := make(map[string]int) totalScopedNodes := 0 if aggregated != nil { @@ -126,9 +178,9 @@ func (s *Server) handleGetRepoOutline(ctx context.Context, req mcp.CallToolReque maps.Copy(langCounts, stats.ByLanguage) totalScopedNodes = stats.TotalNodes } - var languages []langEntry + var languages []outlineLanguage for name, n := range langCounts { - languages = append(languages, langEntry{Name: name, Nodes: n}) + languages = append(languages, outlineLanguage{Name: name, Nodes: n}) } sort.Slice(languages, func(i, j int) bool { if languages[i].Nodes != languages[j].Nodes { @@ -172,52 +224,46 @@ func (s *Server) handleGetRepoOutline(ctx context.Context, req mcp.CallToolReque // Communities — top N by member count, filtered to communities // with at least one member inside the session's workspace. communitiesSection := map[string]any{"count": 0} - var comms *analysis.CommunityResult - generationCommunities := false - if aggregated != nil { - var err error - comms, generationCommunities, err = s.outlineGenerationCommunities(ctx, reader, graph.NodeScope{ - WorkspaceID: opts.WorkspaceID, ProjectID: opts.ProjectID, RepoAllow: opts.RepoAllow, - }) - if err != nil { - return nil, err - } - } - if !generationCommunities { - comms = s.getCommunities() - } - if comms != nil && (generationCommunities || len(comms.Communities) > 0) { - sorted := make([]analysis.Community, 0, len(comms.Communities)) - if generationCommunities { - sorted = append(sorted, comms.Communities...) - } else if aggregated != nil { - sorted = outlineCommunitiesInScope(comms.Communities, loadCandidates, candidateScope) - } else { - for _, c := range comms.Communities { - if allowsAny(c.Members) { - sorted = append(sorted, c) - } + if prepared != nil && prepared.communities != nil { + communitiesSection = prepared.communities + } else { + var comms *analysis.CommunityResult + generationCommunities := false + if aggregated != nil { + var err error + comms, generationCommunities, err = s.outlineGenerationCommunities(ctx, reader, graph.NodeScope{ + WorkspaceID: opts.WorkspaceID, ProjectID: opts.ProjectID, RepoAllow: opts.RepoAllow, + }) + if err != nil { + return nil, err } } - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Size > sorted[j].Size - }) - top := sorted - if len(top) > topCommunitiesN { - top = top[:topCommunitiesN] + if !generationCommunities { + comms = s.getCommunities() } - communitiesSection = map[string]any{ - "count": len(sorted), - "modularity": comms.Modularity, - "top": topCommunitiesSummary(top), + if comms != nil && (generationCommunities || len(comms.Communities) > 0) { + sorted := make([]analysis.Community, 0, len(comms.Communities)) + if generationCommunities { + sorted = append(sorted, comms.Communities...) + } else if aggregated != nil { + sorted = outlineCommunitiesInScope(comms.Communities, loadCandidates, candidateScope) + } else { + for _, c := range comms.Communities { + if allowsAny(c.Members) { + sorted = append(sorted, c) + } + } + } + communitiesSection = outlineCommunitySection(sorted, comms.Modularity) } + } // Hotspots — load-bearing symbols by fan-in/out/crossings. Use a low // threshold to ensure we get the top N regardless of repo size. // Post-filtered to the session's workspace. hotspotsSection := []map[string]any{} - hs := s.getHotspots() + hs := s.outlineHotspots() for i, h := range hs { if len(hotspotsSection) >= topHotspotsN { break @@ -253,29 +299,58 @@ func (s *Server) handleGetRepoOutline(ctx context.Context, req mcp.CallToolReque for _, row := range aggregated.FileImports { counts[row.FilePath] = row.Count } - imported = rankImportedFiles(counts, topMostImportedN) - mains := reader.FindNodesByName("main") - ids := make([]string, 0, len(mains)) - for _, n := range mains { - if n != nil { - ids = append(ids, n.ID) - } + if prepared != nil { + imported = prepared.imports + } else { + imported = rankImportedFiles(counts, outlineMostImportedLimit) } - for id, n := range reader.GetNodesByIDs(ids) { - candidateScope[id] = opts.ScopeAllows(n) + if scopedNodes != nil { + mains, err := scopedNodes.OutlineNodesByName(scope, "main") + if err != nil { + return nil, err + } + entries = entryPointsForNodes(mains, nil, topEntryPointsN) + } else { + mains := reader.FindNodesByName("main") + ids := make([]string, 0, len(mains)) + for _, n := range mains { + if n != nil { + ids = append(ids, n.ID) + } + } + for id, n := range reader.GetNodesByIDs(ids) { + candidateScope[id] = opts.ScopeAllows(n) + } + entries = entryPointsForNodes(mains, candidateScope, topEntryPointsN) } - entries = entryPointsForNodes(mains, candidateScope, topEntryPointsN) } else { - imported = mostImportedFiles(reader, inScope, topMostImportedN) + imported = mostImportedFiles(reader, inScope, outlineMostImportedLimit) entries = entryPoints(reader, inScope, topEntryPointsN) } - return s.respondJSONOrTOON(ctx, req, map[string]any{ + if candidateErr != nil { + return nil, candidateErr + } + if err := ctx.Err(); err != nil { + return nil, err + } + return map[string]any{ "summary": summary, "communities": communitiesSection, "hotspots": hotspotsSection, "most_imported_files": imported, "entry_points": entries, - }) + }, nil +} + +// outlineCommunitySection shares the exact legacy ranking/tie behavior with +// explicit summary preparation; only the small final section is retained. +func outlineCommunitySection(sorted []analysis.Community, modularity float64) map[string]any { + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Size > sorted[j].Size }) + top := sorted + if len(top) > 5 { + top = top[:5] + } + return map[string]any{"count": len(sorted), "modularity": modularity, "top": topCommunitiesSummary(top)} } // topCommunitiesSummary shapes a subset of communities for the outline. diff --git a/internal/mcp/tools_outline_scoped_test.go b/internal/mcp/tools_outline_scoped_test.go index 4a939add3..ca1e67331 100644 --- a/internal/mcp/tools_outline_scoped_test.go +++ b/internal/mcp/tools_outline_scoped_test.go @@ -47,6 +47,12 @@ func (r *outlineCountingReader) GetNodesByIDs(ids []string) map[string]*graph.No r.counters().lookupBatches = append(r.counters().lookupBatches, append([]string(nil), ids...)) return r.Store.GetNodesByIDs(ids) } +func (r *outlineCountingReader) OutlineNodeIDsInScope(scope graph.NodeScope, ids []string) ([]string, error) { + r.counters().lookedUp = append(r.counters().lookedUp, ids...) + r.counters().lookupBatches = append(r.counters().lookupBatches, append([]string(nil), ids...)) + return r.Store.OutlineNodeIDsInScope(scope, ids) +} + func (r *outlineCountingReader) ScopedOutlineCounts(scope graph.NodeScope) (graph.OutlineCounts, error) { r.counters().aggregates++ return r.Store.ScopedOutlineCounts(scope) @@ -420,3 +426,44 @@ func TestOutlineContractRuntimeHonorsCancellation(t *testing.T) { require.Zero(t, reader.allEdges) require.Zero(t, reader.allNodes) } + +func TestOutlineGoldenSixRepo(t *testing.T) { + store := openContractCoreCapStore(t) + s := &Server{graph: store, session: newSessionState(), sessions: newSessionMap(), logger: zap.NewNop()} + var nodes []*graph.Node + var edges []*graph.Edge + comms := &analysis.CommunityResult{Modularity: .5} + for i := 1; i <= 6; i++ { + repo := fmt.Sprintf("repo%d", i) + nodes = append(nodes, + &graph.Node{ID: repo + "-main", Name: "main", Kind: graph.KindFunction, RepoPrefix: repo, WorkspaceID: "ws", Language: "go", FilePath: repo + "/main.go"}, + &graph.Node{ID: repo + "-lib", Name: "helper", Kind: graph.KindFunction, RepoPrefix: repo, WorkspaceID: "ws", Language: "go", FilePath: repo + "/lib.go"}, + ) + other := fmt.Sprintf("repo%d", i%6+1) + edges = append(edges, + &graph.Edge{From: repo + "-main", To: repo + "-lib", Kind: graph.EdgeCalls}, + &graph.Edge{From: repo + "-main", To: repo + "-lib", Kind: graph.EdgeImports}, + &graph.Edge{From: other + "-main", To: repo + "-lib", Kind: graph.EdgeImports}, + ) + comms.Communities = append(comms.Communities, analysis.Community{ID: repo, Label: repo, Size: 2, Cohesion: .5, Members: []string{repo + "-main", repo + "-lib"}}) + } + store.AddBatch(nodes, edges) + installCommunitiesForTest(s, comms) + for i := 6; i >= 1; i-- { + repo := fmt.Sprintf("repo%d", i) + s.hotspots = append(s.hotspots, analysis.HotspotEntry{ID: repo + "-lib", Name: "helper", Kind: "function", FilePath: repo + "/lib.go", FanIn: 2, FanOut: 0, ComplexityScore: 3}) + } + s.hotspotsReady = true + for i := 1; i <= 6; i++ { + repo := fmt.Sprintf("repo%d", i) + ctx := outlineBoundContext(s, repo, "ws", map[string]bool{repo: true}) + got := outlineJSON(t, s, ctx, store) + oracle := outlineJSON(t, s, ctx, legacyOutlineReader{store}) + require.JSONEq(t, string(oracle), string(got)) + golden := fmt.Sprintf(`{"summary":{"total_nodes":2,"total_edges":2,"primary_language":"go","languages":[{"name":"go","nodes":2}]},"communities":{"count":1,"modularity":0.5,"top":[{"id":%q,"label":%q,"size":2,"cohesion":0.5}]},"hotspots":[{"id":%q,"name":"helper","kind":"function","file_path":%q,"fan_in":2,"fan_out":0,"complexity_score":3}],"most_imported_files":[{"path":%q,"import_count":2}],"entry_points":[{"id":%q,"name":"main","file_path":%q}]}`, repo, repo, repo+"-lib", repo+"/lib.go", repo+"/lib.go", repo+"-main", repo+"/main.go") + require.JSONEq(t, golden, string(got), repo) + } + // The unbound response still uses the exact legacy Stats/edge-count path + // and formatting. Even JSON key/number spelling stays byte-identical. + require.Equal(t, outlineJSON(t, s, context.Background(), legacyOutlineReader{store}), outlineJSON(t, s, context.Background(), store)) +}