@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1

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 (228) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/agentic-security.js +111 -1
  3. package/dist/1128.index.js +912 -0
  4. package/dist/1665.index.js +828 -0
  5. package/dist/2584.index.js +1 -1
  6. package/dist/4171.index.js +2 -2
  7. package/dist/5752.index.js +466 -0
  8. package/dist/7405.index.js +1 -1
  9. package/dist/8158.index.js +1713 -0
  10. package/dist/8798.index.js +1 -1
  11. package/dist/9382.index.js +2 -2
  12. package/dist/9579.index.js +1 -1
  13. package/dist/agentic-security.mjs +29 -29
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
  16. package/dist/frontend/src/shell.js +12 -2
  17. package/package.json +29 -12
  18. package/src/dataflow/CLAUDE.md +46 -1
  19. package/src/dataflow/access-paths.js +12 -2
  20. package/src/dataflow/backward.js +42 -7
  21. package/src/dataflow/cross-service-taint.js +83 -1
  22. package/src/dataflow/demand-query.js +1168 -0
  23. package/src/dataflow/dep-fingerprint.js +718 -0
  24. package/src/dataflow/engine.js +48 -6
  25. package/src/dataflow/exploit-prover.js +6 -2
  26. package/src/dataflow/ifds-precise.js +22 -0
  27. package/src/dataflow/incremental-replay.js +118 -0
  28. package/src/dataflow/incremental-session.js +257 -0
  29. package/src/dataflow/incremental.js +237 -35
  30. package/src/dataflow/index.js +25 -94
  31. package/src/dataflow/ir-cache.js +268 -0
  32. package/src/dataflow/privacy-taint.js +7 -0
  33. package/src/dataflow/summaries.js +72 -6
  34. package/src/dataflow/tabulation.js +80 -24
  35. package/src/engine.js +113 -8
  36. package/src/graph/CLAUDE.md +31 -0
  37. package/src/graph/adapters/artifact.js +81 -0
  38. package/src/graph/adapters/crossrepo.js +75 -0
  39. package/src/graph/adapters/dataflow.js +76 -0
  40. package/src/graph/adapters/provenance.js +92 -0
  41. package/src/graph/adapters/semantic.js +89 -0
  42. package/src/graph/crosswalk-builders.js +152 -0
  43. package/src/graph/crosswalk.js +146 -0
  44. package/src/graph/index.js +15 -0
  45. package/src/graph/observation.js +57 -0
  46. package/src/graph/refs.js +105 -0
  47. package/src/graph/registry.js +129 -0
  48. package/src/graph/relations.js +81 -0
  49. package/src/graph/scc.js +241 -0
  50. package/src/graph/view.js +254 -0
  51. package/src/ir/CLAUDE.md +4 -0
  52. package/src/ir/callgraph.js +36 -8
  53. package/src/ir/index.js +18 -4
  54. package/src/ir/parser-js.js +10 -0
  55. package/src/language/CLAUDE.md +13 -0
  56. package/src/language/nix-option-types.js +170 -0
  57. package/src/language/nix-script-taint.js +46 -3
  58. package/src/language/nix-secrets.js +20 -2
  59. package/src/lineage/CLAUDE.md +72 -8
  60. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  61. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  62. package/src/lineage/boundary-contract.js +273 -0
  63. package/src/lineage/completion.js +205 -0
  64. package/src/lineage/continuation.js +140 -0
  65. package/src/lineage/control-credit.js +313 -0
  66. package/src/lineage/correlation.js +199 -0
  67. package/src/lineage/coverage.js +8 -1
  68. package/src/lineage/cross-repo-link.js +22 -0
  69. package/src/lineage/cut-planner.js +548 -0
  70. package/src/lineage/driver.js +219 -66
  71. package/src/lineage/engine.js +166 -21
  72. package/src/lineage/export-json.js +5 -0
  73. package/src/lineage/graph-builder.js +84 -7
  74. package/src/lineage/haskell-view.js +59 -0
  75. package/src/lineage/index.js +3 -0
  76. package/src/lineage/nix-view.js +35 -0
  77. package/src/lineage/owned-build.js +77 -0
  78. package/src/lineage/owned-worker-entry.js +22 -0
  79. package/src/lineage/owned-worker.js +95 -0
  80. package/src/lineage/path-evidence.js +123 -0
  81. package/src/lineage/path-predicates.js +529 -0
  82. package/src/lineage/path-query.js +189 -20
  83. package/src/lineage/path-store.js +191 -17
  84. package/src/lineage/scc-solver.js +186 -0
  85. package/src/lineage/smt-orchestrator.js +174 -0
  86. package/src/lineage/smt-z3-backend.js +294 -0
  87. package/src/lineage/source-seeding.js +36 -2
  88. package/src/lineage/summaries.js +66 -4
  89. package/src/lineage/value-versions.js +130 -0
  90. package/src/lineage/witness-selection.js +95 -0
  91. package/src/pipeline/scan-health.js +18 -0
  92. package/src/posture/CLAUDE.md +17 -0
  93. package/src/posture/business-logic.js +16 -7
  94. package/src/posture/dag-commands.js +442 -0
  95. package/src/posture/evidence-bundle.js +1 -1
  96. package/src/posture/finding-defaults.js +19 -0
  97. package/src/posture/git-history.js +91 -1
  98. package/src/posture/witness-bundle.js +573 -0
  99. package/src/posture/witness-runtime.js +276 -0
  100. package/src/report/index.js +2 -0
  101. package/src/sast/CLAUDE.md +2 -0
  102. package/src/sast/_call-args.js +70 -0
  103. package/src/sast/init-scope.js +87 -0
  104. package/src/sast/listen-bind-address.js +49 -0
  105. package/src/sast/redirect-target.js +106 -0
  106. package/src/sast/reflected-input.js +76 -0
  107. package/src/sast/send-file-root.js +27 -0
  108. package/src/sast/sensitive-data-logging.js +38 -1
  109. package/dist/119.index.js +0 -197
  110. package/dist/128.index.js +0 -129
  111. package/dist/1293.index.js +0 -5689
  112. package/dist/1336.index.js +0 -16182
  113. package/dist/1351.index.js +0 -133
  114. package/dist/1387.index.js +0 -271
  115. package/dist/1437.index.js +0 -23
  116. package/dist/1445.index.js +0 -110
  117. package/dist/1505.index.js +0 -151
  118. package/dist/1557.index.js +0 -293
  119. package/dist/1621.index.js +0 -311
  120. package/dist/1751.index.js +0 -160
  121. package/dist/1765.index.js +0 -727
  122. package/dist/1861.index.js +0 -690
  123. package/dist/1906.index.js +0 -433
  124. package/dist/208.index.js +0 -1631
  125. package/dist/2223.index.js +0 -159
  126. package/dist/2238.index.js +0 -295
  127. package/dist/2530.index.js +0 -152
  128. package/dist/2565.index.js +0 -116
  129. package/dist/2574.index.js +0 -127
  130. package/dist/2620.index.js +0 -345
  131. package/dist/2773.index.js +0 -98
  132. package/dist/2775.index.js +0 -32
  133. package/dist/2966.index.js +0 -171
  134. package/dist/2972.index.js +0 -87
  135. package/dist/2991.index.js +0 -131
  136. package/dist/3063.index.js +0 -833
  137. package/dist/3075.index.js +0 -227
  138. package/dist/3121.index.js +0 -297
  139. package/dist/3314.index.js +0 -263
  140. package/dist/3480.index.js +0 -1822
  141. package/dist/3481.index.js +0 -24
  142. package/dist/3506.index.js +0 -268
  143. package/dist/3533.index.js +0 -591
  144. package/dist/3653.index.js +0 -450
  145. package/dist/3740.index.js +0 -722
  146. package/dist/3822.index.js +0 -131
  147. package/dist/3868.index.js +0 -292
  148. package/dist/3918.index.js +0 -192
  149. package/dist/4090.index.js +0 -288
  150. package/dist/4113.index.js +0 -1286
  151. package/dist/4121.index.js +0 -1190
  152. package/dist/4142.index.js +0 -690
  153. package/dist/4168.index.js +0 -509
  154. package/dist/4281.index.js +0 -993
  155. package/dist/4336.index.js +0 -436
  156. package/dist/4387.index.js +0 -532
  157. package/dist/4413.index.js +0 -302
  158. package/dist/4476.index.js +0 -4510
  159. package/dist/4491.index.js +0 -133
  160. package/dist/4538.index.js +0 -185
  161. package/dist/4684.index.js +0 -319
  162. package/dist/4740.index.js +0 -165
  163. package/dist/4780.index.js +0 -209
  164. package/dist/4802.index.js +0 -225
  165. package/dist/4812.index.js +0 -379
  166. package/dist/4835.index.js +0 -126
  167. package/dist/494.index.js +0 -226
  168. package/dist/4991.index.js +0 -549
  169. package/dist/5199.index.js +0 -305
  170. package/dist/5216.index.js +0 -109
  171. package/dist/5439.index.js +0 -301
  172. package/dist/5443.index.js +0 -97
  173. package/dist/5496.index.js +0 -377
  174. package/dist/5738.index.js +0 -240
  175. package/dist/5801.index.js +0 -588
  176. package/dist/5817.index.js +0 -714
  177. package/dist/5858.index.js +0 -223
  178. package/dist/5933.index.js +0 -143
  179. package/dist/601.index.js +0 -23
  180. package/dist/6173.index.js +0 -273
  181. package/dist/6291.index.js +0 -1871
  182. package/dist/6312.index.js +0 -666
  183. package/dist/634.index.js +0 -16182
  184. package/dist/644.index.js +0 -87
  185. package/dist/6508.index.js +0 -283
  186. package/dist/6560.index.js +0 -645
  187. package/dist/6576.index.js +0 -422
  188. package/dist/6579.index.js +0 -271
  189. package/dist/6655.index.js +0 -3221
  190. package/dist/6745.index.js +0 -305
  191. package/dist/6818.index.js +0 -21642
  192. package/dist/6932.index.js +0 -1190
  193. package/dist/6972.index.js +0 -167
  194. package/dist/6995.index.js +0 -163
  195. package/dist/7249.index.js +0 -191
  196. package/dist/7372.index.js +0 -1197
  197. package/dist/7546.index.js +0 -541
  198. package/dist/76.index.js +0 -448
  199. package/dist/7879.index.js +0 -163
  200. package/dist/8025.index.js +0 -130
  201. package/dist/8054.index.js +0 -248
  202. package/dist/808.index.js +0 -1330
  203. package/dist/8097.index.js +0 -201
  204. package/dist/8126.index.js +0 -191
  205. package/dist/8178.index.js +0 -78
  206. package/dist/8382.index.js +0 -93
  207. package/dist/8408.index.js +0 -261
  208. package/dist/8540.index.js +0 -174
  209. package/dist/8635.index.js +0 -802
  210. package/dist/8688.index.js +0 -410
  211. package/dist/8699.index.js +0 -779
  212. package/dist/8891.index.js +0 -457
  213. package/dist/8916.index.js +0 -885
  214. package/dist/9146.index.js +0 -170
  215. package/dist/9168.index.js +0 -841
  216. package/dist/9292.index.js +0 -369
  217. package/dist/9422.index.js +0 -866
  218. package/dist/9440.index.js +0 -453
  219. package/dist/9479.index.js +0 -152
  220. package/dist/9481.index.js +0 -220
  221. package/dist/9539.index.js +0 -100
  222. package/dist/9547.index.js +0 -103
  223. package/dist/9692.index.js +0 -90
  224. package/dist/9800.index.js +0 -462
  225. package/dist/9899.index.js +0 -138
  226. package/dist/9981.index.js +0 -206
  227. package/dist/999.index.js +0 -117
  228. package/dist/9999.index.js +0 -1698
