@holmes-lab/holmes-kit 0.3.11 → 0.4.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 (47) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/ci-gate.js +3 -1
  4. package/dist/holmes/cpg/foundation/ast-store.d.ts +49 -0
  5. package/dist/holmes/cpg/foundation/ast-store.js +209 -0
  6. package/dist/holmes/cpg/foundation/cdg.d.ts +31 -0
  7. package/dist/holmes/cpg/foundation/cdg.js +83 -0
  8. package/dist/holmes/cpg/foundation/cfg.d.ts +60 -0
  9. package/dist/holmes/cpg/foundation/cfg.js +617 -0
  10. package/dist/holmes/cpg/foundation/ddg.d.ts +41 -0
  11. package/dist/holmes/cpg/foundation/ddg.js +394 -0
  12. package/dist/holmes/cpg/foundation/language-envelope.d.ts +29 -0
  13. package/dist/holmes/cpg/foundation/language-envelope.js +131 -0
  14. package/dist/holmes/cpg/foundation/language-matrix.d.ts +57 -0
  15. package/dist/holmes/cpg/foundation/language-matrix.js +142 -0
  16. package/dist/holmes/cpg/foundation/substrate-census.d.ts +36 -0
  17. package/dist/holmes/cpg/foundation/substrate-census.js +135 -0
  18. package/dist/holmes/cpg/language-capability.js +21 -3
  19. package/dist/holmes/cpg/language-parser-walk.js +137 -6
  20. package/dist/holmes/cpg/language-parser.d.ts +1 -0
  21. package/dist/holmes/hooks/pre-tool-use.d.ts +17 -2
  22. package/dist/holmes/hooks/pre-tool-use.js +21 -4
  23. package/dist/holmes/mcp/handlers.d.ts +17 -5
  24. package/dist/holmes/mcp/handlers.js +98 -1
  25. package/dist/holmes/mcp/supervisor.d.ts +24 -0
  26. package/dist/holmes/mcp/supervisor.js +63 -6
  27. package/dist/holmes/mcp/tool-schemas.js +8 -1
  28. package/dist/holmes/project/root.d.ts +1 -0
  29. package/dist/holmes/project/root.js +30 -0
  30. package/dist/holmes/review/test-runner.d.ts +15 -0
  31. package/dist/holmes/review/test-runner.js +87 -9
  32. package/dist/holmes/rtm/dataflow-taint.js +5 -1
  33. package/dist/holmes/rtm/flow-sensitive-taint.d.ts +41 -0
  34. package/dist/holmes/rtm/flow-sensitive-taint.js +109 -0
  35. package/dist/holmes/rtm/reaching-def-filter.d.ts +44 -0
  36. package/dist/holmes/rtm/reaching-def-filter.js +167 -0
  37. package/dist/holmes/rtm/sink-matching.d.ts +26 -0
  38. package/dist/holmes/rtm/sink-matching.js +73 -0
  39. package/dist/holmes/rtm/taint-benchmark.d.ts +13 -0
  40. package/dist/holmes/rtm/taint-benchmark.js +97 -9
  41. package/dist/holmes/rtm/taint-vocabulary.d.ts +24 -0
  42. package/dist/holmes/rtm/taint-vocabulary.js +58 -0
  43. package/grammars/manifest.json +23 -0
  44. package/grammars/tree-sitter-python.wasm +0 -0
  45. package/grammars/tree-sitter-tsx.wasm +0 -0
  46. package/grammars/tree-sitter-typescript.wasm +0 -0
  47. package/package.json +4 -2
