graph-indexer 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @file mcp/resolver.mjs
3
+ * @description Resolver providers (A1): pluggable confidence assignment for the A4 symbol
4
+ * graph. The DEFAULT `heuristic` provider preserves today's two-tier confidence
5
+ * ({ high, name_only }) exactly. The opt-in `precise` provider lifts the subset of
6
+ * edges whose binding is provably UNAMBIGUOUS into a third, stronger tier,
7
+ * `resolved` — a reference is `resolved` when there is no question *which*
8
+ * definition it binds to:
9
+ * • the symbol has a single definition in the repo (no same-named rival), or
10
+ * • the receiver's TYPE matches the defining class (typed receiver, the A3
11
+ * inter-procedural fixpoint result, `this.m()` inside the class, or an explicit
12
+ * target class) — type-resolved, so immune to name shadowing.
13
+ * `high` then means "ambiguous name, disambiguated by import/proximity" and
14
+ * `name_only` means "ambiguous name, no evidence."
15
+ *
16
+ * The provider only changes the `confidence` STRING on edges that already exist —
17
+ * it never adds, drops, or reorders edges — so it is parity-free (confidence is
18
+ * already serialized) and `findCallers`/`findReferers` set-equivalence is preserved.
19
+ * It runs at index time inside buildSymbolGraph and is inert without --symbol-graph.
20
+ *
21
+ * SCOPE (honest): v1 exposes the unambiguous-binding tier the engine can already
22
+ * prove from index-time signals. Detecting a LOCAL variable that *shadows* an import
23
+ * (and refuting that edge), and cross-file precise resolution via stack-graphs, are
24
+ * the documented next layers — both require per-language AST/scope analysis.
25
+ * @author MaquinaTech <https://github.com/MaquinaTech>
26
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
27
+ * @license MIT
28
+ */
29
+
30
+ /** Total order on confidence — strongest first. Used by the symbol-graph dedupe. */
31
+ export const CONFIDENCE_RANK = { resolved: 3, high: 2, name_only: 1 };
32
+
33
+ /** The stronger of two confidence strings (ties → `a`). */
34
+ export function strongerConfidence(a, b) {
35
+ return (CONFIDENCE_RANK[b] || 0) > (CONFIDENCE_RANK[a] || 0) ? b : a;
36
+ }
37
+
38
+ /**
39
+ * Default provider: identity. Edge confidence stays exactly { high, name_only } — the index is
40
+ * byte-identical to a pre-A1 build.
41
+ */
42
+ const HEURISTIC = {
43
+ name: 'heuristic',
44
+ /** @param {string} base 'high' | 'name_only' @param {boolean} _proven */
45
+ confidenceFor(base /* , proven */) { return base; },
46
+ };
47
+
48
+ /**
49
+ * Precise provider: promote a `high` edge to `resolved` when its binding is provably
50
+ * unambiguous (the `proven` flag set by classifyCallers / findReferences). Never touches
51
+ * `name_only` (no evidence stays no evidence) and never downgrades.
52
+ */
53
+ const PRECISE = {
54
+ name: 'precise',
55
+ /** @param {string} base @param {boolean} proven */
56
+ confidenceFor(base, proven) { return base === 'high' && proven ? 'resolved' : base; },
57
+ };
58
+
59
+ const PROVIDERS = { heuristic: HEURISTIC, precise: PRECISE };
60
+
61
+ /** Resolve a provider by name; unknown names fall back to the default heuristic. */
62
+ export function getResolver(name) { return PROVIDERS[name] || HEURISTIC; }
63
+
64
+ /**
65
+ * SCIP provider (A2): a DATA-BACKED resolver built from a cross-file binding relation
66
+ * (`bindings: Map<fromChunkId, Set<defChunkId>>`) and the set of defs SCIP actually saw
67
+ * (`definedChunks`), both produced from a locally-generated SCIP index (see parse/scip.mjs
68
+ * `buildScipBindings`). Unlike heuristic/precise — which only RELABEL a fixed edge set — the SCIP
69
+ * provider can also SUPPRESS edges, because SCIP knows the *exact* binding among several same-named
70
+ * definitions. For a caller `fromId` and the candidate defs `defIds` of one name:
71
+ *
72
+ * • no SCIP target for `fromId` at all, OR none of `defIds` is a target → return null: the
73
+ * builder falls through to the heuristic confidence (absence ≠ refutation for this name).
74
+ * • SCIP confirms `fromId` binds to ≥1 of `defIds` → `resolved` = those confirmed def(s);
75
+ * `suppressed` = the OTHER candidate defs **that SCIP actually saw as definitions**
76
+ * (`def ∈ definedChunks`). A candidate def SCIP NEVER recorded — an uncovered file in a
77
+ * polyglot / partial run, a stale `.scip`, a missed occurrence — is neither resolved nor
78
+ * suppressed: it falls through to heuristic, so a covered caller referencing an *uncovered* def
79
+ * is never wrongly dropped. This is the soundness gate the partial-coverage case needs.
80
+ *
81
+ * It refines the heuristic's CANDIDATE edges (promote + suppress); it does not synthesize edges the
82
+ * AST call-extraction missed (a v2 recall concern). The symbol-graph builder applies this to `calls`
83
+ * edges only — the binding relation is kind-agnostic, so `extends`/`type` edges stay heuristic.
84
+ * `confidenceFor` is identity, so any edge SCIP does not cover keeps its base confidence.
85
+ *
86
+ * @param {Map<string, Set<string>>} bindings fromChunkId → set of def chunk ids SCIP confirms.
87
+ * @param {Set<string>} [definedChunks] every chunk SCIP recorded as a definition (suppression gate).
88
+ * @returns {{ name:string, confidenceFor:Function, resolveEdges:Function }}
89
+ */
90
+ export function createScipResolver(bindings, definedChunks) {
91
+ const rel = bindings instanceof Map ? bindings : new Map();
92
+ const defined = definedChunks instanceof Set ? definedChunks : new Set();
93
+ return {
94
+ name: 'scip',
95
+ /** Uncovered edges keep their base confidence (no promotion without SCIP evidence). */
96
+ confidenceFor(base /* , proven */) { return base; },
97
+ /**
98
+ * @param {{ fromId:string, defIds:string[] }} q
99
+ * @returns {{ resolved: Set<string>, suppressed: Set<string> } | null} null → no SCIP info.
100
+ */
101
+ resolveEdges({ fromId, defIds }) {
102
+ const targets = rel.get(fromId);
103
+ if (!targets || !targets.size) return null; // caller uncovered → no opinion
104
+ const resolved = new Set();
105
+ for (const id of defIds) if (targets.has(id)) resolved.add(id);
106
+ if (!resolved.size) return null; // binds none of THIS name's defs → fall through
107
+ const suppressed = new Set();
108
+ for (const id of defIds) {
109
+ if (resolved.has(id)) continue;
110
+ if (defined.has(id)) suppressed.add(id); // SCIP saw this def but bound elsewhere → wrong-target
111
+ // else: def uncovered by SCIP → keep heuristic (sound under partial coverage)
112
+ }
113
+ return { resolved, suppressed };
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @file mcp/symbolgraph.mjs
3
+ * @description Builds the persistent RESOLVED symbol graph (A4): one edge per resolved
4
+ * (referencing-chunk → definition-chunk) pair, tagged with `kind`
5
+ * ('calls' | 'extends' | 'type') and `confidence`. Confidence is `high` | `name_only`
6
+ * on the default `heuristic` path; the opt-in resolvers add a `resolved` tier — A1
7
+ * (`precise`) promotes provably-unambiguous edges, A2 (`scip`) confirms a cross-file
8
+ * binding AND suppresses the wrong-target fan-out a same-name ambiguity would emit.
9
+ *
10
+ * It REUSES the query-time resolvers (classifyCallers / findReferences) over
11
+ * every defined symbol, so an edge's confidence is identical to what
12
+ * get_call_graph / find_references would report — no logic duplication, no
13
+ * drift. The result is serialized into the index so both backends expose the
14
+ * same graph through getEdges() (deterministically ordered → parity-free), and
15
+ * findCallers / findReferers can read it instead of scanning.
16
+ *
17
+ * Opt-in and index-time only (see config.symbolGraph / indexer.mjs). It does
18
+ * not touch search ranking. This is the structural foundation for impact_of_edit
19
+ * (transitive getEdges), symbol-level PageRank (A5), and precise resolution (A1,
20
+ * which upgrades 'high' → 'resolved').
21
+ * @author MaquinaTech <https://github.com/MaquinaTech>
22
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
23
+ * @license MIT
24
+ */
25
+ import { classifyCallers, findReferences } from './topology.mjs';
26
+ import { getResolver, strongerConfidence } from './resolver.mjs';
27
+
28
+ /** Deterministic total order on edges — required for memory↔sqlite parity. */
29
+ export function edgeOrder(a, b) {
30
+ return (a.from_chunk_id < b.from_chunk_id ? -1 : a.from_chunk_id > b.from_chunk_id ? 1 : 0)
31
+ || (a.to_chunk_id < b.to_chunk_id ? -1 : a.to_chunk_id > b.to_chunk_id ? 1 : 0)
32
+ || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0)
33
+ || (a.confidence < b.confidence ? -1 : a.confidence > b.confidence ? 1 : 0);
34
+ }
35
+
36
+ const normConfidence = (c) => (c === 'high' ? 'high' : 'name_only');
37
+
38
+ /**
39
+ * Build the resolved edge list from a loaded store.
40
+ *
41
+ * @param {object} db A loaded store (iterateChunks, resolveSymbol, findCallers,
42
+ * findReferers, getDependencies — the same contract the resolvers use).
43
+ * @param {object} [opts]
44
+ * @param {number} [opts.maxPerName] Safety cap on edges emitted for one symbol name
45
+ * (pathological ambiguous names). Excess is logged, not
46
+ * silently dropped.
47
+ * @param {object} [opts.resolver] A1 resolver provider (default `heuristic` → byte-identical
48
+ * { high, name_only }; `precise` → adds the `resolved` tier).
49
+ * @returns {{ edges: Array<{from_chunk_id:string,to_chunk_id:string,kind:string,confidence:string}>,
50
+ * cappedNames: string[] }}
51
+ */
52
+ export function buildSymbolGraph(db, { maxPerName = 20000, resolver = getResolver('heuristic') } = {}) {
53
+ const edges = [];
54
+ const cappedNames = [];
55
+
56
+ const calleeNames = new Set();
57
+ const refNames = new Set();
58
+ for (const c of db.iterateChunks()) {
59
+ for (const n of (c.calls || [])) calleeNames.add(n);
60
+ for (const t of (c.extends || [])) refNames.add(t);
61
+ for (const t of (c.type_refs || [])) refNames.add(t);
62
+ }
63
+
64
+ // ── 'calls' edges: every name-match caller → each definition of the callee name,
65
+ // bucketed high / name_only exactly as classifyCallers reports. ──────────────
66
+ for (const name of [...calleeNames].sort()) {
67
+ const defs = db.resolveSymbol(name);
68
+ if (!defs.length) continue; // undefined name → no edge (findCallers falls back to scan)
69
+ const defIds = defs.map(d => d.id);
70
+ const { high, nameOnly } = classifyCallers(db, name);
71
+ let n = 0; let capped = false;
72
+ const emit = (callers, baseConf) => {
73
+ for (const { chunk, proven } of callers) {
74
+ // A2 (calls edges only): a data-backed resolver (scip) may CONFIRM the exact binding
75
+ // (→ `resolved`) and SUPPRESS the SCIP-known wrong-target siblings. A def SCIP never
76
+ // saw is neither — it falls through to the heuristic confidence (sound under partial
77
+ // coverage). No resolveEdges (heuristic/precise) → decision null → byte-identical.
78
+ const decision = resolver.resolveEdges
79
+ ? resolver.resolveEdges({ fromId: chunk.id, defIds })
80
+ : null;
81
+ const fallbackConf = resolver.confidenceFor(baseConf, Boolean(proven));
82
+ for (const def of defs) {
83
+ if (n >= maxPerName) { capped = true; return; }
84
+ if (decision && decision.resolved.has(def.id)) {
85
+ edges.push({ from_chunk_id: chunk.id, to_chunk_id: def.id, kind: 'calls', confidence: 'resolved' });
86
+ } else if (decision && decision.suppressed.has(def.id)) {
87
+ continue; // SCIP-confirmed wrong-target → suppress
88
+ } else {
89
+ edges.push({ from_chunk_id: chunk.id, to_chunk_id: def.id, kind: 'calls', confidence: fallbackConf });
90
+ }
91
+ n++;
92
+ }
93
+ }
94
+ };
95
+ emit(high, 'high');
96
+ if (!capped) emit(nameOnly, 'name_only');
97
+ if (capped) cappedNames.push(name);
98
+ }
99
+
100
+ // ── 'extends' / 'type' edges: subclasses/implementers and type users → each
101
+ // definition, with the per-reference confidence findReferences assigns. ──────
102
+ for (const name of [...refNames].sort()) {
103
+ const defs = db.resolveSymbol(name);
104
+ if (!defs.length) continue;
105
+ const { inherits, types } = findReferences(db, name);
106
+ let n = 0; let capped = false;
107
+ // A2 scopes SCIP resolution to `calls` edges only: the binding relation is kind-agnostic, so
108
+ // applying it here would let a call-site binding suppress/relabel `extends`/`type` siblings of
109
+ // a different kind. These edges therefore stay heuristic under every resolver (byte-identical).
110
+ const emit = (refs, kind) => {
111
+ for (const r of refs) {
112
+ const confidence = resolver.confidenceFor(normConfidence(r.confidence), Boolean(r.proven));
113
+ for (const def of defs) {
114
+ if (n >= maxPerName) { capped = true; return; }
115
+ edges.push({ from_chunk_id: r.chunk.id, to_chunk_id: def.id, kind, confidence });
116
+ n++;
117
+ }
118
+ }
119
+ };
120
+ emit(inherits, 'extends');
121
+ if (!capped) emit(types, 'type');
122
+ if (capped && !cappedNames.includes(name)) cappedNames.push(name);
123
+ }
124
+
125
+ // Dedupe by (from, to, kind) keeping the STRONGEST confidence (resolved > high > name_only),
126
+ // then return in the deterministic parity order. (On the default `heuristic` path the only
127
+ // values are high/name_only and high is strongest → identical to the prior keep-first dedupe.)
128
+ const rep = new Map();
129
+ for (const e of edges) {
130
+ const k = `${e.from_chunk_id}|${e.to_chunk_id}|${e.kind}`;
131
+ const cur = rep.get(k);
132
+ if (!cur) rep.set(k, { from_chunk_id: e.from_chunk_id, to_chunk_id: e.to_chunk_id, kind: e.kind, confidence: e.confidence });
133
+ else cur.confidence = strongerConfidence(cur.confidence, e.confidence);
134
+ }
135
+ const out = [...rep.values()].sort(edgeOrder);
136
+ return { edges: out, cappedNames };
137
+ }
package/mcp/taint.mjs ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * @file mcp/taint.mjs
3
+ * @description Taint analysis (C2): trace untrusted data from SOURCES (request bodies, argv/env,
4
+ * stdin, socket/file reads) to dangerous SINKS (eval/exec, SQL, fs/path, HTML, outbound
5
+ * requests) across the symbol graph, surfacing injection-class risks (rce/sqli/xss/
6
+ * path/ssrf) with a concrete source→sink path.
7
+ *
8
+ * QUERY-TIME and read-only: it scans chunk source with the heuristic catalogs in
9
+ * parse/taint-patterns.mjs and walks the call graph (A4 getEdges when present, else
10
+ * chunk.calls). No index-time cost, no serialization, no parity surface — the default
11
+ * index is byte-identical and the tools cost nothing unless invoked. Air-gapped (pure
12
+ * regex + graph traversal; no model, no network).
13
+ *
14
+ * HONESTY: this is a FINDER, not a verifier. It favours precision (direct same-function
15
+ * flows are 'high'; cross-function reachability is 'medium'; a sanitizer on the path
16
+ * lowers confidence) and will MISS flows through dynamic dispatch, reflection, ORM/
17
+ * query-builder indirection, and untyped collections. "0 findings" ≠ "no vulnerabilities."
18
+ * @author MaquinaTech <https://github.com/MaquinaTech>
19
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
20
+ * @license MIT
21
+ */
22
+ import { SOURCES, SINKS, SANITIZERS, CATEGORY_SEVERITY, langKeyForExt } from '../parse/taint-patterns.mjs';
23
+
24
+ const extOf = (filePath) => { const i = String(filePath).lastIndexOf('.'); return i >= 0 ? filePath.slice(i) : ''; };
25
+
26
+ /**
27
+ * Scan one chunk's source for source / sink / sanitizer hits. Returns null for unsupported
28
+ * languages or chunks without source text.
29
+ */
30
+ export function scanChunk(chunk) {
31
+ const lang = langKeyForExt(extOf(chunk.file_path));
32
+ if (!lang) return null;
33
+ const src = chunk.code_snippet || '';
34
+ if (!src) return null;
35
+ const lines = src.split('\n');
36
+ const base = chunk.start_line || 1;
37
+
38
+ const sources = [];
39
+ const sinks = [];
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const line = lines[i];
42
+ for (const s of SOURCES[lang]) if (s.re.test(line)) sources.push({ line: base + i, kind: s.kind, snippet: line.trim().slice(0, 160) });
43
+ for (const k of SINKS[lang]) if (k.re.test(line)) sinks.push({ line: base + i, category: k.category, label: k.label, snippet: line.trim().slice(0, 160) });
44
+ }
45
+ const sanitized = SANITIZERS[lang].some(re => re.test(src));
46
+ return { lang, sources, sinks, sanitized };
47
+ }
48
+
49
+ /** Forward callees of a chunk (what it calls): A4 edges when present, else resolved chunk.calls. */
50
+ function forwardCallees(db, chunk) {
51
+ const out = [];
52
+ const seen = new Set();
53
+ const push = (c) => { if (c && !seen.has(c.id)) { seen.add(c.id); out.push(c); } };
54
+ if (typeof db.hasSymbolGraph === 'function' && db.hasSymbolGraph()) {
55
+ for (const e of db.getEdges(chunk.id, { direction: 'out', kind: 'calls' })) if (e.chunk) push(e.chunk);
56
+ return out;
57
+ }
58
+ for (const name of (chunk.calls || [])) for (const def of (db.resolveSymbol(name) || [])) push(def);
59
+ return out;
60
+ }
61
+
62
+ const confRank = { high: 3, medium: 2, low: 1 };
63
+ const sevRank = Object.fromEntries(CATEGORY_SEVERITY.map((c, i) => [c, CATEGORY_SEVERITY.length - i]));
64
+
65
+ function flowKey(f) { return `${f.source.chunk_id}|${f.sink.chunk_id}|${f.sink.category}|${f.sink.line}`; }
66
+
67
+ /** Deterministic order: worst category, then confidence, then source place, then sink place, then id. */
68
+ function flowOrder(a, b) {
69
+ return (sevRank[b.sink.category] - sevRank[a.sink.category])
70
+ || (confRank[b.confidence] - confRank[a.confidence])
71
+ || a.source.file_path.localeCompare(b.source.file_path)
72
+ || (a.source.line - b.source.line)
73
+ || a.sink.file_path.localeCompare(b.sink.file_path)
74
+ || (a.sink.line - b.sink.line)
75
+ || (a.source.chunk_id < b.source.chunk_id ? -1 : a.source.chunk_id > b.source.chunk_id ? 1 : 0);
76
+ }
77
+
78
+ /**
79
+ * Build the taint flow set: every source→sink flow the heuristics can find.
80
+ *
81
+ * @param {object} db
82
+ * @param {object} [opts]
83
+ * @param {number} [opts.maxDepth] Cross-function call hops to follow from a source (default 4).
84
+ * @param {number} [opts.maxFlows] Cap on flows returned (default 200; truncation is reported).
85
+ * @param {string} [opts.category] Restrict to one sink category (rce|sqli|xss|path|ssrf).
86
+ * @param {boolean}[opts.includeReachable] Include cross-function reachability flows (default true).
87
+ * @returns {{ flows: object[], truncated: boolean, scanned: number, sources: number, sinks: number }}
88
+ */
89
+ export function buildTaintGraph(db, { maxDepth = 4, maxFlows = 200, category = null, includeReachable = true } = {}) {
90
+ // ── Fast path: serialized flows from an index built with --taint (C2). The cache is the FULL
91
+ // flow set computed once at index time (includeReachable, uncapped), so any query within its
92
+ // depth envelope is served by filtering — no graph walk, and both backends serve identical
93
+ // flows. A direct-only query (includeReachable:false) or a deeper maxDepth than the cache
94
+ // falls through to a live recompute (correctness preserved over speed). ───────────────────
95
+ if (includeReachable && typeof db.hasTaint === 'function' && db.hasTaint()) {
96
+ const cached = db.getTaintFlows();
97
+ if (cached && cached.meta && maxDepth <= cached.meta.maxDepth) {
98
+ let flows = cached.flows || [];
99
+ if (category) flows = flows.filter(f => f.sink.category === category);
100
+ if (maxDepth < cached.meta.maxDepth) flows = flows.filter(f => f.depth <= maxDepth);
101
+ const truncated = flows.length > maxFlows;
102
+ return {
103
+ flows: truncated ? flows.slice(0, maxFlows) : flows, truncated, cached: true,
104
+ scanned: cached.meta.scanned, sources: cached.meta.sources, sinks: cached.meta.sinks,
105
+ };
106
+ }
107
+ }
108
+
109
+ // Scan every chunk once (sorted by id for deterministic, backend-stable output).
110
+ const chunks = [...db.iterateChunks()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
111
+ const scans = new Map();
112
+ const sourceChunks = [];
113
+ const sinkChunkIds = new Set();
114
+ let sourceCount = 0, sinkCount = 0;
115
+ for (const c of chunks) {
116
+ const sc = scanChunk(c);
117
+ if (!sc) continue;
118
+ scans.set(c.id, sc);
119
+ if (sc.sources.length) { sourceChunks.push(c); sourceCount += sc.sources.length; }
120
+ if (sc.sinks.length) { sinkChunkIds.add(c.id); sinkCount += sc.sinks.length; }
121
+ }
122
+
123
+ const sinkMatches = (sk) => !category || sk.category === category;
124
+ const card = (chunk) => ({ chunk_id: chunk.id, name: chunk.name, file_path: chunk.file_path });
125
+
126
+ const flows = new Map();
127
+ const addFlow = (srcChunk, srcHit, sinkChunk, sinkHit, path, depth, via, sanitized) => {
128
+ const confidence = via === 'direct' ? (sanitized ? 'low' : 'high') : (sanitized ? 'low' : 'medium');
129
+ const f = {
130
+ source: { ...card(srcChunk), line: srcHit.line, kind: srcHit.kind, snippet: srcHit.snippet },
131
+ sink: { ...card(sinkChunk), line: sinkHit.line, category: sinkHit.category, label: sinkHit.label, snippet: sinkHit.snippet },
132
+ path, depth, via, sanitized, confidence,
133
+ };
134
+ const k = flowKey(f);
135
+ const prev = flows.get(k);
136
+ if (!prev || confRank[f.confidence] > confRank[prev.confidence] || f.depth < prev.depth) flows.set(k, f);
137
+ };
138
+
139
+ // ── Direct: a single function that both takes untrusted input AND performs a dangerous op. ──
140
+ for (const c of sourceChunks) {
141
+ const sc = scans.get(c.id);
142
+ if (!sc.sinks.length) continue;
143
+ const srcHit = sc.sources[0]; // representative source
144
+ for (const sinkHit of sc.sinks) if (sinkMatches(sinkHit)) addFlow(c, srcHit, c, sinkHit, [c.id], 0, 'direct', sc.sanitized);
145
+ }
146
+
147
+ // ── Reachable: a source function transitively calls a sink function. ─────────────────────────
148
+ if (includeReachable) {
149
+ for (const start of sourceChunks) {
150
+ const startScan = scans.get(start.id);
151
+ const srcHit = startScan.sources[0];
152
+ const visited = new Set([start.id]);
153
+ let frontier = [{ chunk: start, path: [start.id], pathSanitized: startScan.sanitized }];
154
+ for (let depth = 1; depth <= maxDepth; depth++) {
155
+ const next = [];
156
+ for (const { chunk, path, pathSanitized } of frontier) {
157
+ for (const callee of forwardCallees(db, chunk)) {
158
+ if (visited.has(callee.id)) continue;
159
+ visited.add(callee.id);
160
+ const calleeScan = scans.get(callee.id);
161
+ const sanitizedPath = pathSanitized || (calleeScan ? calleeScan.sanitized : false);
162
+ if (sinkChunkIds.has(callee.id) && calleeScan) {
163
+ for (const sinkHit of calleeScan.sinks) if (sinkMatches(sinkHit)) addFlow(start, srcHit, callee, sinkHit, [...path, callee.id], depth, 'reachable', sanitizedPath);
164
+ }
165
+ next.push({ chunk: callee, path: [...path, callee.id], pathSanitized: sanitizedPath });
166
+ }
167
+ }
168
+ if (!next.length) break;
169
+ frontier = next;
170
+ }
171
+ }
172
+ }
173
+
174
+ const all = [...flows.values()].sort(flowOrder);
175
+ const truncated = all.length > maxFlows;
176
+ return { flows: truncated ? all.slice(0, maxFlows) : all, truncated, scanned: scans.size, sources: sourceCount, sinks: sinkCount };
177
+ }
178
+
179
+ /**
180
+ * Compute the serializable taint cache (C2 --taint) for index-time serialization: the FULL flow set
181
+ * (uncapped, includeReachable) plus the meta the fast path needs to answer in-envelope queries
182
+ * (`maxDepth` is the cache's depth — a query asking for ≤ this is served from the cache).
183
+ */
184
+ export function computeTaintCache(db, { maxDepth = 4 } = {}) {
185
+ const r = buildTaintGraph(db, { maxDepth, maxFlows: Infinity, includeReachable: true, category: null });
186
+ return { flows: r.flows, meta: { maxDepth, scanned: r.scanned, sources: r.sources, sinks: r.sinks } };
187
+ }
188
+
189
+ /** Flows filtered by source kind and/or sink category (a thin filter over buildTaintGraph). */
190
+ export function traceTaint(db, { sourceKind = null, sinkCategory = null, maxDepth = 4, maxFlows = 200 } = {}) {
191
+ const { flows, truncated, scanned } = buildTaintGraph(db, { maxDepth, maxFlows, category: sinkCategory });
192
+ const filtered = sourceKind ? flows.filter(f => f.source.kind === sourceKind) : flows;
193
+ return { flows: filtered, truncated, scanned };
194
+ }
195
+
196
+ /**
197
+ * Every dangerous sink, grouped by category, each annotated with whether any untrusted source
198
+ * reaches it. The orientation tool: "where are my dangerous operations, and which are reachable
199
+ * from untrusted input?"
200
+ */
201
+ export function findTaintedSinks(db, { category = null, reachableOnly = false, maxDepth = 4 } = {}) {
202
+ const { flows, scanned } = buildTaintGraph(db, { maxDepth, maxFlows: 100000, category });
203
+ const reachedKey = new Set(flows.map(f => `${f.sink.chunk_id}|${f.sink.line}|${f.sink.category}`));
204
+
205
+ const chunks = [...db.iterateChunks()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
206
+ const byCategory = {};
207
+ for (const c of chunks) {
208
+ const sc = scanChunk(c);
209
+ if (!sc || !sc.sinks.length) continue;
210
+ for (const sk of sc.sinks) {
211
+ if (category && sk.category !== category) continue;
212
+ const reached = reachedKey.has(`${c.id}|${sk.line}|${sk.category}`);
213
+ if (reachableOnly && !reached) continue;
214
+ (byCategory[sk.category] ||= []).push({
215
+ chunk_id: c.id, name: c.name, file_path: c.file_path, line: sk.line,
216
+ label: sk.label, snippet: sk.snippet, reached_by_source: reached,
217
+ });
218
+ }
219
+ }
220
+ for (const cat of Object.keys(byCategory)) {
221
+ byCategory[cat].sort((a, b) => (Number(b.reached_by_source) - Number(a.reached_by_source))
222
+ || a.file_path.localeCompare(b.file_path) || (a.line - b.line));
223
+ }
224
+ return { byCategory, scanned, flowCount: flows.length };
225
+ }