@clear-capabilities/agentic-security-scanner 0.183.1 → 0.185.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/agentic-security.js +88 -0
  3. package/dist/{2a8456e0800aeee871b6.json → 0b301d45b231f144a1b4.json} +27 -9
  4. package/dist/1128.index.js +908 -0
  5. package/dist/1336.index.js +261 -112
  6. package/dist/1665.index.js +828 -0
  7. package/dist/2923.index.js +17 -9
  8. package/dist/9382.index.js +23 -2
  9. package/dist/9579.index.js +11 -4
  10. package/dist/agentic-security.mjs +15 -15
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/dist/frontend/src/shell.js +12 -2
  13. package/package.json +27 -9
  14. package/src/context-intelligence/controls.js +15 -0
  15. package/src/context-intelligence/reachability.js +8 -2
  16. package/src/dataflow/CLAUDE.md +43 -1
  17. package/src/dataflow/backward.js +42 -7
  18. package/src/dataflow/cross-service-taint.js +83 -1
  19. package/src/dataflow/demand-query.js +929 -0
  20. package/src/dataflow/dep-fingerprint.js +718 -0
  21. package/src/dataflow/engine.js +48 -6
  22. package/src/dataflow/ifds-precise.js +22 -0
  23. package/src/dataflow/incremental-replay.js +118 -0
  24. package/src/dataflow/incremental-session.js +257 -0
  25. package/src/dataflow/incremental.js +237 -35
  26. package/src/dataflow/index.js +25 -94
  27. package/src/dataflow/summaries.js +72 -6
  28. package/src/dataflow/tabulation.js +80 -24
  29. package/src/discovery/hunter.js +6 -2
  30. package/src/discovery/index.js +5 -2
  31. package/src/engine.js +63 -3
  32. package/src/graph/CLAUDE.md +31 -0
  33. package/src/graph/adapters/artifact.js +81 -0
  34. package/src/graph/adapters/crossrepo.js +75 -0
  35. package/src/graph/adapters/dataflow.js +76 -0
  36. package/src/graph/adapters/provenance.js +92 -0
  37. package/src/graph/adapters/semantic.js +89 -0
  38. package/src/graph/crosswalk-builders.js +152 -0
  39. package/src/graph/crosswalk.js +146 -0
  40. package/src/graph/index.js +15 -0
  41. package/src/graph/observation.js +57 -0
  42. package/src/graph/refs.js +105 -0
  43. package/src/graph/registry.js +129 -0
  44. package/src/graph/relations.js +81 -0
  45. package/src/graph/scc.js +241 -0
  46. package/src/graph/view.js +254 -0
  47. package/src/language/haskell-disclosure.js +13 -1
  48. package/src/lineage/CLAUDE.md +71 -8
  49. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  50. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  51. package/src/lineage/boundary-contract.js +273 -0
  52. package/src/lineage/completion.js +205 -0
  53. package/src/lineage/continuation.js +140 -0
  54. package/src/lineage/control-credit.js +313 -0
  55. package/src/lineage/correlation.js +199 -0
  56. package/src/lineage/coverage.js +8 -1
  57. package/src/lineage/cross-repo-link.js +22 -0
  58. package/src/lineage/cut-planner.js +548 -0
  59. package/src/lineage/driver.js +219 -66
  60. package/src/lineage/engine.js +166 -21
  61. package/src/lineage/export-json.js +5 -0
  62. package/src/lineage/graph-builder.js +84 -7
  63. package/src/lineage/haskell-view.js +59 -0
  64. package/src/lineage/index.js +3 -0
  65. package/src/lineage/nix-view.js +35 -0
  66. package/src/lineage/observation-adapters.js +17 -9
  67. package/src/lineage/owned-build.js +77 -0
  68. package/src/lineage/owned-worker-entry.js +22 -0
  69. package/src/lineage/owned-worker.js +95 -0
  70. package/src/lineage/path-evidence.js +123 -0
  71. package/src/lineage/path-predicates.js +529 -0
  72. package/src/lineage/path-query.js +189 -20
  73. package/src/lineage/path-store.js +191 -17
  74. package/src/lineage/scc-solver.js +186 -0
  75. package/src/lineage/smt-orchestrator.js +138 -0
  76. package/src/lineage/source-seeding.js +36 -2
  77. package/src/lineage/summaries.js +66 -4
  78. package/src/lineage/value-versions.js +130 -0
  79. package/src/lineage/witness-selection.js +95 -0
  80. package/src/pipeline/scan-health.js +19 -0
  81. package/src/posture/CLAUDE.md +13 -0
  82. package/src/posture/evidence-bundle.js +1 -1
  83. package/src/posture/witness-bundle.js +573 -0
  84. package/src/posture/witness-runtime.js +276 -0
  85. package/src/report/index.js +2 -0