@@ -0,0 +1,617 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CFG_LANGUAGES = exports.FUNCTION_TYPES = void 0;
4
+ exports.childrenIndex = childrenIndex;
5
+ exports.functionsIn = functionsIn;
6
+ exports.cfgOf = cfgOf;
7
+ exports.domViolations = domViolations;
8
+ exports.FUNCTION_TYPES = new Set([
9
+ 'function_declaration', 'generator_function_declaration', 'function_expression',
10
+ 'generator_function', 'arrow_function', 'method_definition', 'function',
11
+ // @implements A-SPEC-510.7 — Python's function node joins the same census.
12
+ 'function_definition', 'lambda',
13
+ ]);
14
+ const TS_SIMPLE = new Set([
15
+ 'expression_statement', 'lexical_declaration', 'variable_declaration', 'debugger_statement',
16
+ 'empty_statement', 'function_declaration', 'generator_function_declaration', 'class_declaration',
17
+ 'import_statement', 'export_statement', 'type_alias_declaration', 'interface_declaration',
18
+ 'enum_declaration', 'abstract_class_declaration', 'ambient_declaration',
19
+ ]);
20
+ const TS_RULES = {
21
+ simple: TS_SIMPLE,
22
+ handled: new Set([
23
+ ...TS_SIMPLE,
24
+ 'statement_block', 'if_statement', 'while_statement', 'do_statement', 'for_statement',
25
+ 'for_in_statement', 'switch_statement', 'break_statement', 'continue_statement',
26
+ 'return_statement', 'throw_statement', 'try_statement', 'labeled_statement',
27
+ ]),
28
+ block: 'statement_block',
29
+ };
30
+ // @implements A-SPEC-510.7 — Python. `pass`/`import`/`global` are straight-line statements;
31
+ // `for`/`while` carry an ELSE clause with distinctive semantics (see the lowering), `match` has
32
+ // no fallthrough, and `with` is a body wrapper whose exceptional edge the try rules cover.
33
+ const PY_SIMPLE = new Set([
34
+ 'expression_statement', 'pass_statement', 'import_statement', 'import_from_statement',
35
+ 'global_statement', 'nonlocal_statement', 'assert_statement', 'delete_statement',
36
+ 'print_statement', 'exec_statement', 'future_import_statement', 'class_definition',
37
+ 'function_definition', 'decorated_definition',
38
+ ]);
39
+ const PY_RULES = {
40
+ simple: PY_SIMPLE,
41
+ handled: new Set([
42
+ ...PY_SIMPLE,
43
+ 'block', 'if_statement', 'elif_clause', 'while_statement', 'for_statement', 'match_statement',
44
+ 'with_statement', 'try_statement', 'break_statement', 'continue_statement',
45
+ 'return_statement', 'raise_statement',
46
+ ]),
47
+ block: 'block',
48
+ };
49
+ const LANG_RULES = {
50
+ typescript: TS_RULES, tsx: TS_RULES, python: PY_RULES,
51
+ };
52
+ // @implements A-SPEC-510.6
53
+ /**
54
+ * The languages this lowering understands — DERIVED from the rule table, so the layer×language
55
+ * matrix and the guard below can never disagree with what is actually implemented.
56
+ */
57
+ exports.CFG_LANGUAGES = new Set(Object.keys(LANG_RULES));
58
+ /** Shared child index over the flat pre-order AST — DDG (L3) walks with the same truth. */
59
+ function childrenIndex(ast) {
60
+ const kids = ast.nodes.map(() => []);
61
+ ast.nodes.forEach((n, i) => { if (n.parent >= 0)
62
+ kids[n.parent].push(i); });
63
+ return { of: (i) => kids[i] };
64
+ }
65
+ /** Every function-like node in the AST, with its body (statement_block or expression body). */
66
+ function functionsIn(ast) {
67
+ const kids = childrenIndex(ast);
68
+ const out = [];
69
+ ast.nodes.forEach((n, i) => {
70
+ if (!exports.FUNCTION_TYPES.has(n.type))
71
+ return;
72
+ const ks = kids.of(i);
73
+ const block = ks.find((k) => ast.nodes[k].type === 'statement_block' || ast.nodes[k].type === 'block');
74
+ // Arrow expression bodies: the last named child that is not the parameter list / return type.
75
+ const body = block ?? ks.filter((k) => ast.nodes[k].named).at(-1);
76
+ if (body !== undefined)
77
+ out.push({ nodeIndex: i, bodyIndex: body });
78
+ });
79
+ return out;
80
+ }
81
+ class Unsupported {
82
+ type;
83
+ constructor(type) {
84
+ this.type = type;
85
+ }
86
+ }
87
+ /** Build the CFG of one function, or refuse the whole function honestly. */
88
+ function cfgOf(ast, fn, source) {
89
+ if (!exports.CFG_LANGUAGES.has(ast.lang)) {
90
+ return { unsupported: `${ast.lang} (next slice)` };
91
+ }
92
+ const rules = LANG_RULES[ast.lang];
93
+ const isPython = ast.lang === 'python';
94
+ const kids = childrenIndex(ast);
95
+ // Points: one per lowered statement (block ids after coalescing). 0=ENTRY, 1=EXIT.
96
+ const stmtsOf = [[], []];
97
+ const edges = [];
98
+ const ENTRY = 0;
99
+ const EXIT = 1;
100
+ const newPoint = (astIdx) => { stmtsOf.push([astIdx]); return stmtsOf.length - 1; };
101
+ const link = (from, to, kind) => { edges.push({ from, to, kind }); };
102
+ // Label identity is TEXT: the definition and its references sit at different spans, so a
103
+ // span-keyed name can never match. Without source, labeled flow is honestly unsupported.
104
+ const labelName = (i) => {
105
+ if (source === undefined)
106
+ throw new Unsupported('labeled statement requires source');
107
+ return source.slice(ast.nodes[i].start, ast.nodes[i].end);
108
+ };
109
+ // A `case _:` arm, read from the SOURCE: the pattern's text is exactly `_`.
110
+ const wildcardText = (caseClause) => {
111
+ if (source === undefined)
112
+ return false;
113
+ const pat = kids.of(caseClause).find((k) => ast.nodes[k].type === 'case_pattern');
114
+ return pat !== undefined && source.slice(ast.nodes[pat].start, ast.nodes[pat].end).trim() === '_';
115
+ };
116
+ try {
117
+ const lowerSeq = (stmtIdxs, ctx) => {
118
+ let entry = -1;
119
+ let exits = [];
120
+ for (const s of stmtIdxs) {
121
+ const f = lowerStmt(s, ctx);
122
+ if (entry === -1)
123
+ entry = f.entry;
124
+ for (const e of exits)
125
+ link(e.from, f.entry, e.kind);
126
+ exits = f.exits;
127
+ }
128
+ if (entry === -1) {
129
+ const p = newPoint(-1);
130
+ return { entry: p, exits: [{ from: p, kind: 'seq' }] };
131
+ }
132
+ return { entry, exits };
133
+ };
134
+ const stmtChildren = (i) => kids.of(i).filter((k) => ast.nodes[k].named && ast.nodes[k].type !== 'comment');
135
+ const wireException = (first, targetProvider) => {
136
+ // Conservative: every point created inside [first, now) can throw into the handler.
137
+ const target = targetProvider();
138
+ for (let p = first; p < stmtsOf.length; p++)
139
+ link(p, target, 'exception');
140
+ };
141
+ const lowerStmt = (i, ctx) => {
142
+ const t = ast.nodes[i].type;
143
+ if (!rules.handled.has(t))
144
+ throw new Unsupported(t);
145
+ const label = ctx.pendingLabel;
146
+ ctx.pendingLabel = null;
147
+ if (rules.simple.has(t)) {
148
+ const p = newPoint(i);
149
+ return { entry: p, exits: [{ from: p, kind: 'seq' }] };
150
+ }
151
+ switch (t) {
152
+ case 'statement_block':
153
+ case 'block':
154
+ return lowerSeq(stmtChildren(i), ctx);
155
+ case 'labeled_statement': {
156
+ const [labelNode, inner] = [kids.of(i)[0], stmtChildren(i).at(-1)];
157
+ ctx.pendingLabel = labelName(labelNode);
158
+ return lowerStmt(inner, ctx);
159
+ }
160
+ case 'elif_clause':
161
+ case 'if_statement': {
162
+ const p = newPoint(i);
163
+ const named = stmtChildren(i);
164
+ // @implements A-SPEC-510.7 — Python spells the arms as fields (`consequence`,
165
+ // `alternative` = elif_clause | else_clause), TS as positional children. Reading the
166
+ // fields where they exist keeps ONE lowering for both grammars.
167
+ const cons = isPython ? (kids.of(i).find((k) => ast.nodes[k].type === 'block') ?? named[1]) : named[1];
168
+ let alt;
169
+ if (isPython) {
170
+ const clause = kids.of(i).find((k) => ['elif_clause', 'else_clause'].includes(ast.nodes[k].type));
171
+ if (clause !== undefined) {
172
+ alt = ast.nodes[clause].type === 'elif_clause'
173
+ ? clause // an elif IS a nested branch
174
+ : stmtChildren(clause).find((k) => ast.nodes[k].type === 'block');
175
+ }
176
+ }
177
+ else {
178
+ alt = named[2] !== undefined ? stmtChildren(named[2])[0] : undefined;
179
+ }
180
+ const cf = lowerStmt(cons, ctx);
181
+ link(p, cf.entry, 'true');
182
+ const exits = [...cf.exits];
183
+ if (alt !== undefined) {
184
+ const af = lowerStmt(alt, ctx);
185
+ link(p, af.entry, 'false');
186
+ exits.push(...af.exits);
187
+ }
188
+ else {
189
+ exits.push({ from: p, kind: 'false' });
190
+ }
191
+ return { entry: p, exits };
192
+ }
193
+ case 'while_statement':
194
+ case 'for_statement':
195
+ case 'for_in_statement': {
196
+ const header = newPoint(i);
197
+ // @implements A-SPEC-510.7 — Python's loop `else` runs ONLY when the loop is EXHAUSTED;
198
+ // a `break` skips it. Modelling it as a plain successor (the common mistake) would claim
199
+ // the else block runs on every exit. Here the false/exhaustion edge enters else, while
200
+ // break edges bypass it entirely.
201
+ const pyElse = isPython
202
+ ? kids.of(i).filter((k) => ast.nodes[k].type === 'else_clause')
203
+ .flatMap((k) => stmtChildren(k)).find((k) => ast.nodes[k].type === 'block')
204
+ : undefined;
205
+ const bodyIdx = isPython
206
+ ? kids.of(i).filter((k) => ast.nodes[k].type === 'block').at(0)
207
+ : stmtChildren(i).at(-1);
208
+ const myBreak = { label, target: -1 }; // ONE owner: labeled and bare breaks share it
209
+ ctx.breakT.push({ label, owner: myBreak }, { label: null, owner: myBreak });
210
+ ctx.contT.push({ label, target: header }, { label: null, target: header });
211
+ const bf = lowerStmt(bodyIdx, ctx);
212
+ ctx.breakT.splice(-2);
213
+ ctx.contT.splice(-2);
214
+ link(header, bf.entry, 'true');
215
+ for (const e of bf.exits)
216
+ link(e.from, header, 'loop-back');
217
+ const exits = [];
218
+ if (pyElse !== undefined) {
219
+ const ef = lowerStmt(pyElse, ctx);
220
+ link(header, ef.entry, 'false'); // exhaustion path enters `else`
221
+ exits.push(...ef.exits);
222
+ }
223
+ else {
224
+ exits.push({ from: header, kind: 'false' });
225
+ }
226
+ // break targets resolved lazily: edges recorded with target -1 fixed by caller — instead
227
+ // we collect them via the pendingBreaks list below.
228
+ for (const b of claimBreaks(myBreak)) {
229
+ exits.push({ from: b.point, kind: 'break' });
230
+ }
231
+ return { entry: header, exits };
232
+ }
233
+ case 'do_statement': {
234
+ const header = newPoint(i); // condition point (the `do` node)
235
+ const bodyIdx = stmtChildren(i)[0];
236
+ const myBreak = { label, target: -1 };
237
+ ctx.breakT.push({ label, owner: myBreak }, { label: null, owner: myBreak });
238
+ ctx.contT.push({ label, target: header }, { label: null, target: header });
239
+ const bf = lowerStmt(bodyIdx, ctx);
240
+ ctx.breakT.splice(-2);
241
+ ctx.contT.splice(-2);
242
+ for (const e of bf.exits)
243
+ link(e.from, header, e.kind);
244
+ link(header, bf.entry, 'loop-back');
245
+ const exits = [{ from: header, kind: 'false' }];
246
+ for (const b of claimBreaks(myBreak)) {
247
+ exits.push({ from: b.point, kind: 'break' });
248
+ }
249
+ return { entry: bf.entry, exits };
250
+ }
251
+ case 'match_statement': {
252
+ // @implements A-SPEC-510.7 — `match` is NOT a switch: the first matching case runs and
253
+ // control leaves; there is no fallthrough. Reusing the switch lowering here would invent
254
+ // seq edges between cases that Python never takes.
255
+ const p = newPoint(i);
256
+ const body = kids.of(i).find((k) => ast.nodes[k].type === 'block');
257
+ const exits = [];
258
+ let sawWildcard = false;
259
+ for (const c of body === undefined ? [] : kids.of(body)) {
260
+ if (ast.nodes[c].type !== 'case_clause')
261
+ continue;
262
+ const caseBlock = kids.of(c).find((k) => ast.nodes[k].type === 'block');
263
+ if (caseBlock === undefined)
264
+ continue;
265
+ const cf = lowerStmt(caseBlock, ctx);
266
+ link(p, cf.entry, 'case');
267
+ exits.push(...cf.exits);
268
+ // `case _:` is the catch-all. Detected by NODE TYPE only — reading the type string for
269
+ // an underscore matched `case_pattern` itself (measured: every match looked exhaustive).
270
+ if (wildcardText(c))
271
+ sawWildcard = true;
272
+ }
273
+ if (!sawWildcard)
274
+ exits.push({ from: p, kind: 'default' }); // no arm may match
275
+ return { entry: p, exits };
276
+ }
277
+ case 'with_statement': {
278
+ // @implements A-SPEC-510.7 — a `with` is a body wrapper for control-flow purposes; the
279
+ // context manager's exceptional path is covered by the enclosing try rules (conservative).
280
+ const body = kids.of(i).find((k) => ast.nodes[k].type === 'block');
281
+ if (body === undefined)
282
+ throw new Unsupported('with_statement without a block');
283
+ return lowerStmt(body, ctx);
284
+ }
285
+ case 'raise_statement': {
286
+ const p = newPoint(i);
287
+ link(p, ctx.catchT.at(-1) ?? EXIT, 'throw');
288
+ return { entry: p, exits: [] };
289
+ }
290
+ case 'switch_statement': {
291
+ const p = newPoint(i);
292
+ const body = stmtChildren(i).at(-1); // switch_body
293
+ const myBreak = { label, target: -1 };
294
+ ctx.breakT.push({ label, owner: myBreak }, { label: null, owner: myBreak });
295
+ const cases = kids.of(body).filter((k) => ['switch_case', 'switch_default'].includes(ast.nodes[k].type));
296
+ let sawDefault = false;
297
+ let prevFall = [];
298
+ const exits = [];
299
+ for (const c of cases) {
300
+ const caseStmts = stmtChildren(c).filter((k) => rules.handled.has(ast.nodes[k].type));
301
+ const cf = lowerSeq(caseStmts, ctx);
302
+ const kind = ast.nodes[c].type === 'switch_default' ? 'default' : 'case';
303
+ if (ast.nodes[c].type === 'switch_default')
304
+ sawDefault = true;
305
+ link(p, cf.entry, kind);
306
+ for (const e of prevFall)
307
+ link(e.from, cf.entry, 'seq'); // fallthrough
308
+ prevFall = cf.exits;
309
+ }
310
+ exits.push(...prevFall);
311
+ if (!sawDefault)
312
+ exits.push({ from: p, kind: 'default' });
313
+ ctx.breakT.splice(-2);
314
+ for (const b of claimBreaks(myBreak)) {
315
+ exits.push({ from: b.point, kind: 'break' });
316
+ }
317
+ return { entry: p, exits };
318
+ }
319
+ case 'break_statement': {
320
+ const p = newPoint(i);
321
+ const lbl = stmtChildren(i).length ? labelName(stmtChildren(i)[0]) : null;
322
+ const bentry = nearestBreak(ctx.breakT, lbl);
323
+ if (!bentry)
324
+ throw new Unsupported('break outside breakable');
325
+ // The owner's OUT is not built yet — park the edge for the construct to claim.
326
+ pendingBreaks.push({ point: p, owner: bentry.owner });
327
+ return { entry: p, exits: [] };
328
+ }
329
+ case 'continue_statement': {
330
+ const p = newPoint(i);
331
+ const lbl = stmtChildren(i).length ? labelName(stmtChildren(i)[0]) : null;
332
+ const centry = nearestCont(ctx.contT, lbl);
333
+ if (!centry || centry.target < 0)
334
+ throw new Unsupported('continue outside loop');
335
+ const target = centry.target;
336
+ link(p, target, 'continue');
337
+ return { entry: p, exits: [] };
338
+ }
339
+ case 'return_statement': {
340
+ const p = newPoint(i);
341
+ link(p, EXIT, 'return');
342
+ return { entry: p, exits: [] };
343
+ }
344
+ case 'throw_statement': {
345
+ const p = newPoint(i);
346
+ link(p, ctx.catchT.at(-1) ?? EXIT, 'throw');
347
+ return { entry: p, exits: [] };
348
+ }
349
+ case 'try_statement': {
350
+ const named = stmtChildren(i);
351
+ // @implements A-SPEC-510.7 — Python spells the same shape with `block` / `except_clause`
352
+ // / `finally_clause`, PLUS an `else_clause` that runs ONLY when no exception was raised.
353
+ const body = isPython
354
+ ? kids.of(i).find((k) => ast.nodes[k].type === 'block')
355
+ : named.find((k) => ast.nodes[k].type === 'statement_block');
356
+ const handler = isPython
357
+ ? kids.of(i).find((k) => ast.nodes[k].type === 'except_clause')
358
+ : named.find((k) => ast.nodes[k].type === 'catch_clause');
359
+ const finalizer = isPython
360
+ ? kids.of(i).find((k) => ast.nodes[k].type === 'finally_clause')
361
+ : named.find((k) => ast.nodes[k].type === 'finally_clause');
362
+ const pyNoExcept = isPython
363
+ ? kids.of(i).filter((k) => ast.nodes[k].type === 'else_clause')
364
+ .flatMap((k) => kids.of(k)).find((k) => ast.nodes[k].type === 'block')
365
+ : undefined;
366
+ const exits = [];
367
+ // Lower catch/finally FIRST so their entry points exist for exception wiring.
368
+ const clauseBody = (clause) => (isPython
369
+ ? kids.of(clause).find((k) => ast.nodes[k].type === 'block')
370
+ : stmtChildren(clause).at(-1));
371
+ let catchFlow;
372
+ if (handler !== undefined)
373
+ catchFlow = lowerStmt(clauseBody(handler), ctx);
374
+ let finallyFlow;
375
+ if (finalizer !== undefined)
376
+ finallyFlow = lowerStmt(clauseBody(finalizer), ctx);
377
+ const excTarget = catchFlow?.entry ?? finallyFlow?.entry ?? (ctx.catchT.at(-1) ?? EXIT);
378
+ const firstBodyPoint = stmtsOf.length;
379
+ ctx.catchT.push(excTarget);
380
+ const bf = lowerStmt(body, ctx);
381
+ ctx.catchT.pop();
382
+ wireException(firstBodyPoint, () => excTarget); // conservative: any try stmt may throw
383
+ const after = [];
384
+ if (pyNoExcept !== undefined) {
385
+ // Python's try/else: reached ONLY when the body completed without raising.
386
+ const ef = lowerStmt(pyNoExcept, ctx);
387
+ for (const e of bf.exits)
388
+ link(e.from, ef.entry, e.kind);
389
+ after.push(...ef.exits);
390
+ }
391
+ else {
392
+ after.push(...bf.exits);
393
+ }
394
+ if (catchFlow)
395
+ after.push(...catchFlow.exits);
396
+ if (finallyFlow) {
397
+ for (const e of after)
398
+ link(e.from, finallyFlow.entry, e.kind);
399
+ exits.push(...finallyFlow.exits);
400
+ if (!catchFlow) {
401
+ // Uncaught exceptional path traverses finally then leaves the function.
402
+ for (const e of finallyFlow.exits)
403
+ link(e.from, ctx.catchT.at(-1) ?? EXIT, 'throw');
404
+ }
405
+ }
406
+ else {
407
+ exits.push(...after);
408
+ }
409
+ return { entry: bf.entry, exits };
410
+ }
411
+ default:
412
+ throw new Unsupported(t);
413
+ }
414
+ };
415
+ const pendingBreaks = [];
416
+ // Claim ONLY this construct's parked breaks: an inner loop draining the whole list would
417
+ // swallow an outer labeled break (measured: the labeled fixture lost its break edges).
418
+ const claimBreaks = (owner) => {
419
+ const mine = [];
420
+ for (let k = pendingBreaks.length - 1; k >= 0; k--) {
421
+ if (pendingBreaks[k].owner === owner)
422
+ mine.push(...pendingBreaks.splice(k, 1));
423
+ }
424
+ return mine;
425
+ };
426
+ const nearestBreak = (stack, lbl) => {
427
+ for (let k = stack.length - 1; k >= 0; k--) {
428
+ if (lbl === null ? stack[k].label === null : stack[k].label === lbl)
429
+ return stack[k];
430
+ }
431
+ return undefined;
432
+ };
433
+ const nearestCont = (stack, lbl) => {
434
+ for (let k = stack.length - 1; k >= 0; k--) {
435
+ if (lbl === null ? stack[k].label === null : stack[k].label === lbl)
436
+ return stack[k];
437
+ }
438
+ return undefined;
439
+ };
440
+ const ctx = { breakT: [], contT: [], catchT: [], pendingLabel: null };
441
+ const bodyNode = ast.nodes[fn.bodyIndex];
442
+ const flow = bodyNode.type === rules.block
443
+ ? lowerSeq(stmtChildren(fn.bodyIndex), ctx)
444
+ : (() => { const p = newPoint(fn.bodyIndex); return { entry: p, exits: [{ from: p, kind: 'seq' }] }; })();
445
+ link(ENTRY, flow.entry, 'seq');
446
+ for (const e of flow.exits)
447
+ link(e.from, EXIT, e.kind === 'seq' ? 'seq' : e.kind);
448
+ if (pendingBreaks.length > 0)
449
+ throw new Unsupported('unresolved break label');
450
+ return finalize(stmtsOf, edges, ENTRY, EXIT);
451
+ }
452
+ catch (e) {
453
+ if (e instanceof Unsupported)
454
+ return { unsupported: e.type };
455
+ throw e;
456
+ }
457
+ }
458
+ /** Coalesce trivial chains, compute reachability and (post-)dominators, package the Cfg. */
459
+ function finalize(stmtsOf, rawEdges, entry, exit) {
460
+ const n = stmtsOf.length;
461
+ // Deduplicate edges (conservative exception wiring can double-insert).
462
+ const seen = new Set();
463
+ const edges = rawEdges.filter((e) => {
464
+ const k = `${e.from}>${e.to}>${e.kind}`;
465
+ if (seen.has(k) || e.from === e.to && e.kind === 'seq')
466
+ return false;
467
+ seen.add(k);
468
+ return true;
469
+ });
470
+ const succ = Array.from({ length: n }, () => []);
471
+ const pred = Array.from({ length: n }, () => []);
472
+ for (const e of edges) {
473
+ succ[e.from].push(e.to);
474
+ pred[e.to].push(e.from);
475
+ }
476
+ // Reachability from entry.
477
+ const reach = new Uint8Array(n);
478
+ const stack = [entry];
479
+ reach[entry] = 1;
480
+ while (stack.length) {
481
+ const b = stack.pop();
482
+ for (const s of succ[b])
483
+ if (!reach[s]) {
484
+ reach[s] = 1;
485
+ stack.push(s);
486
+ }
487
+ }
488
+ const unreachable = [...Array(n).keys()].filter((b) => !reach[b] && b !== entry && b !== exit && stmtsOf[b].length > 0);
489
+ const idom = dominators(n, entry, succ, pred, reach);
490
+ // Post-dominators = dominators over the reversed graph rooted at EXIT, restricted to blocks
491
+ // that actually reach EXIT (an infinite loop legitimately never does).
492
+ const reachExit = new Uint8Array(n);
493
+ const st2 = [exit];
494
+ reachExit[exit] = 1;
495
+ while (st2.length) {
496
+ const b = st2.pop();
497
+ for (const p of pred[b])
498
+ if (!reachExit[p]) {
499
+ reachExit[p] = 1;
500
+ st2.push(p);
501
+ }
502
+ }
503
+ const ipostdom = dominators(n, exit, pred, succ, reachExit);
504
+ return {
505
+ blocks: stmtsOf.map((stmts, id) => ({ id, stmts: stmts.filter((s) => s >= 0) })),
506
+ edges, entry, exit, idom, ipostdom, unreachable,
507
+ };
508
+ }
509
+ /** Cooper–Harvey–Kennedy iterative dominators over the sub-graph marked in `included`. */
510
+ function dominators(n, root, succ, pred, included) {
511
+ // Reverse post-order over included nodes.
512
+ const order = [];
513
+ const state = new Uint8Array(n);
514
+ const dfs = (u) => {
515
+ state[u] = 1;
516
+ for (const v of succ[u])
517
+ if (included[v] && !state[v])
518
+ dfs(v);
519
+ order.push(u);
520
+ };
521
+ if (included[root])
522
+ dfs(root);
523
+ order.reverse();
524
+ const rpo = new Int32Array(n).fill(-1);
525
+ order.forEach((b, i) => { rpo[b] = i; });
526
+ const idom = new Int32Array(n).fill(-1);
527
+ idom[root] = root;
528
+ const intersect = (a, b) => {
529
+ while (a !== b) {
530
+ while (rpo[a] > rpo[b])
531
+ a = idom[a];
532
+ while (rpo[b] > rpo[a])
533
+ b = idom[b];
534
+ }
535
+ return a;
536
+ };
537
+ let changed = true;
538
+ while (changed) {
539
+ changed = false;
540
+ for (const b of order) {
541
+ if (b === root)
542
+ continue;
543
+ let newIdom = -1;
544
+ for (const p of pred[b]) {
545
+ if (!included[p] || idom[p] === -1)
546
+ continue;
547
+ newIdom = newIdom === -1 ? p : intersect(p, newIdom);
548
+ }
549
+ if (newIdom !== -1 && idom[b] !== newIdom) {
550
+ idom[b] = newIdom;
551
+ changed = true;
552
+ }
553
+ }
554
+ }
555
+ idom[root] = -1; // the root has no immediate dominator
556
+ return idom;
557
+ }
558
+ // @implements A-SPEC-510.3
559
+ /** The invariant verifier — the ONE truth tests and later layers share. [] means the theory holds. */
560
+ function domViolations(cfg) {
561
+ const out = [];
562
+ const n = cfg.blocks.length;
563
+ const inEdges = cfg.edges.filter((e) => e.to === cfg.entry);
564
+ const outEdges = cfg.edges.filter((e) => e.from === cfg.exit);
565
+ if (inEdges.length > 0)
566
+ out.push('ENTRY has predecessors');
567
+ if (outEdges.length > 0)
568
+ out.push('EXIT has successors');
569
+ for (const e of cfg.edges) {
570
+ if (e.from < 0 || e.from >= n || e.to < 0 || e.to >= n)
571
+ out.push(`edge escapes the function: ${e.from}->${e.to}`);
572
+ }
573
+ // Every reachable block's idom chain terminates at ENTRY (acyclic, rooted).
574
+ const unreachableSet = new Set(cfg.unreachable);
575
+ for (let b = 0; b < n; b++) {
576
+ if (b === cfg.entry || unreachableSet.has(b))
577
+ continue;
578
+ if (cfg.idom[b] === -1)
579
+ continue; // not reachable from entry at all
580
+ let cur = b;
581
+ const hop = new Set();
582
+ while (cur !== cfg.entry) {
583
+ if (hop.has(cur)) {
584
+ out.push(`idom cycle at ${b}`);
585
+ break;
586
+ }
587
+ hop.add(cur);
588
+ const d = cfg.idom[cur];
589
+ if (d === -1) {
590
+ out.push(`idom chain of ${b} does not reach ENTRY`);
591
+ break;
592
+ }
593
+ cur = d;
594
+ }
595
+ }
596
+ // Post-dominator chains terminate at EXIT for every block that reaches EXIT.
597
+ for (let b = 0; b < n; b++) {
598
+ if (b === cfg.exit || cfg.ipostdom[b] === -1)
599
+ continue;
600
+ let cur = b;
601
+ const hop = new Set();
602
+ while (cur !== cfg.exit) {
603
+ if (hop.has(cur)) {
604
+ out.push(`ipostdom cycle at ${b}`);
605
+ break;
606
+ }
607
+ hop.add(cur);
608
+ const d = cfg.ipostdom[cur];
609
+ if (d === -1) {
610
+ out.push(`ipostdom chain of ${b} does not reach EXIT`);
611
+ break;
612
+ }
613
+ cur = d;
614
+ }
615
+ }
616
+ return out;
617
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * L3-DDG: reaching definitions over the L2 CFG, emitted as Joern-vocabulary REACHING_DEF edges.
3
+ * Textbook forward may-analysis: per-block gen/kill in statement order, IN = ∪OUT(pred),
4
+ * worklist to fixpoint. The load-bearing demonstration is the per-branch-sanitize join: when
5
+ * BOTH branches redefine a name, the tainted definition does not reach the join's use — exactly
6
+ * the shape behind the two recorded controlFlowAttributable taint false positives.
7
+ *
8
+ * HONEST ENVELOPE (sealed): function-level namespace (params + locally defined names) — inner
9
+ * block shadowing merges (over-approx); imported/global/closure-captured names are NOT tracked;
10
+ * member/subscript assignment is NOT a def (object mutation is a future axis); nested function
11
+ * bodies do not participate in the outer function's def/use; destructuring patterns
12
+ * over-approximate every identifier as a def. Exception-edge reachability is inherited from L2's
13
+ * conservative model.
14
+ */
15
+ import type { PersistedAst } from './ast-store';
16
+ import { Cfg, FunctionRef } from './cfg';
17
+ export interface DdgEdge {
18
+ defStmt: number;
19
+ useStmt: number;
20
+ name: string;
21
+ }
22
+ export interface Ddg {
23
+ edges: DdgEdge[];
24
+ /** Per-statement definitions/uses (AST node index of the statement → names). */
25
+ defs: Map<number, Set<string>>;
26
+ uses: Map<number, Set<string>>;
27
+ /** Parameter name → the identifier's AST index (its def point lives at ENTRY). */
28
+ params: Map<string, number>;
29
+ }
30
+ export declare function defUseOf(ast: PersistedAst, cfg: Cfg, fn: FunctionRef, source: string): {
31
+ defs: Map<number, Set<string>>;
32
+ uses: Map<number, Set<string>>;
33
+ params: Map<string, number>;
34
+ };
35
+ export declare function ddgOf(ast: PersistedAst, cfg: Cfg | {
36
+ unsupported: string;
37
+ }, fn: FunctionRef, source: string): Ddg | {
38
+ unsupported: string;
39
+ };
40
+ /** The DDG verifier — [] means the reaching-definition THEORY holds on this instance. */
41
+ export declare function ddgViolations(ast: PersistedAst, cfg: Cfg, ddg: Ddg): string[];