@@ -0,0 +1,1168 @@
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 (an instance field of a class it
18
+ // cannot name, such as a constructor function or a non-JavaScript receiver;
19
+ // an exhausted depth or work budget; an unresolved higher-order shape) never
20
+ // silently drops a function. An instance field of a named JavaScript or
21
+ // TypeScript class IS followed: the class's methods and the receiver at
22
+ // their call sites are selected. Default policy expands to the full function set
23
+ // (`onLimit` / `onUnsupported` = 'full'); the 'partial' policy instead
24
+ // reports partial scope with negativeClaimAllowed false.
25
+ // 3. A limited reverse walk never authorizes a negative verdict. The verdict
26
+ // vocabulary has no "safe": `flow-found`, `no-flow-in-modeled-scope`
27
+ // (only when the completion envelope allows a negative claim) and
28
+ // `unknown`.
29
+ //
30
+ // Opt-in: AGENTIC_SECURITY_DEMAND=1 (see demandEnabled) plus the API below. The
31
+ // default scan never calls this module.
32
+
33
+ import * as crypto from 'node:crypto';
34
+ import { accessPathOf } from './access-paths.js';
35
+ import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, _catalogSize } from './catalog.js';
36
+ import { lookupBuiltinSummary } from './builtin-summaries.js';
37
+ import { runTaintEngine } from './engine.js';
38
+ import { buildCallerQidIndex, reachabilitySliceWithCompletion } from './tabulation.js';
39
+ import { makeCompletion } from '../lineage/completion.js';
40
+
41
+ const DEMAND_SCHEMA_VERSION = '1.0.0';
42
+ const DEMAND_CONTEXTS = Object.freeze(['whole-program']);
43
+ const DEFAULT_MAX_CALLER_DEPTH = 64;
44
+ const DEFAULT_MAX_WORK = 50000;
45
+ const MAX_PATH_SEGMENTS = 6;
46
+ // Bounds on the class-scoped expansion; past them the construct is reported
47
+ // unsupported and the query takes the conservative full path.
48
+ const MAX_CLASS_MEMBERS = 2000;
49
+ const MAX_ENCLOSING_CANDIDATES = 32;
50
+ const MAX_CLOSURE_HOPS = 6;
51
+
52
+ /** Demand mode is opt-in: this env flag AND an explicit API/query list. */
53
+ export function demandEnabled(env = process.env) {
54
+ return env.AGENTIC_SECURITY_DEMAND === '1';
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // R02.1 query contract
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Normalize a sink query. Keys: exact sink site (file + line, optionally
63
+ * nodeId / callee / sinkId to narrow), argument index, field/access path within
64
+ * the sink argument, analysis context, rule family (CWE id or family name), and
65
+ * the graph/configuration digests the answer is valid for.
66
+ */
67
+ export function normalizeSinkQuery(q = {}) {
68
+ const num = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
69
+ const str = (v) => (typeof v === 'string' && v.length ? v : null);
70
+ return {
71
+ file: str(q.file),
72
+ line: num(q.line),
73
+ nodeId: str(q.nodeId),
74
+ fnQid: str(q.fnQid),
75
+ callee: str(q.callee),
76
+ sinkId: str(q.sinkId),
77
+ argIndex: num(q.argIndex),
78
+ accessPath: str(q.accessPath),
79
+ context: str(q.context) || 'whole-program',
80
+ ruleFamily: str(q.ruleFamily),
81
+ graphDigest: str(q.graphDigest),
82
+ configDigest: str(q.configDigest),
83
+ };
84
+ }
85
+
86
+ const _digestCache = new WeakMap();
87
+
88
+ /** Stable digest of the call graph content the query was answered over. */
89
+ export function graphDigest(callGraph) {
90
+ if (!callGraph || !callGraph.functions) return null;
91
+ if (_digestCache.has(callGraph)) return _digestCache.get(callGraph);
92
+ const h = crypto.createHash('sha256');
93
+ for (const qid of [...callGraph.functions.keys()].sort()) {
94
+ const fn = callGraph.functions.get(qid);
95
+ h.update(qid); h.update('\0');
96
+ h.update(JSON.stringify(fn.params || [])); h.update('\0');
97
+ h.update(JSON.stringify(fn.cfg ? fn.cfg.nodes : null)); h.update('\n');
98
+ }
99
+ const d = h.digest('hex').slice(0, 32);
100
+ _digestCache.set(callGraph, d);
101
+ return d;
102
+ }
103
+
104
+ /** Digest of everything besides the graph that can change an answer. */
105
+ export function configDigest(opts = {}) {
106
+ const h = crypto.createHash('sha256');
107
+ h.update(JSON.stringify({
108
+ catalog: _catalogSize(),
109
+ kcfa: process.env.AGENTIC_SECURITY_KCFA_MAX_CONTEXTS || null,
110
+ callstring: process.env.AGENTIC_SECURITY_KCFA_CALLSTRING || null,
111
+ fnLimit: opts.fnLimit || null,
112
+ maxCallerDepth: opts.maxCallerDepth || DEFAULT_MAX_CALLER_DEPTH,
113
+ maxWork: opts.maxWork || DEFAULT_MAX_WORK,
114
+ onLimit: opts.onLimit || 'full',
115
+ onUnsupported: opts.onUnsupported || 'full',
116
+ }));
117
+ return h.digest('hex').slice(0, 16);
118
+ }
119
+
120
+ /** Stable key of a normalized query bound to its digests. */
121
+ export function sinkQueryKey(query, digests = {}) {
122
+ const q = normalizeSinkQuery(query);
123
+ q.graphDigest = digests.graphDigest ?? q.graphDigest;
124
+ q.configDigest = digests.configDigest ?? q.configDigest;
125
+ return crypto.createHash('sha256').update(JSON.stringify(q)).digest('hex').slice(0, 24);
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Expression helpers
130
+ // ---------------------------------------------------------------------------
131
+
132
+ const SELF_ROOTS = new Set(['this', '_this_', 'self', 'cls']);
133
+ const JS_FILE = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
134
+ // In JavaScript and TypeScript the receiver is `this` (lowered to `_this_`);
135
+ // `self` and `cls` are ordinary names there (a local, a parameter, the global
136
+ // `self`, or an alias made by `const self = this`, which the assignment rule
137
+ // follows back to `_this_`). In the other languages they are the receiver.
138
+ function isSelfRoot(root, file) {
139
+ if (root === 'this' || root === '_this_') return true;
140
+ return SELF_ROOTS.has(root) && !JS_FILE.test(file || '');
141
+ }
142
+ const REQ_PARAM = /^(?:req|request|ctx)$/;
143
+ // Roots that are language builtins or ambient globals, so a call on them is
144
+ // modeled by the engine's own summaries rather than being an unresolved callee.
145
+ 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']);
146
+
147
+ const HIGHER_ORDER_METHODS = new Set(['map', 'forEach', 'filter', 'flatMap', 'find', 'findIndex', 'some', 'every', 'reduce', 'reduceRight', 'sort', 'then', 'catch', 'finally', 'all', 'allSettled', 'race', 'any']);
148
+
149
+ function isExpr(v) { return v && typeof v === 'object' && typeof v.kind === 'string'; }
150
+
151
+ function visitVal(v, f) {
152
+ if (!v || typeof v !== 'object') return;
153
+ if (isExpr(v)) { f(v); return; }
154
+ if (Array.isArray(v)) { for (const x of v) visitVal(x, f); return; }
155
+ for (const x of Object.values(v)) visitVal(x, f);
156
+ }
157
+
158
+ function forEachChild(e, f) {
159
+ for (const k of Object.keys(e)) {
160
+ if (k === 'kind') continue;
161
+ visitVal(e[k], f);
162
+ }
163
+ }
164
+
165
+ const rootOf = (p) => { const i = p.indexOf('.'); return i < 0 ? p : p.slice(0, i); };
166
+
167
+ /** Segment-wise overlap; a `*` segment (computed key) matches any segment. */
168
+ const _segs = new Map();
169
+ 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; }
170
+ function segOverlap(a, b) {
171
+ if (a === b) return true;
172
+ const x = segsOf(a); const y = segsOf(b);
173
+ const n = Math.min(x.length, y.length);
174
+ for (let i = 0; i < n; i++) if (x[i] !== y[i] && x[i] !== '*' && y[i] !== '*') return false;
175
+ return true;
176
+ }
177
+
178
+ function exprOfPath(path) {
179
+ const segs = path.split('.');
180
+ let e = { kind: 'ident', name: segs[0] };
181
+ for (let i = 1; i < segs.length; i++) e = { kind: 'member', object: e, prop: segs[i] };
182
+ return e;
183
+ }
184
+
185
+ function calleeName(callee) {
186
+ if (typeof callee === 'string') return callee;
187
+ if (!callee) return null;
188
+ if (callee.kind === 'ident') return callee.name;
189
+ if (callee.kind === 'member') return accessPathOf(callee);
190
+ return null;
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Function facts and the extended incoming-call index (R02.2)
195
+ // ---------------------------------------------------------------------------
196
+
197
+ const _infoCache = new WeakMap();
198
+
199
+ function fnInfo(fn) {
200
+ if (_infoCache.has(fn)) return _infoCache.get(fn);
201
+ 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 };
202
+ const walk = (e, nid, line) => {
203
+ if (!e || typeof e !== 'object') return;
204
+ if (e.kind === 'function-value') { if (e.qid) info.fnValues.add(e.qid); return; }
205
+ if (e.kind === 'call') info.calls.push({ expr: e, nid, line: e.line || line });
206
+ if (isExpr(e)) forEachChild(e, (c) => walk(c, nid, line));
207
+ };
208
+ const nodes = (fn.cfg && fn.cfg.nodes) || {};
209
+ for (const nid of Object.keys(nodes)) {
210
+ const n = nodes[nid];
211
+ if (!n) continue;
212
+ if (n.kind === 'assign') {
213
+ if (typeof n.target === 'string') { info.assigns.push({ nid, target: n.target, source: n.source, line: n.line }); info.assignedRoots.add(rootOf(n.target)); }
214
+ else { info.assigns.push({ nid, target: null, source: n.source, line: n.line, opaque: true }); info.hasOpaqueAssign = true; }
215
+ walk(n.source, nid, n.line);
216
+ } else if (n.kind === 'call') {
217
+ info.calls.push({ expr: n, nid, line: n.line, top: true });
218
+ for (const a of (n.args || [])) walk(a, nid, n.line);
219
+ if (n.callee && typeof n.callee === 'object') walk(n.callee, nid, n.line);
220
+ } else if (n.kind === 'return') {
221
+ if (n.value) { info.returns.push(n.value); walk(n.value, nid, n.line); }
222
+ } else {
223
+ for (const k of Object.keys(n)) {
224
+ if (['id', 'kind', 'succ', 'pred', 'line'].includes(k)) continue;
225
+ visitVal(n[k], (c) => walk(c, nid, n.line));
226
+ }
227
+ }
228
+ }
229
+ _infoCache.set(fn, info);
230
+ return info;
231
+ }
232
+
233
+ // Access paths a call can write through (its receiver and its arguments).
234
+ const _touchCache = new WeakMap();
235
+ function touchedPaths(call) {
236
+ let paths = _touchCache.get(call);
237
+ if (paths) return paths;
238
+ paths = [];
239
+ _touchCache.set(call, paths);
240
+ const collect = (e) => {
241
+ if (!e || typeof e !== 'object') return;
242
+ if (e.kind === 'function-value') return;
243
+ const ap = accessPathOf(e);
244
+ if (ap) { paths.push(ap); return; }
245
+ if (isExpr(e)) forEachChild(e, collect);
246
+ };
247
+ const callee = call.callee;
248
+ if (callee && typeof callee === 'object' && callee.kind === 'member') collect(callee.object);
249
+ else if (typeof callee === 'string' && callee.includes('.')) paths.push(callee.slice(0, callee.lastIndexOf('.')));
250
+ for (const a of (call.args || [])) collect(a);
251
+ return paths;
252
+ }
253
+
254
+ // Per-function views by root segment. Two access paths can overlap only when
255
+ // their first segments agree or one of them is the `*` wildcard, so a lookup
256
+ // for a path rooted at `r` need only visit the assignments and calls rooted at
257
+ // `r` (plus wildcard roots and, for assignments, the opaque ones). The views
258
+ // keep the function's own order, so the walk visits them exactly as a full scan
259
+ // would and only skips what cannot overlap.
260
+ function mergeSorted(lists) {
261
+ const out = [];
262
+ for (const l of lists) for (const i of l) out.push(i);
263
+ return out.sort((a, b) => a - b);
264
+ }
265
+
266
+ function assignsFor(info, root) {
267
+ let b = info.assignBuckets;
268
+ if (!b) {
269
+ b = { byRoot: new Map(), wild: [], opaque: [], views: new Map() };
270
+ info.assigns.forEach((a, i) => {
271
+ if (a.opaque) { b.opaque.push(i); return; }
272
+ const r = rootOf(a.target);
273
+ if (r === '*') { b.wild.push(i); return; }
274
+ if (!b.byRoot.has(r)) b.byRoot.set(r, []);
275
+ b.byRoot.get(r).push(i);
276
+ });
277
+ info.assignBuckets = b;
278
+ }
279
+ if (root === '*') return info.assigns;
280
+ let v = b.views.get(root);
281
+ if (!v) {
282
+ v = mergeSorted([b.opaque, b.wild, b.byRoot.get(root) || []]).map((i) => info.assigns[i]);
283
+ b.views.set(root, v);
284
+ }
285
+ return v;
286
+ }
287
+
288
+ function callsFor(info, root) {
289
+ let b = info.callBuckets;
290
+ if (!b) {
291
+ b = { byRoot: new Map(), wild: [], any: [], views: new Map() };
292
+ info.calls.forEach((occ, i) => {
293
+ const paths = touchedPaths(occ.expr);
294
+ if (!paths.length) return;
295
+ b.any.push(i);
296
+ const roots = new Set(paths.map(rootOf));
297
+ if (roots.has('*')) { b.wild.push(i); return; }
298
+ for (const r of roots) { if (!b.byRoot.has(r)) b.byRoot.set(r, []); b.byRoot.get(r).push(i); }
299
+ });
300
+ info.callBuckets = b;
301
+ }
302
+ let v = b.views.get(root);
303
+ if (!v) {
304
+ v = (root === '*' ? b.any : mergeSorted([b.wild, b.byRoot.get(root) || []])).map((i) => info.calls[i]);
305
+ b.views.set(root, v);
306
+ }
307
+ return v;
308
+ }
309
+
310
+ const _indexCache = new WeakMap();
311
+
312
+ /**
313
+ * Incoming-call index over the WHOLE call graph: callee qid -> sites, each
314
+ * {caller, nid, call, kind: 'call'|'callback'|'callback-ref'}. Starts from the
315
+ * canonical buildCallerQidIndex (statement-level calls) and adds embedded call
316
+ * sites that `fn.calls` omits, plus callback registrations. Also indexes
317
+ * functions by bare name for method-name candidate expansion.
318
+ */
319
+ export function buildIncomingIndex(callGraph) {
320
+ if (_indexCache.has(callGraph)) return _indexCache.get(callGraph);
321
+ const incoming = new Map();
322
+ const byTail = new Map();
323
+ const modules = new Map(); const byFile = new Map();
324
+ const byScope = new Map(); // enclosing-scope segment of the qid -> member qids (class methods share their class name)
325
+ const namesInFile = new Map(); // file -> Map(function name -> [fn])
326
+ const add = (qid, site) => { if (!incoming.has(qid)) incoming.set(qid, []); incoming.get(qid).push(site); };
327
+ // Name resolution over a fixed graph is a pure function of (name, file), and
328
+ // most call sites repeat a handful of names per file (`push`, `has`, `get`),
329
+ // each of which would otherwise scan every file's name table on a miss.
330
+ const memo = new Map();
331
+ const rk = callGraph.resolveKnownCallee;
332
+ const resolveName = (name, file) => {
333
+ if (!name || !rk) return null;
334
+ let m = memo.get(file);
335
+ if (!m) { m = new Map(); memo.set(file, m); }
336
+ let v = m.get(name);
337
+ if (v === undefined) { v = rk(name, file) || null; m.set(name, v); }
338
+ return v;
339
+ };
340
+ for (const fn of callGraph.functions.values()) {
341
+ const tail = String(fn.name || '').split('.').pop();
342
+ if (tail) { if (!byTail.has(tail)) byTail.set(tail, []); byTail.get(tail).push(fn.qid); }
343
+ if (!byFile.has(fn.file)) byFile.set(fn.file, []); byFile.get(fn.file).push(fn);
344
+ 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); }
345
+ const parts = qidParts(fn.qid);
346
+ if (parts) { if (!byScope.has(parts.scope)) byScope.set(parts.scope, []); byScope.get(parts.scope).push(fn.qid); }
347
+ let nf = namesInFile.get(fn.file);
348
+ if (!nf) { nf = new Map(); namesInFile.set(fn.file, nf); }
349
+ if (!nf.has(fn.name)) nf.set(fn.name, []);
350
+ nf.get(fn.name).push(fn);
351
+ }
352
+ for (const fn of callGraph.functions.values()) {
353
+ const info = fnInfo(fn);
354
+ for (const occ of info.calls) {
355
+ const name = calleeName(occ.expr.callee);
356
+ const g = resolveName(name, fn.file);
357
+ if (g) add(g, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'call' });
358
+ for (const a of (occ.expr.args || [])) {
359
+ if (a && a.kind === 'function-value' && a.qid) add(a.qid, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'callback' });
360
+ else if (a && a.kind === 'ident') {
361
+ const r = resolveName(a.name, fn.file);
362
+ if (r && r !== fn.qid) add(r, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'callback-ref' });
363
+ }
364
+ }
365
+ }
366
+ }
367
+ const idx = { incoming, byTail, modules, byFile, byScope, namesInFile, definers: new Map(), resolve: resolveName };
368
+ // The canonical statement-level edges, kept for parity checks. Built on first
369
+ // use: nothing on the query path reads them, and building them costs a second
370
+ // full pass of name resolution.
371
+ let base = null;
372
+ Object.defineProperty(idx, 'base', { enumerable: true, get() { if (!base) base = buildCallerQidIndex(callGraph); return base; } });
373
+ _indexCache.set(callGraph, idx);
374
+ return idx;
375
+ }
376
+
377
+ /** `file::scope::name@line` -> { scope, name }; null for a qid of another shape. */
378
+ function qidParts(qid) {
379
+ const j = qid.lastIndexOf('::');
380
+ if (j < 0) return null;
381
+ const i = qid.lastIndexOf('::', j - 1);
382
+ if (i < 0) return null;
383
+ return { scope: qid.slice(i + 2, j), name: qid.slice(j + 2) };
384
+ }
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Sink-site discovery (nested sinks in assignment and return expressions)
388
+ // ---------------------------------------------------------------------------
389
+
390
+ function matchesFamily(entry, family) {
391
+ if (!family) return true;
392
+ const v = entry.vuln || {};
393
+ return v.cwe === family || entry.id === family || (v.name && v.name === family) || (entry.family && entry.family === family);
394
+ }
395
+
396
+ /** Enumerate sink sites in the graph that satisfy the query's site keys. */
397
+ export function findSinkSites(callGraph, query) {
398
+ const q = normalizeSinkQuery(query);
399
+ const sites = [];
400
+ for (const fn of callGraph.functions.values()) {
401
+ if (q.file && fn.file !== q.file) continue;
402
+ if (q.fnQid && fn.qid !== q.fnQid) continue;
403
+ const info = fnInfo(fn);
404
+ for (const occ of info.calls) {
405
+ if (q.line != null && occ.line !== q.line) continue;
406
+ if (q.nodeId && occ.nid !== q.nodeId) continue;
407
+ const hits = matchSinkOrSanitizer(occ.expr.callee, fn.file) || [];
408
+ for (const e of hits) {
409
+ if (e.kind !== 'sink' || !matchesFamily(e, q.ruleFamily)) continue;
410
+ if (q.sinkId && e.id !== q.sinkId) continue;
411
+ if (q.callee && calleeName(occ.expr.callee) !== q.callee) continue;
412
+ 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' });
413
+ }
414
+ }
415
+ for (const a of info.assigns) {
416
+ if (!a.target || (q.line != null && a.line !== q.line)) continue;
417
+ if (q.nodeId && a.nid !== q.nodeId) continue;
418
+ const hits = matchMemberWriteSink(a.target, fn.file) || [];
419
+ for (const e of hits) {
420
+ if (!matchesFamily(e, q.ruleFamily) || (q.sinkId && e.id !== q.sinkId)) continue;
421
+ if (q.callee && a.target !== q.callee) continue;
422
+ 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' });
423
+ }
424
+ }
425
+ }
426
+ // The IR can carry one call both as a statement node and as the value of a
427
+ // return node; both name the same source site.
428
+ const seenSite = new Set();
429
+ for (let i = sites.length - 1; i >= 0; i--) {
430
+ const s = sites[i];
431
+ const k = `${s.fnQid}|${s.line}|${s.sinkId}|${s.callee}`;
432
+ if (seenSite.has(k)) sites.splice(i, 1); else seenSite.add(k);
433
+ }
434
+ 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));
435
+ return sites;
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // R02.2 / R02.4: the backward demand frontier
440
+ // ---------------------------------------------------------------------------
441
+
442
+ class Frontier {
443
+ constructor(callGraph, opts) {
444
+ this.cg = callGraph;
445
+ this.idx = buildIncomingIndex(callGraph);
446
+ this.maxDepth = opts.maxCallerDepth ?? DEFAULT_MAX_CALLER_DEPTH;
447
+ this.maxWork = opts.maxWork ?? DEFAULT_MAX_WORK;
448
+ this.hasCha = !!(opts.engineOpts && opts.engineOpts._cha);
449
+ this.region = new Map(); // qid -> Set(reason)
450
+ this.work = [];
451
+ this.seen = new Set();
452
+ this.cbDone = new Set();
453
+ this.reqDone = new Set();
454
+ this.steps = 0;
455
+ this.maxDepthReached = 0;
456
+ this.limits = []; // {kind, count}
457
+ this.unsupported = []; // {kind, qid, detail}
458
+ this.unresolved = new Map(); // name@qid -> record
459
+ this.terminals = new Set(); // 'source' | 'param' | 'call' | 'free' | 'callback'
460
+ this.followed = { callers: 0, producers: 0, callbacks: 0, mutators: 0, siblings: 0 };
461
+ this.depthCut = 0;
462
+ this.workLeft = 0;
463
+ this.sinkCalls = new Set();
464
+ this.expanded = new Set();
465
+ this.mateCache = new Map();
466
+ this.holdersDone = new Set();
467
+ this.classDone = new Set();
468
+ }
469
+
470
+ addRegion(qid, reason) {
471
+ if (!this.cg.functions.has(qid)) return;
472
+ if (!this.region.has(qid)) this.region.set(qid, new Set());
473
+ this.region.get(qid).add(reason);
474
+ }
475
+
476
+ push(qid, path, ep, depth, reason) {
477
+ // recursive field growth (`x = x.next` in a loop) would otherwise generate
478
+ // an unbounded family of paths; fold to a capped prefix, which selects
479
+ // more, never less
480
+ if (path.length > 24 && path.charCodeAt(0) !== 36) {
481
+ const segs = segsOf(path);
482
+ if (segs.length > MAX_PATH_SEGMENTS) path = segs.slice(0, MAX_PATH_SEGMENTS).join('.');
483
+ }
484
+ const k = `${qid}|${path}|${ep ? 1 : 0}`;
485
+ if (this.seen.has(k)) return;
486
+ this.seen.add(k);
487
+ this.work.push({ qid, path, ep, depth, reason });
488
+ if (depth > this.maxDepthReached) this.maxDepthReached = depth;
489
+ }
490
+
491
+ run() {
492
+ while (this.work.length) {
493
+ if (++this.steps > this.maxWork) {
494
+ this.limits.push({ kind: 'work-limit', count: this.work.length });
495
+ this.workLeft = this.work.length;
496
+ this.work = [];
497
+ break;
498
+ }
499
+ const item = this.work.pop();
500
+ this.process(item);
501
+ }
502
+ }
503
+
504
+ markUnsupported(kind, qid, detail) {
505
+ if (!this.unsupported.some((u) => u.kind === kind && u.qid === qid)) this.unsupported.push({ kind, qid, detail });
506
+ }
507
+
508
+ /** Walk an expression in `fn` and register every path/call it depends on. */
509
+ reads(fn, expr, item) {
510
+ const rd = (e) => {
511
+ if (!e || typeof e !== 'object') return;
512
+ if (e.kind === 'literal') return;
513
+ if (e.kind === 'function-value') { this.callbackValue(e.qid, item.depth); return; }
514
+ const p = accessPathOf(e);
515
+ if (p) { this.push(fn.qid, p, item.ep, item.depth, item.reason); this.checkSourceExpr(fn, e, p); return; }
516
+ if (e.kind === 'call') { this.callExpr(fn, e, item); return; }
517
+ if (matchSource(e, fn.file)) this.terminals.add('source');
518
+ forEachChild(e, rd);
519
+ };
520
+ rd(expr);
521
+ }
522
+
523
+ callbackValue(qid, depth) {
524
+ if (!qid || !this.cg.functions.has(qid)) return;
525
+ this.followed.callbacks += 1;
526
+ this.addRegion(qid, 'callback');
527
+ this.push(qid, '$ret', false, depth, 'callback');
528
+ this.push(qid, '$params', false, depth, 'callback');
529
+ }
530
+
531
+ callExpr(fn, e, item) {
532
+ if (matchSource(e, fn.file)) this.terminals.add('source');
533
+ const callee = e.callee;
534
+ // receiver: taint on the receiver flows through the call (`tainted.trim()`)
535
+ if (callee && typeof callee === 'object' && callee.kind === 'member') {
536
+ const rp = accessPathOf(callee.object);
537
+ if (rp) this.push(fn.qid, rp, item.ep, item.depth, item.reason);
538
+ else this.reads(fn, callee.object, item);
539
+ } else if (typeof callee === 'string' && callee.includes('.')) {
540
+ this.push(fn.qid, callee.slice(0, callee.lastIndexOf('.')), item.ep, item.depth, item.reason);
541
+ }
542
+ for (const a of (e.args || [])) this.reads(fn, a, item);
543
+ const name = calleeName(callee);
544
+ const g = this.idx.resolve(name, fn.file);
545
+ if (g) {
546
+ this.followed.producers += 1;
547
+ this.addRegion(g, 'producer');
548
+ this.push(g, '$ret', false, item.depth, 'producer');
549
+ this.terminals.add('call');
550
+ return;
551
+ }
552
+ // member call on an unresolved receiver: the engine may resolve it through
553
+ // class-hierarchy analysis, so every project function with that bare name
554
+ // is a candidate producer (conservative expansion, never a drop).
555
+ const tail = name ? name.split('.').pop() : null;
556
+ // Only the engine's class-hierarchy resolution (opts._cha) can reach such a
557
+ // callee, so without it there is nothing to over-select; with it, only
558
+ // class methods are candidates.
559
+ const cands = tail && this.hasCha && callee && typeof callee === 'object' && callee.kind === 'member'
560
+ ? (this.idx.byTail.get(tail) || []).filter((q) => /::[A-Za-z_$][\w$]*::[\w$]+@/.test(q) && !q.includes('::<module>::'))
561
+ : null;
562
+ if (cands && cands.length) {
563
+ 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'); }
564
+ this.terminals.add('call');
565
+ return;
566
+ }
567
+ const info = fnInfo(fn);
568
+ const prop = callee && typeof callee === 'object' && callee.kind === 'member' && typeof callee.prop === 'string' ? callee.prop : (name ? name.split('.').pop() : null);
569
+ // A call through a parameter is a callback invocation (the registration
570
+ // site is followed by expandCallers); an array or promise combinator is
571
+ // modeled by the engine's higher-order handling.
572
+ const viaParam = callee && typeof callee === 'object' && callee.kind === 'ident' && info.params.includes(callee.name);
573
+ const modeled = viaParam || (prop && HIGHER_ORDER_METHODS.has(prop)) || (callee && matchSinkOrSanitizer(callee, fn.file)) || (name && lookupBuiltinSummary(name)) || (name && BUILTIN_ROOTS.has(rootOf(name)));
574
+ if (!modeled) {
575
+ const key = `${name || '<dynamic>'}@${fn.qid}`;
576
+ if (!this.unresolved.has(key)) this.unresolved.set(key, { callee: name || '<dynamic>', fnQid: fn.qid, line: e.line || null });
577
+ }
578
+ this.terminals.add('call');
579
+ }
580
+
581
+ checkSource(fn, path) {
582
+ const segs = path.split('.');
583
+ for (let i = 1; i <= segs.length; i++) {
584
+ if (matchSource(exprOfPath(segs.slice(0, i).join('.')), fn.file)) { this.terminals.add('source'); return; }
585
+ }
586
+ }
587
+
588
+ checkSourceExpr(fn, e, p) {
589
+ if (matchSource(e, fn.file)) this.terminals.add('source');
590
+ else this.checkSource(fn, p);
591
+ }
592
+
593
+ process(item) {
594
+ const fn = this.cg.functions.get(item.qid);
595
+ if (!fn || !fn.cfg || !fn.cfg.nodes) { this.markUnsupported('missing-function-body', item.qid, item.path); return; }
596
+ this.addRegion(item.qid, item.reason || 'sink');
597
+ const info = fnInfo(fn);
598
+ const p = item.path;
599
+
600
+ if (!this.cbDone.has(fn.qid)) {
601
+ this.cbDone.add(fn.qid);
602
+ // callbacks defined inside a selected function run under its taint
603
+ for (const q of info.fnValues) this.callbackValue(q, item.depth);
604
+ this.requestFieldWriters(fn, info, item);
605
+ }
606
+ if (p === '$ret') { for (const r of info.returns) this.reads(fn, r, item); return; }
607
+ 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; }
608
+
609
+ const root = rootOf(p);
610
+ const selfRooted = isSelfRoot(root, fn.file);
611
+ // An instance field can be written by any method of its class and by whoever
612
+ // holds the receiver; select those (class-scoped expansion), or report the
613
+ // construct unsupported when the class cannot be determined.
614
+ if (selfRooted && !this.expandClass(fn, p, item)) return;
615
+ this.checkSource(fn, p);
616
+
617
+ for (const a of assignsFor(info, root)) {
618
+ if (a.opaque) { this.reads(fn, a.source, item); continue; }
619
+ if (!segOverlap(a.target, p)) continue;
620
+ // target covers the needed path (equal or a prefix): the value of the
621
+ // needed path comes from the matching part of the source. Otherwise the
622
+ // write is to a sub-field of the needed path (a partial write): the whole
623
+ // source matters.
624
+ const tl = a.target.split('.').length; const pl = p.split('.').length;
625
+ if (tl <= pl) this.assignSource(fn, a, p.split('.').slice(tl).join('.'), item);
626
+ else this.reads(fn, a.source, item);
627
+ }
628
+
629
+ for (const occ of callsFor(info, root)) {
630
+ if (!this.callTouches(occ.expr, p)) continue;
631
+ // the queried sink call itself runs after its argument is formed, so it
632
+ // cannot change what the argument holds
633
+ if (this.sinkCalls.size && this.sinkCalls.has(`${fn.qid}|${occ.line}|${calleeName(occ.expr.callee)}`)) continue;
634
+ this.followed.mutators += 1;
635
+ this.reads(fn, occ.expr, item);
636
+ const name = calleeName(occ.expr.callee);
637
+ const g = this.idx.resolve(name, fn.file);
638
+ if (g && g !== fn.qid) { this.addRegion(g, 'mutator'); this.push(g, '$params', false, item.depth, 'mutator'); }
639
+ }
640
+
641
+ const pi = info.params.indexOf(root);
642
+ if (pi >= 0) {
643
+ this.terminals.add('param');
644
+ if (item.ep) this.expandCallers(fn, pi, item);
645
+ } else if (selfRooted) {
646
+ this.terminals.add('field');
647
+ } else if (!info.assignedRoots.has(root) && !info.hasOpaqueAssign && !info.params.includes(root)) {
648
+ this.freeVariable(fn, root, p, item);
649
+ }
650
+ }
651
+
652
+ /**
653
+ * The qids sharing the receiver that `fn`'s `this` names, or null when that
654
+ * cannot be decided from the qid structure. A method's qid scope is its class
655
+ * name, so a class is the set of functions with that scope. A closure's scope
656
+ * is the enclosing function's name, and its `this` may be that function's, so
657
+ * the enclosing function(s) are followed outward. Module-level functions and
658
+ * constructor functions (scope `top`) have no class to name.
659
+ */
660
+ classMates(fn) {
661
+ if (this.mateCache.has(fn.qid)) return this.mateCache.get(fn.qid);
662
+ const out = new Set([fn.qid]);
663
+ const seen = new Set();
664
+ const visit = (f, depth) => {
665
+ if (seen.has(f.qid)) return true;
666
+ seen.add(f.qid);
667
+ const parts = qidParts(f.qid);
668
+ if (!parts || !parts.scope || parts.scope === 'top' || parts.scope === '<module>') return false;
669
+ const named = (this.idx.namesInFile.get(f.file) || new Map()).get(parts.scope);
670
+ if (named && named.length) {
671
+ if (depth >= MAX_CLOSURE_HOPS || named.length > MAX_ENCLOSING_CANDIDATES) return false;
672
+ for (const g of named) { out.add(g.qid); if (!visit(g, depth + 1)) return false; }
673
+ return true;
674
+ }
675
+ const mates = this.idx.byScope.get(parts.scope) || [];
676
+ if (mates.length > MAX_CLASS_MEMBERS) return false;
677
+ for (const q of mates) out.add(q);
678
+ return true;
679
+ };
680
+ const ok = JS_FILE.test(fn.file || '') && visit(fn, 0);
681
+ // A class is named by its scope; a closure's receiver set is its own union.
682
+ const direct = ok && seen.size === 1 ? qidParts(fn.qid).scope : null;
683
+ out.key = direct != null ? `class:${direct}` : `fn:${fn.qid}`;
684
+ const res = ok ? out : null;
685
+ this.mateCache.set(fn.qid, res);
686
+ return res;
687
+ }
688
+
689
+ /**
690
+ * Select what can supply the value of a `this`-rooted path read in `fn`: every
691
+ * method of the class (each may write the field; they share one receiver, as the
692
+ * engine's class-field passes assume), and the receiver expression at every call
693
+ * site of those methods (the caller may have set the field before the call).
694
+ * Parameters written into fields are followed by the ordinary caller expansion.
695
+ */
696
+ expandClass(fn, p, item) {
697
+ const mates = this.classMates(fn);
698
+ if (!mates) { this.markUnsupported('instance-field', fn.qid, p); return false; }
699
+ // one expansion per (class, path): a sibling reaching here for the same path
700
+ // would only repeat what the function that selected it already did
701
+ const done = `${mates.key}|${p}`;
702
+ if (this.classDone.has(done)) return true;
703
+ this.classDone.add(done);
704
+ const dot = p.indexOf('.');
705
+ const suffix = dot < 0 ? '' : p.slice(dot + 1);
706
+ for (const q of mates) {
707
+ // A sibling's parameters reach the field through its call sites, which
708
+ // the receiver tracking below selects (the call that stores the value is
709
+ // a mutator of the receiver path), so no separate caller expansion.
710
+ if (q !== fn.qid) { this.addRegion(q, 'class-sibling'); this.push(q, p, false, item.depth, 'class-sibling'); }
711
+ this.receiverHolders(q, suffix, item);
712
+ }
713
+ this.followed.siblings += mates.size - 1;
714
+ return true;
715
+ }
716
+
717
+ /**
718
+ * At every call site of `qid`, the receiver's `suffix` field is what the callee
719
+ * sees as `this.<suffix>`. Following that one path (not the whole receiver)
720
+ * keeps the walk to the values that can actually reach the field.
721
+ */
722
+ receiverHolders(qid, suffix, item) {
723
+ const hk = `${qid}|${suffix}`;
724
+ if (this.holdersDone.has(hk)) return;
725
+ this.holdersDone.add(hk);
726
+ const sites = this.idx.incoming.get(qid) || [];
727
+ if (!sites.length) return;
728
+ if (item.depth + 1 > this.maxDepth) { this.depthCut += sites.length; this.limits.push({ kind: 'depth-limit', count: sites.length }); return; }
729
+ const withSuffix = (base) => (suffix ? `${base}.${suffix}` : base);
730
+ for (const s of sites) {
731
+ const caller = this.cg.functions.get(s.caller);
732
+ if (!caller) continue;
733
+ this.followed.callers += 1;
734
+ this.addRegion(s.caller, s.kind === 'call' ? 'caller' : 'callback-registrar');
735
+ const ni = { ...item, qid: s.caller, depth: item.depth + 1, ep: true, reason: 'caller' };
736
+ const callee = s.call.callee;
737
+ let recvPath = null;
738
+ if (callee && typeof callee === 'object' && callee.kind === 'member') {
739
+ recvPath = accessPathOf(callee.object);
740
+ if (!recvPath) this.reads(caller, callee.object, ni);
741
+ } else if (typeof callee === 'string' && callee.includes('.')) recvPath = callee.slice(0, callee.lastIndexOf('.'));
742
+ if (recvPath) this.push(s.caller, withSuffix(recvPath), true, ni.depth, 'caller');
743
+ }
744
+ }
745
+
746
+ assignSource(fn, a, suffix, item) {
747
+ const src = a.source;
748
+ if (!src) return;
749
+ if (src.kind === 'object' && Array.isArray(src.props)) {
750
+ if (!suffix) { for (const pr of src.props) this.reads(fn, pr.value, item); return; }
751
+ const segs = suffix.split('.');
752
+ const key = segs[0];
753
+ let found = false;
754
+ for (const pr of src.props) {
755
+ if (pr.key === key || pr.key === '*' || key === '*') {
756
+ found = true;
757
+ const rest = segs.slice(1).join('.');
758
+ const vp = accessPathOf(pr.value);
759
+ if (rest && vp) this.push(fn.qid, `${vp}.${rest}`, item.ep, item.depth, item.reason);
760
+ else this.reads(fn, pr.value, item);
761
+ }
762
+ }
763
+ // a spread entry (`...other`) can supply any key
764
+ if (!found || src.props.some((pr) => pr.key === '...' || pr.spread)) this.reads(fn, src, item);
765
+ return;
766
+ }
767
+ const sp = accessPathOf(src);
768
+ if (sp) { this.push(fn.qid, suffix ? `${sp}.${suffix}` : sp, item.ep, item.depth, item.reason); this.checkSource(fn, sp); return; }
769
+ this.reads(fn, src, item);
770
+ }
771
+
772
+ callTouches(call, p) {
773
+ return touchedPaths(call).some((q) => segOverlap(q, p));
774
+ }
775
+
776
+ expandCallers(fn, paramIdx, item) {
777
+ // the caller-side argument is read whole, so one expansion per parameter
778
+ // covers every access path rooted at it
779
+ const ek = `${fn.qid}|${paramIdx}`;
780
+ if (this.expanded.has(ek)) return;
781
+ this.expanded.add(ek);
782
+ const sites = this.idx.incoming.get(fn.qid) || [];
783
+ if (!sites.length) return;
784
+ if (item.depth + 1 > this.maxDepth) { this.depthCut += sites.length; this.limits.push({ kind: 'depth-limit', count: sites.length }); return; }
785
+ for (const s of sites) {
786
+ const caller = this.cg.functions.get(s.caller);
787
+ if (!caller) continue;
788
+ this.followed.callers += 1;
789
+ this.addRegion(s.caller, s.kind === 'call' ? 'caller' : 'callback-registrar');
790
+ const ni = { ...item, qid: s.caller, depth: item.depth + 1, ep: true, reason: 'caller' };
791
+ if (s.kind === 'call') {
792
+ const arg = (s.call.args || [])[paramIdx];
793
+ if (arg) this.reads(caller, arg, ni);
794
+ const callee = s.call.callee;
795
+ if (callee && typeof callee === 'object' && callee.kind === 'member') this.reads(caller, callee.object, ni);
796
+ } else {
797
+ for (const a of (s.call.args || [])) if (!(a && a.kind === 'function-value' && a.qid === fn.qid)) this.reads(caller, a, ni);
798
+ const callee = s.call.callee;
799
+ if (callee && typeof callee === 'object' && callee.kind === 'member') this.reads(caller, callee.object, ni);
800
+ }
801
+ }
802
+ }
803
+
804
+ requestFieldWriters(fn, info, item) {
805
+ if (!item.ep && item.reason !== 'sink') return;
806
+ const p0 = info.params[0];
807
+ if (!p0 || !REQ_PARAM.test(p0) || this.reqDone.has(fn.file)) return;
808
+ this.reqDone.add(fn.file);
809
+ for (const other of (this.idx.byFile.get(fn.file) || [])) {
810
+ if (other.file !== fn.file) continue;
811
+ const oi = fnInfo(other);
812
+ const q0 = oi.params[0];
813
+ if (!q0 || !REQ_PARAM.test(q0)) continue;
814
+ for (const a of oi.assigns) {
815
+ if (a.target && a.target.startsWith(`${q0}.`)) {
816
+ this.addRegion(other.qid, 'request-field-writer');
817
+ this.push(other.qid, a.target, true, item.depth, 'request-field-writer');
818
+ }
819
+ }
820
+ }
821
+ }
822
+
823
+ freeVariable(fn, root, p, item) {
824
+ // every function of the file that binds the name, memoized per (file, name)
825
+ const dk = `${fn.file}\0${root}`;
826
+ let all = this.idx.definers.get(dk);
827
+ if (!all) {
828
+ all = [];
829
+ for (const other of (this.idx.byFile.get(fn.file) || [])) {
830
+ if (other.file !== fn.file) continue;
831
+ const oi = fnInfo(other);
832
+ if (oi.assignedRoots.has(root) || oi.params.includes(root)) all.push(other);
833
+ }
834
+ this.idx.definers.set(dk, all);
835
+ }
836
+ const definers = all.filter((d) => d.qid !== fn.qid);
837
+ // import bindings (`const x = require(...)`) carry no data of their own
838
+ 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'));
839
+ if (!definers.length) return; // ambient global; the engine models it by name only
840
+ this.terminals.add('free');
841
+ if (importOnly) return;
842
+ for (const d of definers) {
843
+ if (d.qid === fn.qid) continue;
844
+ this.addRegion(d.qid, 'free-variable-definer');
845
+ this.push(d.qid, p, item.ep, item.depth, 'free-variable-definer');
846
+ }
847
+ }
848
+ }
849
+
850
+ // ---------------------------------------------------------------------------
851
+ // Seeds from a sink site
852
+ // ---------------------------------------------------------------------------
853
+
854
+ function seedExprs(site, q) {
855
+ let args;
856
+ if (q.argIndex != null) args = site.argExprs[q.argIndex] ? [site.argExprs[q.argIndex]] : [];
857
+ else if (site.entry.argIndex === 'all') args = site.argExprs;
858
+ else if (typeof site.entry.argIndex === 'number') args = site.argExprs[site.entry.argIndex] ? [site.argExprs[site.entry.argIndex]] : [];
859
+ else args = site.argExprs; // 'rhs' and member-write
860
+ return args;
861
+ }
862
+
863
+ function seedFrontier(frontier, fn, site, q) {
864
+ const item = { qid: site.fnQid, path: '', ep: true, depth: 0, reason: 'sink' };
865
+ frontier.addRegion(site.fnQid, 'sink');
866
+ if (site.kind === 'call') frontier.sinkCalls.add(`${site.fnQid}|${site.line}|${site.callee}`);
867
+ for (const a of seedExprs(site, q)) {
868
+ if (!q.accessPath) { frontier.reads(fn, a, item); continue; }
869
+ const ap = accessPathOf(a);
870
+ if (ap) { frontier.push(site.fnQid, `${ap}.${q.accessPath}`, true, 0, 'sink'); continue; }
871
+ if (a && a.kind === 'object' && Array.isArray(a.props)) {
872
+ const segs = q.accessPath.split('.');
873
+ for (const pr of a.props) {
874
+ if (pr.key !== segs[0] && pr.key !== '*') continue;
875
+ const vp = accessPathOf(pr.value);
876
+ if (segs.length > 1 && vp) frontier.push(site.fnQid, `${vp}.${segs.slice(1).join('.')}`, true, 0, 'sink');
877
+ else frontier.reads(fn, pr.value, item);
878
+ }
879
+ continue;
880
+ }
881
+ // not a path or literal: no field refinement is possible, read the whole expression
882
+ frontier.reads(fn, a, item);
883
+ }
884
+ }
885
+
886
+ // ---------------------------------------------------------------------------
887
+ // Normalization, running, comparing
888
+ // ---------------------------------------------------------------------------
889
+
890
+ export function normalizeFinding(f) {
891
+ const sources = new Set();
892
+ if (f.source && f.source.label) sources.add(`${f.source.label}@${f.source.line ?? ''}`);
893
+ for (const c of (f.chain || [])) if (c && c.label) sources.add(`${c.label}@${c.line ?? ''}`);
894
+ return {
895
+ file: f.file,
896
+ line: f.line,
897
+ sinkId: f.sink ? f.sink.label : null,
898
+ argIndex: f.argIndex ?? null,
899
+ cwe: f.cwe ?? null,
900
+ sources: [...sources].sort(),
901
+ };
902
+ }
903
+
904
+ const findingKey = (n) => JSON.stringify([n.file, n.line, n.sinkId, n.argIndex, n.cwe, n.sources]);
905
+
906
+ function pickFindings(raw, sites, q) {
907
+ const out = [];
908
+ const want = new Set(sites.map((s) => `${s.file}|${s.line}|${s.sinkId}`));
909
+ for (const f of raw) {
910
+ if (!f.sink || !want.has(`${f.file}|${f.line}|${f.sink.label}`)) continue;
911
+ if (q.argIndex != null && f.argIndex !== q.argIndex) continue;
912
+ out.push(normalizeFinding(f));
913
+ }
914
+ const byKey = new Map();
915
+ for (const n of out) byKey.set(findingKey(n), n);
916
+ return [...byKey.values()].sort((a, b) => (findingKey(a) < findingKey(b) ? -1 : 1));
917
+ }
918
+
919
+ const _sbCache = new WeakMap();
920
+ function sourceBearing(fn) {
921
+ if (_sbCache.has(fn)) return _sbCache.get(fn);
922
+ const v = sourceBearingUncached(fn);
923
+ _sbCache.set(fn, v);
924
+ return v;
925
+ }
926
+
927
+ function sourceBearingUncached(fn) {
928
+ const info = fnInfo(fn);
929
+ let found = Array.isArray(fn.paramAnnotations) && fn.paramAnnotations.length > 0;
930
+ 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); };
931
+ for (const a of info.assigns) rd(a.source);
932
+ for (const c of info.calls) rd(c.expr);
933
+ for (const r of info.returns) rd(r);
934
+ return found;
935
+ }
936
+
937
+ const _lru = new Map();
938
+ const LRU_MAX = 64;
939
+
940
+ function resultCacheGet(key) { if (!_lru.has(key)) return null; const v = _lru.get(key); _lru.delete(key); _lru.set(key, v); return v; }
941
+ function resultCachePut(key, v) { _lru.set(key, v); while (_lru.size > LRU_MAX) _lru.delete(_lru.keys().next().value); }
942
+ export function clearDemandCache() { _lru.clear(); }
943
+
944
+ function engineOptsOf(opts) {
945
+ const o = { ...(opts.engineOpts || {}) };
946
+ if (opts.fnLimit) o.fnLimit = opts.fnLimit;
947
+ if (typeof opts.deadlineMs === 'number') o.deadlineMs = opts.deadlineMs;
948
+ return o;
949
+ }
950
+
951
+ function engineIncomplete(raw) {
952
+ const list = raw && raw.analysisIncomplete;
953
+ return Array.isArray(list) ? list.map((x) => x.reason).filter(Boolean) : [];
954
+ }
955
+
956
+ function fieldAttributable(frontier) {
957
+ return frontier.terminals.size > 0;
958
+ }
959
+
960
+ function baseResult(q, key, digests) {
961
+ return { schemaVersion: DEMAND_SCHEMA_VERSION, queryKey: key, query: q, digests };
962
+ }
963
+
964
+ /**
965
+ * Run one sink query in demand mode. Returns the result record described in
966
+ * the module header. Never throws for an unanswerable query: it returns an
967
+ * `unavailable` completion instead.
968
+ */
969
+ export function runDemandSinkQuery(perFileIR, callGraph, query, opts = {}) {
970
+ const q = normalizeSinkQuery(query);
971
+ const digests = { graphDigest: opts.graphDigest ?? graphDigest(callGraph), configDigest: opts.configDigest ?? configDigest(opts) };
972
+ const key = sinkQueryKey(q, digests);
973
+ const useCache = opts.cache !== false && !opts.engine;
974
+ if (useCache) { const hit = resultCacheGet(key); if (hit) return { ...hit, cached: true }; }
975
+ const res = baseResult(q, key, digests);
976
+ res.mode = 'demand';
977
+ const total = callGraph && callGraph.functions ? callGraph.functions.size : 0;
978
+
979
+ if (!callGraph || !callGraph.functions) return unavailable(res, 'no-call-graph', total);
980
+ if (!DEMAND_CONTEXTS.includes(q.context)) return unavailable(res, 'unsupported-context', total);
981
+ const sites = findSinkSites(callGraph, q);
982
+ if (!sites.length) return unavailable(res, 'sink-site-not-found', total);
983
+
984
+ const frontier = new Frontier(callGraph, opts);
985
+ for (const s of sites) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, q);
986
+ frontier.run();
987
+
988
+ const onLimit = opts.onLimit || 'full';
989
+ const onUnsupported = opts.onUnsupported || 'full';
990
+ const reasons = [];
991
+ let fallback = null;
992
+ if (frontier.limits.length) {
993
+ if (onLimit === 'full') fallback = { reason: frontier.limits.map((l) => l.kind).sort()[0], expandedToFull: true };
994
+ else for (const l of frontier.limits) reasons.push(l.kind);
995
+ }
996
+ if (frontier.unsupported.length) {
997
+ const kinds = frontier.unsupported.map((u) => u.kind).sort();
998
+ if (onUnsupported === 'full') fallback = fallback || { reason: `unsupported:${kinds[0]}`, expandedToFull: true };
999
+ else for (const k of new Set(kinds)) reasons.push(`unsupported-construct:${k}`);
1000
+ }
1001
+ if (frontier.unresolved.size) reasons.push('unresolved-callee');
1002
+
1003
+ let regionIds = [...frontier.region.keys()].sort();
1004
+ if (fallback) regionIds = [...callGraph.functions.keys()].sort();
1005
+ // A fallback analyzes every function: hand the engine the graph itself (the
1006
+ // same input, in the same order, as the full-analysis reference), not a copy.
1007
+ const regionGraph = fallback ? callGraph : { ...callGraph, functions: new Map(regionIds.map((id) => [id, callGraph.functions.get(id)])) };
1008
+
1009
+ const run = opts.engine || runTaintEngine;
1010
+ // Within one batch every fallback asks the engine the same question (the whole
1011
+ // graph, same options), so the first answer is shared by the rest. The memo
1012
+ // belongs to one runDemandQueries call and is dropped with it.
1013
+ let raw;
1014
+ if (fallback && opts._fullMemo) {
1015
+ if (!opts._fullMemo.has('raw')) opts._fullMemo.set('raw', run(perFileIR, regionGraph, engineOptsOf(opts)));
1016
+ raw = opts._fullMemo.get('raw');
1017
+ } else raw = run(perFileIR, regionGraph, engineOptsOf(opts));
1018
+ for (const r of engineIncomplete(raw)) reasons.push(`engine:${r}`);
1019
+
1020
+ let findings = pickFindings(raw, sites, q);
1021
+ let siblingSuppressed = 0;
1022
+ if (q.accessPath && !fieldAttributable(frontier)) { siblingSuppressed = findings.length; findings = []; }
1023
+
1024
+ const sbIn = regionIds.filter((id) => sourceBearing(callGraph.functions.get(id)));
1025
+ res.sites = sites.map((s) => ({ file: s.file, line: s.line, fnQid: s.fnQid, nodeId: s.nodeId, sinkId: s.sinkId, kind: s.kind }));
1026
+ res.findings = findings;
1027
+ res.siblingFieldSuppressed = siblingSuppressed;
1028
+ res.witness = findings.length ? 'forward-engine-corroborated' : 'none';
1029
+ res.candidateFilter = { role: 'candidate-filter-only', sourceBearingFunctionsInRegion: sbIn.length, candidate: sbIn.length > 0 || frontier.terminals.has('param') || frontier.terminals.has('free') || frontier.terminals.has('field') };
1030
+ res.selectorCoverage = {
1031
+ totalFunctions: total,
1032
+ selectedFunctions: frontier.region.size,
1033
+ selectedFraction: total ? frontier.region.size / total : 0,
1034
+ byReason: countReasons(frontier.region),
1035
+ followed: { ...frontier.followed },
1036
+ maxDepthReached: frontier.maxDepthReached,
1037
+ workSteps: frontier.steps,
1038
+ limits: { maxCallerDepth: frontier.maxDepth, maxWork: frontier.maxWork },
1039
+ limitHits: frontier.limits.map((l) => ({ ...l })),
1040
+ unsupported: frontier.unsupported.map((u) => ({ ...u })),
1041
+ unresolvedCallees: [...frontier.unresolved.values()],
1042
+ terminals: [...frontier.terminals].sort(),
1043
+ };
1044
+ res.analyzedFunctions = regionIds.length;
1045
+ res.fallback = fallback;
1046
+ res.completion = completionOf(reasons, frontier, fallback);
1047
+ res.negativeClaimAllowed = res.completion.negativeClaimAllowed;
1048
+ res.verdict = findings.length ? 'flow-found' : (res.negativeClaimAllowed ? 'no-flow-in-modeled-scope' : 'unknown');
1049
+ if (useCache) resultCachePut(key, res);
1050
+ return res;
1051
+ }
1052
+
1053
+ function countReasons(region) {
1054
+ const out = {};
1055
+ for (const set of region.values()) for (const r of set) out[r] = (out[r] || 0) + 1;
1056
+ return out;
1057
+ }
1058
+
1059
+ function completionOf(reasons, frontier, fallback) {
1060
+ const unexplored = frontier.depthCut + frontier.workLeft;
1061
+ const partial = reasons.length > 0;
1062
+ return makeCompletion({
1063
+ status: partial ? 'partial' : 'complete',
1064
+ modelCoverage: partial ? 'partial' : 'complete',
1065
+ reasons,
1066
+ unexploredFrontierCount: partial ? Math.max(unexplored, 0) : 0,
1067
+ limits: { maxCallerDepth: frontier.maxDepth, maxWork: frontier.maxWork, expandedToFull: !!fallback },
1068
+ unit: 'demand-query',
1069
+ });
1070
+ }
1071
+
1072
+ function unavailable(res, reason, total) {
1073
+ res.sites = [];
1074
+ res.findings = [];
1075
+ res.siblingFieldSuppressed = 0;
1076
+ res.witness = 'none';
1077
+ res.candidateFilter = { role: 'candidate-filter-only', sourceBearingFunctionsInRegion: 0, candidate: false };
1078
+ res.selectorCoverage = { totalFunctions: total, selectedFunctions: 0, selectedFraction: 0, byReason: {}, followed: {}, maxDepthReached: 0, workSteps: 0, limits: {}, limitHits: [], unsupported: [], unresolvedCallees: [], terminals: [] };
1079
+ res.analyzedFunctions = 0;
1080
+ res.fallback = null;
1081
+ res.completion = makeCompletion({ status: 'unavailable', reasons: [reason], unit: 'demand-query' });
1082
+ res.negativeClaimAllowed = false;
1083
+ res.verdict = 'unknown';
1084
+ return res;
1085
+ }
1086
+
1087
+ /** Reference answer: the same query over the FULL function set. */
1088
+ export function runFullSinkQuery(perFileIR, callGraph, query, opts = {}) {
1089
+ const q = normalizeSinkQuery(query);
1090
+ const digests = { graphDigest: opts.graphDigest ?? graphDigest(callGraph), configDigest: opts.configDigest ?? configDigest(opts) };
1091
+ const res = baseResult(q, sinkQueryKey(q, digests), digests);
1092
+ res.mode = 'full';
1093
+ const total = callGraph.functions.size;
1094
+ const sites = findSinkSites(callGraph, q);
1095
+ if (!sites.length) return unavailable(res, 'sink-site-not-found', total);
1096
+ const run = opts.engine || runTaintEngine;
1097
+ const raw = run(perFileIR, callGraph, engineOptsOf(opts));
1098
+ let findings = pickFindings(raw, sites, q);
1099
+ let suppressed = 0;
1100
+ if (q.accessPath) {
1101
+ const frontier = new Frontier(callGraph, opts);
1102
+ for (const s of sites) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, q);
1103
+ frontier.run();
1104
+ if (!fieldAttributable(frontier)) { suppressed = findings.length; findings = []; }
1105
+ }
1106
+ res.sites = sites.map((s) => ({ file: s.file, line: s.line, fnQid: s.fnQid, nodeId: s.nodeId, sinkId: s.sinkId, kind: s.kind }));
1107
+ res.findings = findings;
1108
+ res.siblingFieldSuppressed = suppressed;
1109
+ res.analyzedFunctions = total;
1110
+ res.engineIncomplete = engineIncomplete(raw);
1111
+ return res;
1112
+ }
1113
+
1114
+ /** Compare a demand result with the full reference for the same query. */
1115
+ export function compareToFull(demand, full) {
1116
+ const a = demand.findings.map(findingKey);
1117
+ const b = full.findings.map(findingKey);
1118
+ const missing = b.filter((k) => !a.includes(k));
1119
+ const extra = a.filter((k) => !b.includes(k));
1120
+ return { equal: missing.length === 0 && extra.length === 0, missing, extra };
1121
+ }
1122
+
1123
+ /** Run a batch of queries (the shape the optional dataflow/index.js hook uses). */
1124
+ export function runDemandQueries(perFileIR, callGraph, queries, opts = {}) {
1125
+ const batchOpts = opts.engine ? opts : { ...opts, _fullMemo: new Map() };
1126
+ return (queries || []).map((q) => runDemandSinkQuery(perFileIR, callGraph, q, batchOpts));
1127
+ }
1128
+
1129
+ /** Enumerate every sink site in the graph as a query (used by parity checks and the benchmark). */
1130
+ export function enumerateSinkQueries(callGraph) {
1131
+ return findSinkSites(callGraph, {}).map((s) => ({ file: s.file, line: s.line, nodeId: s.nodeId, fnQid: s.fnQid, sinkId: s.sinkId }));
1132
+ }
1133
+
1134
+ // ---------------------------------------------------------------------------
1135
+ // R02.5: adapters over the existing reverse implementations
1136
+ // ---------------------------------------------------------------------------
1137
+
1138
+ /**
1139
+ * Compare the demand region of a query result with the coarse tabulation slice
1140
+ * for the same sink functions, using the shared completion metadata. Nothing is
1141
+ * replaced: the report states which role each selector keeps.
1142
+ */
1143
+ export function reconcileSelectors(callGraph, demandResult) {
1144
+ const sinkFns = [...new Set((demandResult.sites || []).map((s) => s.fnQid))];
1145
+ const tab = reachabilitySliceWithCompletion(sinkFns.map((fnQid) => ({ fnQid })), callGraph);
1146
+ const demandIds = new Set();
1147
+ const frontier = new Frontier(callGraph, {});
1148
+ for (const s of findSinkSites(callGraph, demandResult.query)) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, demandResult.query);
1149
+ frontier.run();
1150
+ for (const id of frontier.region.keys()) demandIds.add(id);
1151
+ const onlyDemand = [...demandIds].filter((x) => !tab.reachable.has(x)).sort();
1152
+ const onlyTabulation = [...tab.reachable].filter((x) => !demandIds.has(x)).sort();
1153
+ return {
1154
+ roles: {
1155
+ tabulation: 'coarse caller-only selector; kept, cannot see producer helpers or callback registrars',
1156
+ backward: 'explanation trace of an emitted finding; kept, not a selector',
1157
+ ifdsPrecise: 'depth-capped predecessor chain; kept, now reports a completion record',
1158
+ demand: 'sink-keyed selector with forward corroboration',
1159
+ },
1160
+ tabulationCompletion: makeCompletion({ status: tab.completion.status, reasons: tab.completion.reasons, unexploredFrontierCount: tab.completion.unexploredFrontierCount, limits: tab.completion.limits, unit: 'tabulation-slice' }),
1161
+ demandCompletion: demandResult.completion,
1162
+ onlyDemand,
1163
+ onlyTabulation,
1164
+ tabulationMissedDemandFunctions: onlyDemand.length,
1165
+ };
1166
+ }
1167
+
1168
+ export const _internals = { Frontier, fnInfo, segOverlap, calleeName, sourceBearing };