@@ -0,0 +1,929 @@
1
+ // Demand-driven reverse taint (DAG-02): a sink query answered by a finite
2
+ // backward demand frontier plus the EXISTING forward engine run on the
3
+ // selected region only.
4
+ //
5
+ // Roles (R02.5, no existing implementation is removed):
6
+ // tabulation.js coarse caller-only function selector (reachabilitySliceFromSinks)
7
+ // backward.js intraprocedural explanation trace of a finding
8
+ // ifds-precise.js depth-capped predecessor chain
9
+ // this module sink-keyed selector that follows definitions, parameter and
10
+ // return mappings, receiver fields, aliases, producer helpers
11
+ // and callbacks, then lets runTaintEngine corroborate.
12
+ //
13
+ // Soundness rules this module is built around:
14
+ // 1. The frontier only SELECTS functions. A backward/forward intersection is a
15
+ // candidate filter and is reported as one; the only witness is a finding the
16
+ // forward engine produced over the selected region.
17
+ // 2. A construct the selector cannot follow (instance fields, an exhausted
18
+ // depth or work budget, an unresolved higher-order shape) never silently
19
+ // drops a function. Default policy expands to the full function set
20
+ // (`onLimit` / `onUnsupported` = 'full'); the 'partial' policy instead
21
+ // reports partial scope with negativeClaimAllowed false.
22
+ // 3. A limited reverse walk never authorizes a negative verdict. The verdict
23
+ // vocabulary has no "safe": `flow-found`, `no-flow-in-modeled-scope`
24
+ // (only when the completion envelope allows a negative claim) and
25
+ // `unknown`.
26
+ //
27
+ // Opt-in: AGENTIC_SECURITY_DEMAND=1 (see demandEnabled) plus the API below. The
28
+ // default scan never calls this module.
29
+
30
+ import * as crypto from 'node:crypto';
31
+ import { accessPathOf } from './access-paths.js';
32
+ import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, _catalogSize } from './catalog.js';
33
+ import { lookupBuiltinSummary } from './builtin-summaries.js';
34
+ import { runTaintEngine } from './engine.js';
35
+ import { buildCallerQidIndex, reachabilitySliceWithCompletion } from './tabulation.js';
36
+ import { makeCompletion } from '../lineage/completion.js';
37
+
38
+ const DEMAND_SCHEMA_VERSION = '1.0.0';
39
+ const DEMAND_CONTEXTS = Object.freeze(['whole-program']);
40
+ const DEFAULT_MAX_CALLER_DEPTH = 64;
41
+ const DEFAULT_MAX_WORK = 50000;
42
+ const MAX_PATH_SEGMENTS = 6;
43
+
44
+ /** Demand mode is opt-in: this env flag AND an explicit API/query list. */
45
+ export function demandEnabled(env = process.env) {
46
+ return env.AGENTIC_SECURITY_DEMAND === '1';
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // R02.1 query contract
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Normalize a sink query. Keys: exact sink site (file + line, optionally
55
+ * nodeId / callee / sinkId to narrow), argument index, field/access path within
56
+ * the sink argument, analysis context, rule family (CWE id or family name), and
57
+ * the graph/configuration digests the answer is valid for.
58
+ */
59
+ export function normalizeSinkQuery(q = {}) {
60
+ const num = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
61
+ const str = (v) => (typeof v === 'string' && v.length ? v : null);
62
+ return {
63
+ file: str(q.file),
64
+ line: num(q.line),
65
+ nodeId: str(q.nodeId),
66
+ fnQid: str(q.fnQid),
67
+ callee: str(q.callee),
68
+ sinkId: str(q.sinkId),
69
+ argIndex: num(q.argIndex),
70
+ accessPath: str(q.accessPath),
71
+ context: str(q.context) || 'whole-program',
72
+ ruleFamily: str(q.ruleFamily),
73
+ graphDigest: str(q.graphDigest),
74
+ configDigest: str(q.configDigest),
75
+ };
76
+ }
77
+
78
+ const _digestCache = new WeakMap();
79
+
80
+ /** Stable digest of the call graph content the query was answered over. */
81
+ export function graphDigest(callGraph) {
82
+ if (!callGraph || !callGraph.functions) return null;
83
+ if (_digestCache.has(callGraph)) return _digestCache.get(callGraph);
84
+ const h = crypto.createHash('sha256');
85
+ for (const qid of [...callGraph.functions.keys()].sort()) {
86
+ const fn = callGraph.functions.get(qid);
87
+ h.update(qid); h.update('\0');
88
+ h.update(JSON.stringify(fn.params || [])); h.update('\0');
89
+ h.update(JSON.stringify(fn.cfg ? fn.cfg.nodes : null)); h.update('\n');
90
+ }
91
+ const d = h.digest('hex').slice(0, 32);
92
+ _digestCache.set(callGraph, d);
93
+ return d;
94
+ }
95
+
96
+ /** Digest of everything besides the graph that can change an answer. */
97
+ export function configDigest(opts = {}) {
98
+ const h = crypto.createHash('sha256');
99
+ h.update(JSON.stringify({
100
+ catalog: _catalogSize(),
101
+ kcfa: process.env.AGENTIC_SECURITY_KCFA_MAX_CONTEXTS || null,
102
+ callstring: process.env.AGENTIC_SECURITY_KCFA_CALLSTRING || null,
103
+ fnLimit: opts.fnLimit || null,
104
+ maxCallerDepth: opts.maxCallerDepth || DEFAULT_MAX_CALLER_DEPTH,
105
+ maxWork: opts.maxWork || DEFAULT_MAX_WORK,
106
+ onLimit: opts.onLimit || 'full',
107
+ onUnsupported: opts.onUnsupported || 'full',
108
+ }));
109
+ return h.digest('hex').slice(0, 16);
110
+ }
111
+
112
+ /** Stable key of a normalized query bound to its digests. */
113
+ export function sinkQueryKey(query, digests = {}) {
114
+ const q = normalizeSinkQuery(query);
115
+ q.graphDigest = digests.graphDigest ?? q.graphDigest;
116
+ q.configDigest = digests.configDigest ?? q.configDigest;
117
+ return crypto.createHash('sha256').update(JSON.stringify(q)).digest('hex').slice(0, 24);
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Expression helpers
122
+ // ---------------------------------------------------------------------------
123
+
124
+ const SELF_ROOTS = new Set(['this', '_this_', 'self', 'cls']);
125
+ const REQ_PARAM = /^(?:req|request|ctx)$/;
126
+ // Roots that are language builtins or ambient globals, so a call on them is
127
+ // modeled by the engine's own summaries rather than being an unresolved callee.
128
+ const BUILTIN_ROOTS = new Set(['console', 'JSON', 'Math', 'Object', 'Array', 'String', 'Number', 'Boolean', 'Promise', 'Buffer', 'process', 'Date', 'RegExp', 'Set', 'Map', 'Symbol', 'Error', 'parseInt', 'parseFloat', 'encodeURIComponent', 'decodeURIComponent', 'require']);
129
+
130
+ const HIGHER_ORDER_METHODS = new Set(['map', 'forEach', 'filter', 'flatMap', 'find', 'findIndex', 'some', 'every', 'reduce', 'reduceRight', 'sort', 'then', 'catch', 'finally', 'all', 'allSettled', 'race', 'any']);
131
+
132
+ function isExpr(v) { return v && typeof v === 'object' && typeof v.kind === 'string'; }
133
+
134
+ function visitVal(v, f) {
135
+ if (!v || typeof v !== 'object') return;
136
+ if (isExpr(v)) { f(v); return; }
137
+ if (Array.isArray(v)) { for (const x of v) visitVal(x, f); return; }
138
+ for (const x of Object.values(v)) visitVal(x, f);
139
+ }
140
+
141
+ function forEachChild(e, f) {
142
+ for (const k of Object.keys(e)) {
143
+ if (k === 'kind') continue;
144
+ visitVal(e[k], f);
145
+ }
146
+ }
147
+
148
+ const rootOf = (p) => { const i = p.indexOf('.'); return i < 0 ? p : p.slice(0, i); };
149
+
150
+ /** Segment-wise overlap; a `*` segment (computed key) matches any segment. */
151
+ const _segs = new Map();
152
+ function segsOf(s) { let v = _segs.get(s); if (!v) { v = s.split('.'); if (_segs.size > 200000) _segs.clear(); _segs.set(s, v); } return v; }
153
+ function segOverlap(a, b) {
154
+ if (a === b) return true;
155
+ const x = segsOf(a); const y = segsOf(b);
156
+ const n = Math.min(x.length, y.length);
157
+ for (let i = 0; i < n; i++) if (x[i] !== y[i] && x[i] !== '*' && y[i] !== '*') return false;
158
+ return true;
159
+ }
160
+
161
+ function exprOfPath(path) {
162
+ const segs = path.split('.');
163
+ let e = { kind: 'ident', name: segs[0] };
164
+ for (let i = 1; i < segs.length; i++) e = { kind: 'member', object: e, prop: segs[i] };
165
+ return e;
166
+ }
167
+
168
+ function calleeName(callee) {
169
+ if (typeof callee === 'string') return callee;
170
+ if (!callee) return null;
171
+ if (callee.kind === 'ident') return callee.name;
172
+ if (callee.kind === 'member') return accessPathOf(callee);
173
+ return null;
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Function facts and the extended incoming-call index (R02.2)
178
+ // ---------------------------------------------------------------------------
179
+
180
+ const _infoCache = new WeakMap();
181
+
182
+ function fnInfo(fn) {
183
+ if (_infoCache.has(fn)) return _infoCache.get(fn);
184
+ const info = { params: Array.isArray(fn.params) ? fn.params.filter((p) => typeof p === 'string') : [], assigns: [], calls: [], returns: [], fnValues: new Set(), assignedRoots: new Set(), hasOpaqueAssign: false };
185
+ const walk = (e, nid, line) => {
186
+ if (!e || typeof e !== 'object') return;
187
+ if (e.kind === 'function-value') { if (e.qid) info.fnValues.add(e.qid); return; }
188
+ if (e.kind === 'call') info.calls.push({ expr: e, nid, line: e.line || line });
189
+ if (isExpr(e)) forEachChild(e, (c) => walk(c, nid, line));
190
+ };
191
+ const nodes = (fn.cfg && fn.cfg.nodes) || {};
192
+ for (const nid of Object.keys(nodes)) {
193
+ const n = nodes[nid];
194
+ if (!n) continue;
195
+ if (n.kind === 'assign') {
196
+ if (typeof n.target === 'string') { info.assigns.push({ nid, target: n.target, source: n.source, line: n.line }); info.assignedRoots.add(rootOf(n.target)); }
197
+ else { info.assigns.push({ nid, target: null, source: n.source, line: n.line, opaque: true }); info.hasOpaqueAssign = true; }
198
+ walk(n.source, nid, n.line);
199
+ } else if (n.kind === 'call') {
200
+ info.calls.push({ expr: n, nid, line: n.line, top: true });
201
+ for (const a of (n.args || [])) walk(a, nid, n.line);
202
+ if (n.callee && typeof n.callee === 'object') walk(n.callee, nid, n.line);
203
+ } else if (n.kind === 'return') {
204
+ if (n.value) { info.returns.push(n.value); walk(n.value, nid, n.line); }
205
+ } else {
206
+ for (const k of Object.keys(n)) {
207
+ if (['id', 'kind', 'succ', 'pred', 'line'].includes(k)) continue;
208
+ visitVal(n[k], (c) => walk(c, nid, n.line));
209
+ }
210
+ }
211
+ }
212
+ _infoCache.set(fn, info);
213
+ return info;
214
+ }
215
+
216
+ const _indexCache = new WeakMap();
217
+
218
+ /**
219
+ * Incoming-call index over the WHOLE call graph: callee qid -> sites, each
220
+ * {caller, nid, call, kind: 'call'|'callback'|'callback-ref'}. Starts from the
221
+ * canonical buildCallerQidIndex (statement-level calls) and adds embedded call
222
+ * sites that `fn.calls` omits, plus callback registrations. Also indexes
223
+ * functions by bare name for method-name candidate expansion.
224
+ */
225
+ export function buildIncomingIndex(callGraph) {
226
+ if (_indexCache.has(callGraph)) return _indexCache.get(callGraph);
227
+ const incoming = new Map();
228
+ const byTail = new Map();
229
+ const modules = new Map(); const byFile = new Map();
230
+ const add = (qid, site) => { if (!incoming.has(qid)) incoming.set(qid, []); incoming.get(qid).push(site); };
231
+ const resolveName = (name, file) => (name && callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, file) : null);
232
+ for (const fn of callGraph.functions.values()) {
233
+ const tail = String(fn.name || '').split('.').pop();
234
+ if (tail) { if (!byTail.has(tail)) byTail.set(tail, []); byTail.get(tail).push(fn.qid); }
235
+ if (!byFile.has(fn.file)) byFile.set(fn.file, []); byFile.get(fn.file).push(fn);
236
+ if (fn.name === '<module>' && String(fn.qid).includes('::top::')) { if (!modules.has(fn.file)) modules.set(fn.file, []); modules.get(fn.file).push(fn.qid); }
237
+ }
238
+ const base = buildCallerQidIndex(callGraph); // canonical statement-level edges, retained for parity checks
239
+ for (const fn of callGraph.functions.values()) {
240
+ const info = fnInfo(fn);
241
+ for (const occ of info.calls) {
242
+ const name = calleeName(occ.expr.callee);
243
+ const g = resolveName(name, fn.file);
244
+ if (g) add(g, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'call' });
245
+ for (const a of (occ.expr.args || [])) {
246
+ if (a && a.kind === 'function-value' && a.qid) add(a.qid, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'callback' });
247
+ else if (a && a.kind === 'ident') {
248
+ const r = resolveName(a.name, fn.file);
249
+ if (r && r !== fn.qid) add(r, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'callback-ref' });
250
+ }
251
+ }
252
+ }
253
+ }
254
+ const idx = { incoming, byTail, modules, byFile, base };
255
+ _indexCache.set(callGraph, idx);
256
+ return idx;
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Sink-site discovery (nested sinks in assignment and return expressions)
261
+ // ---------------------------------------------------------------------------
262
+
263
+ function matchesFamily(entry, family) {
264
+ if (!family) return true;
265
+ const v = entry.vuln || {};
266
+ return v.cwe === family || entry.id === family || (v.name && v.name === family) || (entry.family && entry.family === family);
267
+ }
268
+
269
+ /** Enumerate sink sites in the graph that satisfy the query's site keys. */
270
+ export function findSinkSites(callGraph, query) {
271
+ const q = normalizeSinkQuery(query);
272
+ const sites = [];
273
+ for (const fn of callGraph.functions.values()) {
274
+ if (q.file && fn.file !== q.file) continue;
275
+ if (q.fnQid && fn.qid !== q.fnQid) continue;
276
+ const info = fnInfo(fn);
277
+ for (const occ of info.calls) {
278
+ if (q.line != null && occ.line !== q.line) continue;
279
+ if (q.nodeId && occ.nid !== q.nodeId) continue;
280
+ const hits = matchSinkOrSanitizer(occ.expr.callee, fn.file) || [];
281
+ for (const e of hits) {
282
+ if (e.kind !== 'sink' || !matchesFamily(e, q.ruleFamily)) continue;
283
+ if (q.sinkId && e.id !== q.sinkId) continue;
284
+ if (q.callee && calleeName(occ.expr.callee) !== q.callee) continue;
285
+ sites.push({ file: fn.file, fnQid: fn.qid, nodeId: occ.nid, line: occ.line, entry: e, sinkId: e.id, argExprs: occ.expr.args || [], callee: calleeName(occ.expr.callee), kind: 'call' });
286
+ }
287
+ }
288
+ for (const a of info.assigns) {
289
+ if (!a.target || (q.line != null && a.line !== q.line)) continue;
290
+ if (q.nodeId && a.nid !== q.nodeId) continue;
291
+ const hits = matchMemberWriteSink(a.target, fn.file) || [];
292
+ for (const e of hits) {
293
+ if (!matchesFamily(e, q.ruleFamily) || (q.sinkId && e.id !== q.sinkId)) continue;
294
+ if (q.callee && a.target !== q.callee) continue;
295
+ sites.push({ file: fn.file, fnQid: fn.qid, nodeId: a.nid, line: a.line, entry: e, sinkId: e.id, argExprs: [a.source], callee: a.target, kind: 'member-write' });
296
+ }
297
+ }
298
+ }
299
+ // The IR can carry one call both as a statement node and as the value of a
300
+ // return node; both name the same source site.
301
+ const seenSite = new Set();
302
+ for (let i = sites.length - 1; i >= 0; i--) {
303
+ const s = sites[i];
304
+ const k = `${s.fnQid}|${s.line}|${s.sinkId}|${s.callee}`;
305
+ if (seenSite.has(k)) sites.splice(i, 1); else seenSite.add(k);
306
+ }
307
+ sites.sort((x, y) => (x.fnQid < y.fnQid ? -1 : x.fnQid > y.fnQid ? 1 : x.nodeId < y.nodeId ? -1 : x.nodeId > y.nodeId ? 1 : x.sinkId < y.sinkId ? -1 : 1));
308
+ return sites;
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // R02.2 / R02.4: the backward demand frontier
313
+ // ---------------------------------------------------------------------------
314
+
315
+ class Frontier {
316
+ constructor(callGraph, opts) {
317
+ this.cg = callGraph;
318
+ this.idx = buildIncomingIndex(callGraph);
319
+ this.maxDepth = opts.maxCallerDepth ?? DEFAULT_MAX_CALLER_DEPTH;
320
+ this.maxWork = opts.maxWork ?? DEFAULT_MAX_WORK;
321
+ this.hasCha = !!(opts.engineOpts && opts.engineOpts._cha);
322
+ this.region = new Map(); // qid -> Set(reason)
323
+ this.work = [];
324
+ this.seen = new Set();
325
+ this.cbDone = new Set();
326
+ this.reqDone = new Set();
327
+ this.steps = 0;
328
+ this.maxDepthReached = 0;
329
+ this.limits = []; // {kind, count}
330
+ this.unsupported = []; // {kind, qid, detail}
331
+ this.unresolved = new Map(); // name@qid -> record
332
+ this.terminals = new Set(); // 'source' | 'param' | 'call' | 'free' | 'callback'
333
+ this.followed = { callers: 0, producers: 0, callbacks: 0, mutators: 0 };
334
+ this.depthCut = 0;
335
+ this.workLeft = 0;
336
+ this.sinkCalls = new Set();
337
+ this.touchCache = new WeakMap();
338
+ this.expanded = new Set();
339
+ }
340
+
341
+ addRegion(qid, reason) {
342
+ if (!this.cg.functions.has(qid)) return;
343
+ if (!this.region.has(qid)) this.region.set(qid, new Set());
344
+ this.region.get(qid).add(reason);
345
+ }
346
+
347
+ push(qid, path, ep, depth, reason) {
348
+ // recursive field growth (`x = x.next` in a loop) would otherwise generate
349
+ // an unbounded family of paths; fold to a capped prefix, which selects
350
+ // more, never less
351
+ if (path.length > 24 && path.charCodeAt(0) !== 36) {
352
+ const segs = segsOf(path);
353
+ if (segs.length > MAX_PATH_SEGMENTS) path = segs.slice(0, MAX_PATH_SEGMENTS).join('.');
354
+ }
355
+ const k = `${qid}|${path}|${ep ? 1 : 0}`;
356
+ if (this.seen.has(k)) return;
357
+ this.seen.add(k);
358
+ this.work.push({ qid, path, ep, depth, reason });
359
+ if (depth > this.maxDepthReached) this.maxDepthReached = depth;
360
+ }
361
+
362
+ run() {
363
+ while (this.work.length) {
364
+ if (++this.steps > this.maxWork) {
365
+ this.limits.push({ kind: 'work-limit', count: this.work.length });
366
+ this.workLeft = this.work.length;
367
+ this.work = [];
368
+ break;
369
+ }
370
+ const item = this.work.pop();
371
+ this.process(item);
372
+ }
373
+ }
374
+
375
+ markUnsupported(kind, qid, detail) {
376
+ if (!this.unsupported.some((u) => u.kind === kind && u.qid === qid)) this.unsupported.push({ kind, qid, detail });
377
+ }
378
+
379
+ /** Walk an expression in `fn` and register every path/call it depends on. */
380
+ reads(fn, expr, item) {
381
+ const rd = (e) => {
382
+ if (!e || typeof e !== 'object') return;
383
+ if (e.kind === 'literal') return;
384
+ if (e.kind === 'function-value') { this.callbackValue(e.qid, item.depth); return; }
385
+ const p = accessPathOf(e);
386
+ if (p) { this.push(fn.qid, p, item.ep, item.depth, item.reason); this.checkSourceExpr(fn, e, p); return; }
387
+ if (e.kind === 'call') { this.callExpr(fn, e, item); return; }
388
+ if (matchSource(e, fn.file)) this.terminals.add('source');
389
+ forEachChild(e, rd);
390
+ };
391
+ rd(expr);
392
+ }
393
+
394
+ callbackValue(qid, depth) {
395
+ if (!qid || !this.cg.functions.has(qid)) return;
396
+ this.followed.callbacks += 1;
397
+ this.addRegion(qid, 'callback');
398
+ this.push(qid, '$ret', false, depth, 'callback');
399
+ this.push(qid, '$params', false, depth, 'callback');
400
+ }
401
+
402
+ callExpr(fn, e, item) {
403
+ if (matchSource(e, fn.file)) this.terminals.add('source');
404
+ const callee = e.callee;
405
+ // receiver: taint on the receiver flows through the call (`tainted.trim()`)
406
+ if (callee && typeof callee === 'object' && callee.kind === 'member') {
407
+ const rp = accessPathOf(callee.object);
408
+ if (rp) this.push(fn.qid, rp, item.ep, item.depth, item.reason);
409
+ else this.reads(fn, callee.object, item);
410
+ } else if (typeof callee === 'string' && callee.includes('.')) {
411
+ this.push(fn.qid, callee.slice(0, callee.lastIndexOf('.')), item.ep, item.depth, item.reason);
412
+ }
413
+ for (const a of (e.args || [])) this.reads(fn, a, item);
414
+ const name = calleeName(callee);
415
+ const g = name && this.cg.resolveKnownCallee ? this.cg.resolveKnownCallee(name, fn.file) : null;
416
+ if (g) {
417
+ this.followed.producers += 1;
418
+ this.addRegion(g, 'producer');
419
+ this.push(g, '$ret', false, item.depth, 'producer');
420
+ this.terminals.add('call');
421
+ return;
422
+ }
423
+ // member call on an unresolved receiver: the engine may resolve it through
424
+ // class-hierarchy analysis, so every project function with that bare name
425
+ // is a candidate producer (conservative expansion, never a drop).
426
+ const tail = name ? name.split('.').pop() : null;
427
+ // Only the engine's class-hierarchy resolution (opts._cha) can reach such a
428
+ // callee, so without it there is nothing to over-select; with it, only
429
+ // class methods are candidates.
430
+ const cands = tail && this.hasCha && callee && typeof callee === 'object' && callee.kind === 'member'
431
+ ? (this.idx.byTail.get(tail) || []).filter((q) => /::[A-Za-z_$][\w$]*::[\w$]+@/.test(q) && !q.includes('::<module>::'))
432
+ : null;
433
+ if (cands && cands.length) {
434
+ for (const c of cands) { this.followed.producers += 1; this.addRegion(c, 'method-candidate'); this.push(c, '$ret', false, item.depth, 'method-candidate'); this.push(c, '$params', false, item.depth, 'method-candidate'); }
435
+ this.terminals.add('call');
436
+ return;
437
+ }
438
+ const info = fnInfo(fn);
439
+ const prop = callee && typeof callee === 'object' && callee.kind === 'member' && typeof callee.prop === 'string' ? callee.prop : (name ? name.split('.').pop() : null);
440
+ // A call through a parameter is a callback invocation (the registration
441
+ // site is followed by expandCallers); an array or promise combinator is
442
+ // modeled by the engine's higher-order handling.
443
+ const viaParam = callee && typeof callee === 'object' && callee.kind === 'ident' && info.params.includes(callee.name);
444
+ const modeled = viaParam || (prop && HIGHER_ORDER_METHODS.has(prop)) || (callee && matchSinkOrSanitizer(callee, fn.file)) || (name && lookupBuiltinSummary(name)) || (name && BUILTIN_ROOTS.has(rootOf(name)));
445
+ if (!modeled) {
446
+ const key = `${name || '<dynamic>'}@${fn.qid}`;
447
+ if (!this.unresolved.has(key)) this.unresolved.set(key, { callee: name || '<dynamic>', fnQid: fn.qid, line: e.line || null });
448
+ }
449
+ this.terminals.add('call');
450
+ }
451
+
452
+ checkSource(fn, path) {
453
+ const segs = path.split('.');
454
+ for (let i = 1; i <= segs.length; i++) {
455
+ if (matchSource(exprOfPath(segs.slice(0, i).join('.')), fn.file)) { this.terminals.add('source'); return; }
456
+ }
457
+ }
458
+
459
+ checkSourceExpr(fn, e, p) {
460
+ if (matchSource(e, fn.file)) this.terminals.add('source');
461
+ else this.checkSource(fn, p);
462
+ }
463
+
464
+ process(item) {
465
+ const fn = this.cg.functions.get(item.qid);
466
+ if (!fn || !fn.cfg || !fn.cfg.nodes) { this.markUnsupported('missing-function-body', item.qid, item.path); return; }
467
+ this.addRegion(item.qid, item.reason || 'sink');
468
+ const info = fnInfo(fn);
469
+ const p = item.path;
470
+
471
+ if (!this.cbDone.has(fn.qid)) {
472
+ this.cbDone.add(fn.qid);
473
+ // callbacks defined inside a selected function run under its taint
474
+ for (const q of info.fnValues) this.callbackValue(q, item.depth);
475
+ this.requestFieldWriters(fn, info, item);
476
+ }
477
+ if (p === '$ret') { for (const r of info.returns) this.reads(fn, r, item); return; }
478
+ if (p === '$params') { for (const prm of info.params) this.push(fn.qid, prm, false, item.depth, item.reason); this.push(fn.qid, '$ret', false, item.depth, item.reason); return; }
479
+
480
+ const root = rootOf(p);
481
+ if (SELF_ROOTS.has(root)) { this.markUnsupported('instance-field', fn.qid, p); return; }
482
+ this.checkSource(fn, p);
483
+
484
+ for (const a of info.assigns) {
485
+ if (a.opaque) { this.reads(fn, a.source, item); continue; }
486
+ if (!segOverlap(a.target, p)) continue;
487
+ // target covers the needed path (equal or a prefix): the value of the
488
+ // needed path comes from the matching part of the source. Otherwise the
489
+ // write is to a sub-field of the needed path (a partial write): the whole
490
+ // source matters.
491
+ const tl = a.target.split('.').length; const pl = p.split('.').length;
492
+ if (tl <= pl) this.assignSource(fn, a, p.split('.').slice(tl).join('.'), item);
493
+ else this.reads(fn, a.source, item);
494
+ }
495
+
496
+ for (const occ of info.calls) {
497
+ // the queried sink call itself runs after its argument is formed, so it
498
+ // cannot change what the argument holds
499
+ if (this.sinkCalls.has(`${fn.qid}|${occ.line}|${calleeName(occ.expr.callee)}`)) continue;
500
+ if (!this.callTouches(occ.expr, p)) continue;
501
+ this.followed.mutators += 1;
502
+ this.reads(fn, occ.expr, item);
503
+ const name = calleeName(occ.expr.callee);
504
+ const g = name && this.cg.resolveKnownCallee ? this.cg.resolveKnownCallee(name, fn.file) : null;
505
+ if (g && g !== fn.qid) { this.addRegion(g, 'mutator'); this.push(g, '$params', false, item.depth, 'mutator'); }
506
+ }
507
+
508
+ const pi = info.params.indexOf(root);
509
+ if (pi >= 0) {
510
+ this.terminals.add('param');
511
+ if (item.ep) this.expandCallers(fn, pi, item);
512
+ } else if (!info.assignedRoots.has(root) && !info.hasOpaqueAssign && !info.params.includes(root)) {
513
+ this.freeVariable(fn, root, p, item);
514
+ }
515
+ }
516
+
517
+ assignSource(fn, a, suffix, item) {
518
+ const src = a.source;
519
+ if (!src) return;
520
+ if (src.kind === 'object' && Array.isArray(src.props)) {
521
+ if (!suffix) { for (const pr of src.props) this.reads(fn, pr.value, item); return; }
522
+ const segs = suffix.split('.');
523
+ const key = segs[0];
524
+ let found = false;
525
+ for (const pr of src.props) {
526
+ if (pr.key === key || pr.key === '*' || key === '*') {
527
+ found = true;
528
+ const rest = segs.slice(1).join('.');
529
+ const vp = accessPathOf(pr.value);
530
+ if (rest && vp) this.push(fn.qid, `${vp}.${rest}`, item.ep, item.depth, item.reason);
531
+ else this.reads(fn, pr.value, item);
532
+ }
533
+ }
534
+ // a spread entry (`...other`) can supply any key
535
+ if (!found || src.props.some((pr) => pr.key === '...' || pr.spread)) this.reads(fn, src, item);
536
+ return;
537
+ }
538
+ const sp = accessPathOf(src);
539
+ if (sp) { this.push(fn.qid, suffix ? `${sp}.${suffix}` : sp, item.ep, item.depth, item.reason); this.checkSource(fn, sp); return; }
540
+ this.reads(fn, src, item);
541
+ }
542
+
543
+ callTouches(call, p) {
544
+ let paths = this.touchCache.get(call);
545
+ if (paths) return paths.some((q) => segOverlap(q, p));
546
+ paths = [];
547
+ this.touchCache.set(call, paths);
548
+ const collect = (e) => {
549
+ if (!e || typeof e !== 'object') return;
550
+ if (e.kind === 'function-value') return;
551
+ const ap = accessPathOf(e);
552
+ if (ap) { paths.push(ap); return; }
553
+ if (isExpr(e)) forEachChild(e, collect);
554
+ };
555
+ const callee = call.callee;
556
+ if (callee && typeof callee === 'object' && callee.kind === 'member') collect(callee.object);
557
+ else if (typeof callee === 'string' && callee.includes('.')) paths.push(callee.slice(0, callee.lastIndexOf('.')));
558
+ for (const a of (call.args || [])) collect(a);
559
+ return paths.some((q) => segOverlap(q, p));
560
+ }
561
+
562
+ expandCallers(fn, paramIdx, item) {
563
+ // the caller-side argument is read whole, so one expansion per parameter
564
+ // covers every access path rooted at it
565
+ const ek = `${fn.qid}|${paramIdx}`;
566
+ if (this.expanded.has(ek)) return;
567
+ this.expanded.add(ek);
568
+ const sites = this.idx.incoming.get(fn.qid) || [];
569
+ if (!sites.length) return;
570
+ if (item.depth + 1 > this.maxDepth) { this.depthCut += sites.length; this.limits.push({ kind: 'depth-limit', count: sites.length }); return; }
571
+ for (const s of sites) {
572
+ const caller = this.cg.functions.get(s.caller);
573
+ if (!caller) continue;
574
+ this.followed.callers += 1;
575
+ this.addRegion(s.caller, s.kind === 'call' ? 'caller' : 'callback-registrar');
576
+ const ni = { ...item, qid: s.caller, depth: item.depth + 1, ep: true, reason: 'caller' };
577
+ if (s.kind === 'call') {
578
+ const arg = (s.call.args || [])[paramIdx];
579
+ if (arg) this.reads(caller, arg, ni);
580
+ const callee = s.call.callee;
581
+ if (callee && typeof callee === 'object' && callee.kind === 'member') this.reads(caller, callee.object, ni);
582
+ } else {
583
+ for (const a of (s.call.args || [])) if (!(a && a.kind === 'function-value' && a.qid === fn.qid)) this.reads(caller, a, ni);
584
+ const callee = s.call.callee;
585
+ if (callee && typeof callee === 'object' && callee.kind === 'member') this.reads(caller, callee.object, ni);
586
+ }
587
+ }
588
+ }
589
+
590
+ requestFieldWriters(fn, info, item) {
591
+ if (!item.ep && item.reason !== 'sink') return;
592
+ const p0 = info.params[0];
593
+ if (!p0 || !REQ_PARAM.test(p0) || this.reqDone.has(fn.file)) return;
594
+ this.reqDone.add(fn.file);
595
+ for (const other of (this.idx.byFile.get(fn.file) || [])) {
596
+ if (other.file !== fn.file) continue;
597
+ const oi = fnInfo(other);
598
+ const q0 = oi.params[0];
599
+ if (!q0 || !REQ_PARAM.test(q0)) continue;
600
+ for (const a of oi.assigns) {
601
+ if (a.target && a.target.startsWith(`${q0}.`)) {
602
+ this.addRegion(other.qid, 'request-field-writer');
603
+ this.push(other.qid, a.target, true, item.depth, 'request-field-writer');
604
+ }
605
+ }
606
+ }
607
+ }
608
+
609
+ freeVariable(fn, root, p, item) {
610
+ const definers = [];
611
+ for (const other of (this.idx.byFile.get(fn.file) || [])) {
612
+ if (other.file !== fn.file || other.qid === fn.qid) continue;
613
+ const oi = fnInfo(other);
614
+ if (oi.assignedRoots.has(root) || oi.params.includes(root)) definers.push(other);
615
+ }
616
+ // import bindings (`const x = require(...)`) carry no data of their own
617
+ const importOnly = definers.length > 0 && definers.every((d) => fnInfo(d).assigns.filter((a) => a.target && rootOf(a.target) === root).every((a) => a.source && a.source.kind === 'call' && calleeName(a.source.callee) === 'require'));
618
+ if (!definers.length) return; // ambient global; the engine models it by name only
619
+ this.terminals.add('free');
620
+ if (importOnly) return;
621
+ for (const d of definers) {
622
+ if (d.qid === fn.qid) continue;
623
+ this.addRegion(d.qid, 'free-variable-definer');
624
+ this.push(d.qid, p, item.ep, item.depth, 'free-variable-definer');
625
+ }
626
+ }
627
+ }
628
+
629
+ // ---------------------------------------------------------------------------
630
+ // Seeds from a sink site
631
+ // ---------------------------------------------------------------------------
632
+
633
+ function seedExprs(site, q) {
634
+ let args;
635
+ if (q.argIndex != null) args = site.argExprs[q.argIndex] ? [site.argExprs[q.argIndex]] : [];
636
+ else if (site.entry.argIndex === 'all') args = site.argExprs;
637
+ else if (typeof site.entry.argIndex === 'number') args = site.argExprs[site.entry.argIndex] ? [site.argExprs[site.entry.argIndex]] : [];
638
+ else args = site.argExprs; // 'rhs' and member-write
639
+ return args;
640
+ }
641
+
642
+ function seedFrontier(frontier, fn, site, q) {
643
+ const item = { qid: site.fnQid, path: '', ep: true, depth: 0, reason: 'sink' };
644
+ frontier.addRegion(site.fnQid, 'sink');
645
+ if (site.kind === 'call') frontier.sinkCalls.add(`${site.fnQid}|${site.line}|${site.callee}`);
646
+ for (const a of seedExprs(site, q)) {
647
+ if (!q.accessPath) { frontier.reads(fn, a, item); continue; }
648
+ const ap = accessPathOf(a);
649
+ if (ap) { frontier.push(site.fnQid, `${ap}.${q.accessPath}`, true, 0, 'sink'); continue; }
650
+ if (a && a.kind === 'object' && Array.isArray(a.props)) {
651
+ const segs = q.accessPath.split('.');
652
+ for (const pr of a.props) {
653
+ if (pr.key !== segs[0] && pr.key !== '*') continue;
654
+ const vp = accessPathOf(pr.value);
655
+ if (segs.length > 1 && vp) frontier.push(site.fnQid, `${vp}.${segs.slice(1).join('.')}`, true, 0, 'sink');
656
+ else frontier.reads(fn, pr.value, item);
657
+ }
658
+ continue;
659
+ }
660
+ // not a path or literal: no field refinement is possible, read the whole expression
661
+ frontier.reads(fn, a, item);
662
+ }
663
+ }
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // Normalization, running, comparing
667
+ // ---------------------------------------------------------------------------
668
+
669
+ export function normalizeFinding(f) {
670
+ const sources = new Set();
671
+ if (f.source && f.source.label) sources.add(`${f.source.label}@${f.source.line ?? ''}`);
672
+ for (const c of (f.chain || [])) if (c && c.label) sources.add(`${c.label}@${c.line ?? ''}`);
673
+ return {
674
+ file: f.file,
675
+ line: f.line,
676
+ sinkId: f.sink ? f.sink.label : null,
677
+ argIndex: f.argIndex ?? null,
678
+ cwe: f.cwe ?? null,
679
+ sources: [...sources].sort(),
680
+ };
681
+ }
682
+
683
+ const findingKey = (n) => JSON.stringify([n.file, n.line, n.sinkId, n.argIndex, n.cwe, n.sources]);
684
+
685
+ function pickFindings(raw, sites, q) {
686
+ const out = [];
687
+ const want = new Set(sites.map((s) => `${s.file}|${s.line}|${s.sinkId}`));
688
+ for (const f of raw) {
689
+ if (!f.sink || !want.has(`${f.file}|${f.line}|${f.sink.label}`)) continue;
690
+ if (q.argIndex != null && f.argIndex !== q.argIndex) continue;
691
+ out.push(normalizeFinding(f));
692
+ }
693
+ const byKey = new Map();
694
+ for (const n of out) byKey.set(findingKey(n), n);
695
+ return [...byKey.values()].sort((a, b) => (findingKey(a) < findingKey(b) ? -1 : 1));
696
+ }
697
+
698
+ function sourceBearing(fn) {
699
+ const info = fnInfo(fn);
700
+ let found = Array.isArray(fn.paramAnnotations) && fn.paramAnnotations.length > 0;
701
+ const rd = (e) => { if (found || !e || typeof e !== 'object') return; if (e.kind === 'function-value') return; if (matchSource(e, fn.file)) { found = true; return; } if (isExpr(e)) forEachChild(e, rd); };
702
+ for (const a of info.assigns) rd(a.source);
703
+ for (const c of info.calls) rd(c.expr);
704
+ for (const r of info.returns) rd(r);
705
+ return found;
706
+ }
707
+
708
+ const _lru = new Map();
709
+ const LRU_MAX = 64;
710
+
711
+ function resultCacheGet(key) { if (!_lru.has(key)) return null; const v = _lru.get(key); _lru.delete(key); _lru.set(key, v); return v; }
712
+ function resultCachePut(key, v) { _lru.set(key, v); while (_lru.size > LRU_MAX) _lru.delete(_lru.keys().next().value); }
713
+ export function clearDemandCache() { _lru.clear(); }
714
+
715
+ function engineOptsOf(opts) {
716
+ const o = { ...(opts.engineOpts || {}) };
717
+ if (opts.fnLimit) o.fnLimit = opts.fnLimit;
718
+ if (typeof opts.deadlineMs === 'number') o.deadlineMs = opts.deadlineMs;
719
+ return o;
720
+ }
721
+
722
+ function engineIncomplete(raw) {
723
+ const list = raw && raw.analysisIncomplete;
724
+ return Array.isArray(list) ? list.map((x) => x.reason).filter(Boolean) : [];
725
+ }
726
+
727
+ function fieldAttributable(frontier) {
728
+ return frontier.terminals.size > 0;
729
+ }
730
+
731
+ function baseResult(q, key, digests) {
732
+ return { schemaVersion: DEMAND_SCHEMA_VERSION, queryKey: key, query: q, digests };
733
+ }
734
+
735
+ /**
736
+ * Run one sink query in demand mode. Returns the result record described in
737
+ * the module header. Never throws for an unanswerable query: it returns an
738
+ * `unavailable` completion instead.
739
+ */
740
+ export function runDemandSinkQuery(perFileIR, callGraph, query, opts = {}) {
741
+ const q = normalizeSinkQuery(query);
742
+ const digests = { graphDigest: opts.graphDigest ?? graphDigest(callGraph), configDigest: opts.configDigest ?? configDigest(opts) };
743
+ const key = sinkQueryKey(q, digests);
744
+ const useCache = opts.cache !== false && !opts.engine;
745
+ if (useCache) { const hit = resultCacheGet(key); if (hit) return { ...hit, cached: true }; }
746
+ const res = baseResult(q, key, digests);
747
+ res.mode = 'demand';
748
+ const total = callGraph && callGraph.functions ? callGraph.functions.size : 0;
749
+
750
+ if (!callGraph || !callGraph.functions) return unavailable(res, 'no-call-graph', total);
751
+ if (!DEMAND_CONTEXTS.includes(q.context)) return unavailable(res, 'unsupported-context', total);
752
+ const sites = findSinkSites(callGraph, q);
753
+ if (!sites.length) return unavailable(res, 'sink-site-not-found', total);
754
+
755
+ const frontier = new Frontier(callGraph, opts);
756
+ for (const s of sites) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, q);
757
+ frontier.run();
758
+
759
+ const onLimit = opts.onLimit || 'full';
760
+ const onUnsupported = opts.onUnsupported || 'full';
761
+ const reasons = [];
762
+ let fallback = null;
763
+ if (frontier.limits.length) {
764
+ if (onLimit === 'full') fallback = { reason: frontier.limits.map((l) => l.kind).sort()[0], expandedToFull: true };
765
+ else for (const l of frontier.limits) reasons.push(l.kind);
766
+ }
767
+ if (frontier.unsupported.length) {
768
+ const kinds = frontier.unsupported.map((u) => u.kind).sort();
769
+ if (onUnsupported === 'full') fallback = fallback || { reason: `unsupported:${kinds[0]}`, expandedToFull: true };
770
+ else for (const k of new Set(kinds)) reasons.push(`unsupported-construct:${k}`);
771
+ }
772
+ if (frontier.unresolved.size) reasons.push('unresolved-callee');
773
+
774
+ let regionIds = [...frontier.region.keys()].sort();
775
+ if (fallback) regionIds = [...callGraph.functions.keys()].sort();
776
+ const regionGraph = { ...callGraph, functions: new Map(regionIds.map((id) => [id, callGraph.functions.get(id)])) };
777
+
778
+ const run = opts.engine || runTaintEngine;
779
+ const raw = run(perFileIR, regionGraph, engineOptsOf(opts));
780
+ for (const r of engineIncomplete(raw)) reasons.push(`engine:${r}`);
781
+
782
+ let findings = pickFindings(raw, sites, q);
783
+ let siblingSuppressed = 0;
784
+ if (q.accessPath && !fieldAttributable(frontier)) { siblingSuppressed = findings.length; findings = []; }
785
+
786
+ const sbIn = regionIds.filter((id) => sourceBearing(callGraph.functions.get(id)));
787
+ res.sites = sites.map((s) => ({ file: s.file, line: s.line, fnQid: s.fnQid, nodeId: s.nodeId, sinkId: s.sinkId, kind: s.kind }));
788
+ res.findings = findings;
789
+ res.siblingFieldSuppressed = siblingSuppressed;
790
+ res.witness = findings.length ? 'forward-engine-corroborated' : 'none';
791
+ res.candidateFilter = { role: 'candidate-filter-only', sourceBearingFunctionsInRegion: sbIn.length, candidate: sbIn.length > 0 || frontier.terminals.has('param') || frontier.terminals.has('free') };
792
+ res.selectorCoverage = {
793
+ totalFunctions: total,
794
+ selectedFunctions: frontier.region.size,
795
+ selectedFraction: total ? frontier.region.size / total : 0,
796
+ byReason: countReasons(frontier.region),
797
+ followed: { ...frontier.followed },
798
+ maxDepthReached: frontier.maxDepthReached,
799
+ workSteps: frontier.steps,
800
+ limits: { maxCallerDepth: frontier.maxDepth, maxWork: frontier.maxWork },
801
+ limitHits: frontier.limits.map((l) => ({ ...l })),
802
+ unsupported: frontier.unsupported.map((u) => ({ ...u })),
803
+ unresolvedCallees: [...frontier.unresolved.values()],
804
+ terminals: [...frontier.terminals].sort(),
805
+ };
806
+ res.analyzedFunctions = regionIds.length;
807
+ res.fallback = fallback;
808
+ res.completion = completionOf(reasons, frontier, fallback);
809
+ res.negativeClaimAllowed = res.completion.negativeClaimAllowed;
810
+ res.verdict = findings.length ? 'flow-found' : (res.negativeClaimAllowed ? 'no-flow-in-modeled-scope' : 'unknown');
811
+ if (useCache) resultCachePut(key, res);
812
+ return res;
813
+ }
814
+
815
+ function countReasons(region) {
816
+ const out = {};
817
+ for (const set of region.values()) for (const r of set) out[r] = (out[r] || 0) + 1;
818
+ return out;
819
+ }
820
+
821
+ function completionOf(reasons, frontier, fallback) {
822
+ const unexplored = frontier.depthCut + frontier.workLeft;
823
+ const partial = reasons.length > 0;
824
+ return makeCompletion({
825
+ status: partial ? 'partial' : 'complete',
826
+ modelCoverage: partial ? 'partial' : 'complete',
827
+ reasons,
828
+ unexploredFrontierCount: partial ? Math.max(unexplored, 0) : 0,
829
+ limits: { maxCallerDepth: frontier.maxDepth, maxWork: frontier.maxWork, expandedToFull: !!fallback },
830
+ unit: 'demand-query',
831
+ });
832
+ }
833
+
834
+ function unavailable(res, reason, total) {
835
+ res.sites = [];
836
+ res.findings = [];
837
+ res.siblingFieldSuppressed = 0;
838
+ res.witness = 'none';
839
+ res.candidateFilter = { role: 'candidate-filter-only', sourceBearingFunctionsInRegion: 0, candidate: false };
840
+ res.selectorCoverage = { totalFunctions: total, selectedFunctions: 0, selectedFraction: 0, byReason: {}, followed: {}, maxDepthReached: 0, workSteps: 0, limits: {}, limitHits: [], unsupported: [], unresolvedCallees: [], terminals: [] };
841
+ res.analyzedFunctions = 0;
842
+ res.fallback = null;
843
+ res.completion = makeCompletion({ status: 'unavailable', reasons: [reason], unit: 'demand-query' });
844
+ res.negativeClaimAllowed = false;
845
+ res.verdict = 'unknown';
846
+ return res;
847
+ }
848
+
849
+ /** Reference answer: the same query over the FULL function set. */
850
+ export function runFullSinkQuery(perFileIR, callGraph, query, opts = {}) {
851
+ const q = normalizeSinkQuery(query);
852
+ const digests = { graphDigest: opts.graphDigest ?? graphDigest(callGraph), configDigest: opts.configDigest ?? configDigest(opts) };
853
+ const res = baseResult(q, sinkQueryKey(q, digests), digests);
854
+ res.mode = 'full';
855
+ const total = callGraph.functions.size;
856
+ const sites = findSinkSites(callGraph, q);
857
+ if (!sites.length) return unavailable(res, 'sink-site-not-found', total);
858
+ const run = opts.engine || runTaintEngine;
859
+ const raw = run(perFileIR, callGraph, engineOptsOf(opts));
860
+ let findings = pickFindings(raw, sites, q);
861
+ let suppressed = 0;
862
+ if (q.accessPath) {
863
+ const frontier = new Frontier(callGraph, opts);
864
+ for (const s of sites) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, q);
865
+ frontier.run();
866
+ if (!fieldAttributable(frontier)) { suppressed = findings.length; findings = []; }
867
+ }
868
+ res.sites = sites.map((s) => ({ file: s.file, line: s.line, fnQid: s.fnQid, nodeId: s.nodeId, sinkId: s.sinkId, kind: s.kind }));
869
+ res.findings = findings;
870
+ res.siblingFieldSuppressed = suppressed;
871
+ res.analyzedFunctions = total;
872
+ res.engineIncomplete = engineIncomplete(raw);
873
+ return res;
874
+ }
875
+
876
+ /** Compare a demand result with the full reference for the same query. */
877
+ export function compareToFull(demand, full) {
878
+ const a = demand.findings.map(findingKey);
879
+ const b = full.findings.map(findingKey);
880
+ const missing = b.filter((k) => !a.includes(k));
881
+ const extra = a.filter((k) => !b.includes(k));
882
+ return { equal: missing.length === 0 && extra.length === 0, missing, extra };
883
+ }
884
+
885
+ /** Run a batch of queries (the shape the optional dataflow/index.js hook uses). */
886
+ export function runDemandQueries(perFileIR, callGraph, queries, opts = {}) {
887
+ return (queries || []).map((q) => runDemandSinkQuery(perFileIR, callGraph, q, opts));
888
+ }
889
+
890
+ /** Enumerate every sink site in the graph as a query (used by parity checks and the benchmark). */
891
+ export function enumerateSinkQueries(callGraph) {
892
+ return findSinkSites(callGraph, {}).map((s) => ({ file: s.file, line: s.line, nodeId: s.nodeId, fnQid: s.fnQid, sinkId: s.sinkId }));
893
+ }
894
+
895
+ // ---------------------------------------------------------------------------
896
+ // R02.5: adapters over the existing reverse implementations
897
+ // ---------------------------------------------------------------------------
898
+
899
+ /**
900
+ * Compare the demand region of a query result with the coarse tabulation slice
901
+ * for the same sink functions, using the shared completion metadata. Nothing is
902
+ * replaced: the report states which role each selector keeps.
903
+ */
904
+ export function reconcileSelectors(callGraph, demandResult) {
905
+ const sinkFns = [...new Set((demandResult.sites || []).map((s) => s.fnQid))];
906
+ const tab = reachabilitySliceWithCompletion(sinkFns.map((fnQid) => ({ fnQid })), callGraph);
907
+ const demandIds = new Set();
908
+ const frontier = new Frontier(callGraph, {});
909
+ for (const s of findSinkSites(callGraph, demandResult.query)) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, demandResult.query);
910
+ frontier.run();
911
+ for (const id of frontier.region.keys()) demandIds.add(id);
912
+ const onlyDemand = [...demandIds].filter((x) => !tab.reachable.has(x)).sort();
913
+ const onlyTabulation = [...tab.reachable].filter((x) => !demandIds.has(x)).sort();
914
+ return {
915
+ roles: {
916
+ tabulation: 'coarse caller-only selector; kept, cannot see producer helpers or callback registrars',
917
+ backward: 'explanation trace of an emitted finding; kept, not a selector',
918
+ ifdsPrecise: 'depth-capped predecessor chain; kept, now reports a completion record',
919
+ demand: 'sink-keyed selector with forward corroboration',
920
+ },
921
+ tabulationCompletion: makeCompletion({ status: tab.completion.status, reasons: tab.completion.reasons, unexploredFrontierCount: tab.completion.unexploredFrontierCount, limits: tab.completion.limits, unit: 'tabulation-slice' }),
922
+ demandCompletion: demandResult.completion,
923
+ onlyDemand,
924
+ onlyTabulation,
925
+ tabulationMissedDemandFunctions: onlyDemand.length,
926
+ };
927
+ }
928
+
929
+ export const _internals = { Frontier, fnInfo, segOverlap, calleeName, sourceBearing };