gitnexus 1.6.8-rc.12 → 1.6.8-rc.13

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 (36) hide show
  1. package/dist/_shared/scope-resolution/parsed-file.d.ts +21 -0
  2. package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
  3. package/dist/cli/analyze-config.js +1 -0
  4. package/dist/cli/analyze.d.ts +6 -0
  5. package/dist/cli/analyze.js +2 -0
  6. package/dist/cli/index.js +2 -0
  7. package/dist/core/ingestion/cfg/cfg-builder.d.ts +51 -0
  8. package/dist/core/ingestion/cfg/cfg-builder.js +100 -0
  9. package/dist/core/ingestion/cfg/collect.d.ts +30 -0
  10. package/dist/core/ingestion/cfg/collect.js +34 -0
  11. package/dist/core/ingestion/cfg/control-flow-context.d.ts +27 -0
  12. package/dist/core/ingestion/cfg/control-flow-context.js +49 -0
  13. package/dist/core/ingestion/cfg/emit.d.ts +64 -0
  14. package/dist/core/ingestion/cfg/emit.js +95 -0
  15. package/dist/core/ingestion/cfg/traversal-result.d.ts +20 -0
  16. package/dist/core/ingestion/cfg/traversal-result.js +2 -0
  17. package/dist/core/ingestion/cfg/types.d.ts +66 -0
  18. package/dist/core/ingestion/cfg/types.js +13 -0
  19. package/dist/core/ingestion/cfg/visitors/typescript.d.ts +49 -0
  20. package/dist/core/ingestion/cfg/visitors/typescript.js +492 -0
  21. package/dist/core/ingestion/language-provider.d.ts +11 -0
  22. package/dist/core/ingestion/languages/typescript.js +5 -0
  23. package/dist/core/ingestion/pipeline-phases/parse-impl.js +18 -1
  24. package/dist/core/ingestion/pipeline.d.ts +23 -0
  25. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +3 -0
  26. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +10 -0
  27. package/dist/core/ingestion/scope-resolution/pipeline/run.js +62 -0
  28. package/dist/core/ingestion/workers/parse-worker.js +40 -1
  29. package/dist/core/ingestion/workers/worker-pool.d.ts +9 -0
  30. package/dist/core/ingestion/workers/worker-pool.js +5 -2
  31. package/dist/core/run-analyze.d.ts +32 -0
  32. package/dist/core/run-analyze.js +75 -1
  33. package/dist/storage/parse-cache.d.ts +22 -1
  34. package/dist/storage/parse-cache.js +20 -10
  35. package/dist/storage/repo-manager.d.ts +31 -7
  36. package/package.json +1 -1
@@ -0,0 +1,49 @@
1
+ /**
2
+ * TS/JS CfgVisitor (issue #2081, M1).
3
+ *
4
+ * Walks a TypeScript/JavaScript function's tree-sitter AST and drives the
5
+ * language-agnostic {@link CfgBuilder} to produce a serializable
6
+ * {@link FunctionCfg}. TS and JS share a grammar family (tree-sitter-typescript
7
+ * reuses tree-sitter-javascript's statement nodes), so one visitor covers both.
8
+ *
9
+ * Design — a `visit_<node_type>` dispatch over the statement taxonomy. The
10
+ * classic CFG hazards (R10) are handled explicitly:
11
+ * - loops allocate a dedicated **loop-exit** block so `break` has a concrete
12
+ * target before the loop's successor is known; `continue` targets the
13
+ * header/increment; the back-edge closes the loop.
14
+ * - `switch` cases fall through naturally: a case body that does not `break`
15
+ * yields non-empty `exits`, which we wire to the next case as `fallthrough`;
16
+ * a case that `break`s wires to the switch exit (via {@link ControlFlowContext})
17
+ * and yields no fall-out.
18
+ * - `try/catch/finally` routes both normal completion AND a `throw` in the try
19
+ * through `finally` (the finally block post-dominates the try/catch); a
20
+ * `throw` with no catch propagates through finally to the enclosing handler.
21
+ * - labeled `break`/`continue` resolve against the labeled loop's frame.
22
+ *
23
+ * Known M1 limitations:
24
+ * - SOUNDNESS GAP (M2 blocker, not mere precision): a non-local jump
25
+ * (`break`/`continue`/`return`) out of a `try` that has a `finally` edges
26
+ * directly to its target rather than routing THROUGH the `finally` block
27
+ * first. A future taint/PDG pass will therefore MISS flow mediated by a
28
+ * `finally` on the early-exit path (e.g. a value the `finally` taints or
29
+ * sanitizes before the `return` reaches its target) — a false negative. The
30
+ * general fix duplicates `finally` per exit path; deferred past M1 and
31
+ * tracked for M2. Normal completion and `throw` DO route through `finally`.
32
+ * - A `break`/`continue` to a label on a non-loop/non-switch block, and the
33
+ * OUTER label of a doubly-labeled construct (`outer: inner: for (...)`), are
34
+ * not modeled. The jump is conservatively routed to the function EXIT (a
35
+ * sound over-approximation that keeps the graph single-exit — see visitBreak)
36
+ * rather than left as a dangling sink; only the precise labeled target is
37
+ * unmodeled. Single-labeled loops/switches resolve correctly.
38
+ *
39
+ * Block/edge accounting and reachability are pinned in
40
+ * `test/unit/cfg/cfg-builder.test.ts` (core) and
41
+ * `test/unit/cfg/typescript-visitor.test.ts` (this visitor, per hazard).
42
+ */
43
+ import type { SyntaxNode } from '../../utils/ast-helpers.js';
44
+ import type { CfgVisitor } from '../types.js';
45
+ /** TS/JS node types that own a CFG-bearing function body. */
46
+ declare const TS_FUNCTION_TYPES: Set<string>;
47
+ /** The TS/JS CFG visitor (shared by TypeScript and JavaScript). */
48
+ export declare function createTypeScriptCfgVisitor(): CfgVisitor<SyntaxNode>;
49
+ export { TS_FUNCTION_TYPES };
@@ -0,0 +1,492 @@
1
+ import { CfgBuilder } from '../cfg-builder.js';
2
+ import { ControlFlowContext } from '../control-flow-context.js';
3
+ /** TS/JS node types that own a CFG-bearing function body. */
4
+ const TS_FUNCTION_TYPES = new Set([
5
+ 'function_declaration',
6
+ 'function_expression',
7
+ 'arrow_function',
8
+ 'method_definition',
9
+ 'generator_function_declaration',
10
+ 'generator_function',
11
+ 'async_function_declaration',
12
+ 'async_arrow_function',
13
+ ]);
14
+ /** Statement node types that break a basic block (everything else coalesces). */
15
+ const CONTROL_FLOW_TYPES = new Set([
16
+ 'if_statement',
17
+ 'while_statement',
18
+ 'do_statement',
19
+ 'for_statement',
20
+ 'for_in_statement',
21
+ 'for_of_statement',
22
+ 'switch_statement',
23
+ 'try_statement',
24
+ 'return_statement',
25
+ 'break_statement',
26
+ 'continue_statement',
27
+ 'throw_statement',
28
+ 'labeled_statement',
29
+ 'statement_block',
30
+ ]);
31
+ const LOOP_OR_SWITCH_TYPES = new Set([
32
+ 'while_statement',
33
+ 'do_statement',
34
+ 'for_statement',
35
+ 'for_in_statement',
36
+ 'for_of_statement',
37
+ 'switch_statement',
38
+ ]);
39
+ const startLineOf = (n) => n.startPosition.row + 1;
40
+ const endLineOf = (n) => n.endPosition.row + 1;
41
+ /**
42
+ * Per-function walk state. One instance is created per function so the
43
+ * {@link ControlFlowContext}, exception-handler stack, and pending label are
44
+ * scoped to that function and never leak across functions.
45
+ */
46
+ class TsCfgWalk {
47
+ builder;
48
+ cfc = new ControlFlowContext();
49
+ /** Stack of exception-handler entry blocks (catch/finally) a `throw` jumps to. */
50
+ handlers = [];
51
+ /** Label awaiting the loop/switch it immediately precedes (labeled_statement). */
52
+ pendingLabel;
53
+ constructor(builder) {
54
+ this.builder = builder;
55
+ }
56
+ /** Statements of a block node, ignoring comments. */
57
+ statementsOf(block) {
58
+ return block.namedChildren.filter((c) => c.type !== 'comment');
59
+ }
60
+ /** The `body` block of a node (field, or the first statement_block child). */
61
+ bodyBlockOf(node) {
62
+ return (node.childForFieldName('body') ?? node.namedChildren.find((c) => c.type === 'statement_block'));
63
+ }
64
+ /** Visit a body that may be a `statement_block` or a single statement. */
65
+ visitBody(node) {
66
+ if (!node)
67
+ return null;
68
+ if (node.type === 'statement_block')
69
+ return this.visitSeq(this.statementsOf(node));
70
+ return this.visitStmt(node);
71
+ }
72
+ /** Wire a sequence of statements, coalescing straight-line runs into blocks. */
73
+ visitSeq(stmts) {
74
+ let entry;
75
+ let dangling = [];
76
+ let openSimple;
77
+ for (const stmt of stmts) {
78
+ if (CONTROL_FLOW_TYPES.has(stmt.type)) {
79
+ openSimple = undefined; // close any open straight-line block
80
+ const res = this.visitStmt(stmt);
81
+ if (res === null)
82
+ continue; // transparent (empty nested block)
83
+ if (entry === undefined)
84
+ entry = res.entry;
85
+ else
86
+ this.builder.connect(dangling, res.entry, 'seq');
87
+ dangling = [...res.exits];
88
+ }
89
+ else {
90
+ // Simple statement — coalesce into the current straight-line block.
91
+ if (openSimple === undefined) {
92
+ const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
93
+ if (entry === undefined)
94
+ entry = idx;
95
+ else
96
+ this.builder.connect(dangling, idx, 'seq');
97
+ openSimple = idx;
98
+ dangling = [idx];
99
+ }
100
+ else {
101
+ this.builder.extendBlock(openSimple, endLineOf(stmt), stmt.text);
102
+ }
103
+ }
104
+ }
105
+ if (entry === undefined)
106
+ return null;
107
+ return { entry, exits: dangling };
108
+ }
109
+ /** Dispatch one statement to its handler. Non-null except for empty blocks. */
110
+ visitStmt(stmt) {
111
+ switch (stmt.type) {
112
+ case 'if_statement':
113
+ return this.visitIf(stmt);
114
+ case 'while_statement':
115
+ return this.visitWhile(stmt);
116
+ case 'do_statement':
117
+ return this.visitDoWhile(stmt);
118
+ case 'for_statement':
119
+ return this.visitFor(stmt);
120
+ case 'for_in_statement':
121
+ case 'for_of_statement':
122
+ return this.visitForIn(stmt);
123
+ case 'switch_statement':
124
+ return this.visitSwitch(stmt);
125
+ case 'try_statement':
126
+ return this.visitTry(stmt);
127
+ case 'return_statement':
128
+ return this.visitReturn(stmt);
129
+ case 'throw_statement':
130
+ return this.visitThrow(stmt);
131
+ case 'break_statement':
132
+ return this.visitBreak(stmt);
133
+ case 'continue_statement':
134
+ return this.visitContinue(stmt);
135
+ case 'labeled_statement':
136
+ return this.visitLabeled(stmt);
137
+ case 'statement_block':
138
+ return this.visitSeq(this.statementsOf(stmt));
139
+ default:
140
+ return this.visitSimple(stmt);
141
+ }
142
+ }
143
+ visitSimple(stmt) {
144
+ const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
145
+ return { entry: idx, exits: [idx] };
146
+ }
147
+ visitReturn(stmt) {
148
+ const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
149
+ this.builder.edge(idx, this.builder.exitIndex, 'return');
150
+ return { entry: idx, exits: [] };
151
+ }
152
+ visitThrow(stmt) {
153
+ const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
154
+ this.builder.edge(idx, this.currentHandler(), 'throw');
155
+ return { entry: idx, exits: [] };
156
+ }
157
+ visitBreak(stmt) {
158
+ const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
159
+ const target = this.cfc.breakTarget(this.labelOf(stmt));
160
+ // An unresolved target — a label this M1 visitor doesn't model (a stacked
161
+ // outer label like `outer: inner: for`, or a labeled non-loop block) —
162
+ // would otherwise leave this block with NO out-edge, stranding it and
163
+ // breaking the single-exit invariant a downstream post-dominator / PDG pass
164
+ // relies on. Conservatively route an unresolved jump to the function EXIT
165
+ // ("escapes the function"): sound over-approximation, keeps single-exit.
166
+ this.builder.edge(idx, target ?? this.builder.exitIndex, 'break');
167
+ return { entry: idx, exits: [] };
168
+ }
169
+ visitContinue(stmt) {
170
+ const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
171
+ const target = this.cfc.continueTarget(this.labelOf(stmt));
172
+ // See visitBreak: an unresolved label routes to EXIT to preserve single-exit.
173
+ this.builder.edge(idx, target ?? this.builder.exitIndex, 'continue');
174
+ return { entry: idx, exits: [] };
175
+ }
176
+ visitLabeled(stmt) {
177
+ const body = stmt.childForFieldName('body') ?? stmt.namedChildren[stmt.namedChildren.length - 1];
178
+ if (body && LOOP_OR_SWITCH_TYPES.has(body.type)) {
179
+ this.pendingLabel = this.labelOf(stmt);
180
+ const res = this.visitStmt(body);
181
+ this.pendingLabel = undefined; // clear even if the construct didn't consume it
182
+ return res;
183
+ }
184
+ // Labeled non-loop blocks (break-to-block-label) are not modeled in M1.
185
+ return this.visitBody(body);
186
+ }
187
+ visitIf(stmt) {
188
+ const cond = stmt.childForFieldName('condition') ?? stmt;
189
+ const condBlock = this.builder.newBlock(startLineOf(stmt), endLineOf(cond), cond.text);
190
+ const exits = [];
191
+ const thenRes = this.visitBody(stmt.childForFieldName('consequence'));
192
+ if (thenRes) {
193
+ this.builder.edge(condBlock, thenRes.entry, 'cond-true');
194
+ exits.push(...thenRes.exits);
195
+ }
196
+ else {
197
+ exits.push(condBlock); // empty then — true path falls through
198
+ }
199
+ const elseNode = this.elseBodyOf(stmt);
200
+ if (elseNode) {
201
+ const elseRes = this.visitBody(elseNode);
202
+ if (elseRes) {
203
+ this.builder.edge(condBlock, elseRes.entry, 'cond-false');
204
+ exits.push(...elseRes.exits);
205
+ }
206
+ else {
207
+ exits.push(condBlock); // empty else block
208
+ }
209
+ }
210
+ else {
211
+ exits.push(condBlock); // no else — false path falls through to the join
212
+ }
213
+ return { entry: condBlock, exits: [...new Set(exits)] };
214
+ }
215
+ /** The else body node (unwraps an `else_clause` wrapper if present). */
216
+ elseBodyOf(ifStmt) {
217
+ const alt = ifStmt.childForFieldName('alternative');
218
+ if (!alt)
219
+ return undefined;
220
+ if (alt.type === 'else_clause') {
221
+ return alt.childForFieldName('body') ?? alt.namedChildren[0];
222
+ }
223
+ return alt;
224
+ }
225
+ visitWhile(stmt) {
226
+ const label = this.takeLabel();
227
+ const cond = stmt.childForFieldName('condition') ?? stmt;
228
+ const header = this.builder.newBlock(startLineOf(stmt), endLineOf(cond), cond.text);
229
+ const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
230
+ this.cfc.pushLoop(header, loopExit, label);
231
+ const body = this.visitBody(this.bodyBlockOf(stmt));
232
+ this.cfc.pop();
233
+ if (body) {
234
+ this.builder.edge(header, body.entry, 'cond-true');
235
+ this.builder.connect(body.exits, header, 'loop-back');
236
+ }
237
+ else {
238
+ this.builder.edge(header, header, 'loop-back'); // empty body re-tests
239
+ }
240
+ this.builder.edge(header, loopExit, 'cond-false');
241
+ return { entry: header, exits: [loopExit] };
242
+ }
243
+ visitDoWhile(stmt) {
244
+ const label = this.takeLabel();
245
+ const cond = stmt.childForFieldName('condition') ?? stmt;
246
+ const condBlock = this.builder.newBlock(startLineOf(cond), endLineOf(cond), cond.text);
247
+ const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
248
+ this.cfc.pushLoop(condBlock, loopExit, label);
249
+ const body = this.visitBody(this.bodyBlockOf(stmt));
250
+ this.cfc.pop();
251
+ const backTarget = body ? body.entry : condBlock;
252
+ if (body)
253
+ this.builder.connect(body.exits, condBlock, 'seq');
254
+ this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again
255
+ this.builder.edge(condBlock, loopExit, 'cond-false');
256
+ return { entry: backTarget, exits: [loopExit] };
257
+ }
258
+ visitFor(stmt) {
259
+ const label = this.takeLabel();
260
+ const init = stmt.childForFieldName('initializer');
261
+ const cond = stmt.childForFieldName('condition');
262
+ const incr = stmt.childForFieldName('increment');
263
+ const header = this.builder.newBlock(startLineOf(stmt), cond ? endLineOf(cond) : startLineOf(stmt), cond ? cond.text : 'for(;;)');
264
+ const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
265
+ let incrBlock = header;
266
+ if (incr) {
267
+ incrBlock = this.builder.newBlock(startLineOf(incr), endLineOf(incr), incr.text);
268
+ this.builder.edge(incrBlock, header, 'loop-back');
269
+ }
270
+ this.cfc.pushLoop(incrBlock, loopExit, label);
271
+ const body = this.visitBody(this.bodyBlockOf(stmt));
272
+ this.cfc.pop();
273
+ if (body) {
274
+ this.builder.edge(header, body.entry, 'cond-true');
275
+ // With no increment clause the body's exits ARE the back-edge — carry
276
+ // the loop-back kind on them (mirroring visitWhile/visitForIn) instead
277
+ // of a phantom header→header self-loop that models a path which never
278
+ // executes the body. With an increment, the body falls through to the
279
+ // increment (`seq`) and the increment carries the loop-back (:338).
280
+ this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back');
281
+ }
282
+ else {
283
+ this.builder.edge(header, incrBlock, 'cond-true');
284
+ // Empty body with no increment: the header genuinely re-tests itself.
285
+ if (!incr)
286
+ this.builder.edge(header, header, 'loop-back');
287
+ }
288
+ this.builder.edge(header, loopExit, 'cond-false');
289
+ let entry = header;
290
+ if (init) {
291
+ const initBlock = this.builder.newBlock(startLineOf(init), endLineOf(init), init.text);
292
+ this.builder.edge(initBlock, header, 'seq');
293
+ entry = initBlock;
294
+ }
295
+ return { entry, exits: [loopExit] };
296
+ }
297
+ visitForIn(stmt) {
298
+ const label = this.takeLabel();
299
+ const header = this.builder.newBlock(startLineOf(stmt), startLineOf(stmt), this.forInHeaderText(stmt));
300
+ const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
301
+ this.cfc.pushLoop(header, loopExit, label);
302
+ const body = this.visitBody(this.bodyBlockOf(stmt));
303
+ this.cfc.pop();
304
+ if (body) {
305
+ this.builder.edge(header, body.entry, 'cond-true');
306
+ this.builder.connect(body.exits, header, 'loop-back');
307
+ }
308
+ else {
309
+ this.builder.edge(header, header, 'loop-back');
310
+ }
311
+ this.builder.edge(header, loopExit, 'cond-false');
312
+ return { entry: header, exits: [loopExit] };
313
+ }
314
+ forInHeaderText(stmt) {
315
+ const left = stmt.childForFieldName('left')?.text ?? '';
316
+ const right = stmt.childForFieldName('right')?.text ?? '';
317
+ return left || right ? `for(${left} … ${right})` : 'for(… in/of …)';
318
+ }
319
+ visitSwitch(stmt) {
320
+ const label = this.takeLabel();
321
+ const value = stmt.childForFieldName('value') ?? stmt;
322
+ const dispatch = this.builder.newBlock(startLineOf(stmt), endLineOf(value), value.text);
323
+ const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
324
+ this.cfc.pushSwitch(switchExit, label);
325
+ const body = stmt.childForFieldName('body');
326
+ const cases = body
327
+ ? body.namedChildren.filter((c) => c.type === 'switch_case' || c.type === 'switch_default')
328
+ : [];
329
+ const caseResults = cases.map((c) => this.visitSeq(this.caseStatements(c)));
330
+ const hasDefault = cases.some((c) => c.type === 'switch_default');
331
+ // entryOf[i] = block a dispatch/fallthrough INTO case i lands on (empty
332
+ // cases are transparent — they resolve to the next case, or the exit).
333
+ const entryOf = new Array(cases.length);
334
+ let after = switchExit;
335
+ for (let i = cases.length - 1; i >= 0; i--) {
336
+ entryOf[i] = caseResults[i]?.entry ?? after;
337
+ after = entryOf[i];
338
+ }
339
+ for (let i = 0; i < cases.length; i++) {
340
+ this.builder.edge(dispatch, entryOf[i], 'switch-case');
341
+ }
342
+ if (!hasDefault)
343
+ this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path
344
+ for (let i = 0; i < cases.length; i++) {
345
+ const res = caseResults[i];
346
+ if (!res)
347
+ continue;
348
+ const fallTarget = i + 1 < cases.length ? entryOf[i + 1] : switchExit;
349
+ this.builder.connect(res.exits, fallTarget, 'fallthrough');
350
+ }
351
+ this.cfc.pop();
352
+ return { entry: dispatch, exits: [switchExit] };
353
+ }
354
+ caseStatements(caseNode) {
355
+ const value = caseNode.childForFieldName('value');
356
+ return caseNode.namedChildren.filter((c) => c.id !== value?.id && c.type !== 'comment');
357
+ }
358
+ visitTry(stmt) {
359
+ const bodyNode = stmt.childForFieldName('body');
360
+ // Single pass over named children — tree-sitter's `namedChildren` getter
361
+ // allocates a fresh array on every access, so avoid the double `.find`.
362
+ let catchClause;
363
+ let finallyClause;
364
+ for (let i = 0; i < stmt.namedChildCount; i++) {
365
+ const c = stmt.namedChild(i);
366
+ if (c?.type === 'catch_clause')
367
+ catchClause = c;
368
+ else if (c?.type === 'finally_clause')
369
+ finallyClause = c;
370
+ }
371
+ // Build finally first so its entry is known as both a normal join and a
372
+ // handler target. The finally body runs in the OUTER handler context.
373
+ const finallyRes = finallyClause
374
+ ? this.visitSeq(this.statementsOf(this.bodyBlockOf(finallyClause)))
375
+ : null;
376
+ // A throw inside catch propagates to finally (if any), else the outer handler.
377
+ let catchRes = null;
378
+ if (catchClause) {
379
+ if (finallyRes)
380
+ this.handlers.push(finallyRes.entry);
381
+ catchRes = this.visitSeq(this.statementsOf(this.bodyBlockOf(catchClause)));
382
+ if (finallyRes)
383
+ this.handlers.pop();
384
+ if (catchRes === null) {
385
+ // Empty (or comment-only) catch body — `catch {}`. The clause still
386
+ // CATCHES: handler semantics key off the syntactic clause, not the
387
+ // traversal result. Treating it as "no catch" sent the swallowed
388
+ // exception to the outer handler/EXIT and left post-try code
389
+ // unreachable when the body always throws — a hard false-negative
390
+ // for downstream taint. Synthesize one empty block spanning the
391
+ // clause (entry == sole exit) so exception flow lands in it and
392
+ // rejoins the normal continuation. Created BEFORE the protected
393
+ // region is walked, so it never receives a spurious throw edge.
394
+ const idx = this.builder.newBlock(startLineOf(catchClause), endLineOf(catchClause), '');
395
+ catchRes = { entry: idx, exits: [idx] };
396
+ }
397
+ }
398
+ // Handler for the try body: catch if present, else finally, else outer.
399
+ const tryHandler = catchRes?.entry ?? finallyRes?.entry ?? this.currentHandler();
400
+ const protectedStart = this.builder.blockCount;
401
+ this.handlers.push(tryHandler);
402
+ const bodyRes = bodyNode ? this.visitSeq(this.statementsOf(bodyNode)) : null;
403
+ this.handlers.pop();
404
+ // Conservative exceptional edges: ANY block in the protected region may raise
405
+ // to the handler — not just an explicit `throw`, and not just the body ENTRY.
406
+ // Edging every block created during the try-body walk keeps exception flow
407
+ // sound when the body BRANCHES: an `if` / nested-try / post-branch block whose
408
+ // interior blocks would otherwise have no path to the handler — i.e. a taint
409
+ // false-negative into `catch` for the downstream PDG analysis. The
410
+ // per-function edge cap bounds the count; explicit `throw`s add their own
411
+ // (idempotent) edge to the same handler.
412
+ if (catchClause || finallyClause) {
413
+ for (let b = protectedStart; b < this.builder.blockCount; b++) {
414
+ this.builder.edge(b, tryHandler, 'throw');
415
+ }
416
+ }
417
+ const exits = [];
418
+ if (finallyRes) {
419
+ // Normal completion of try AND catch both flow through finally.
420
+ if (bodyRes)
421
+ this.builder.connect(bodyRes.exits, finallyRes.entry, 'seq');
422
+ if (catchRes)
423
+ this.builder.connect(catchRes.exits, finallyRes.entry, 'seq');
424
+ exits.push(...finallyRes.exits);
425
+ // No catch → an exception re-propagates out after finally runs.
426
+ if (!catchRes)
427
+ this.builder.connect(finallyRes.exits, this.currentHandler(), 'throw');
428
+ }
429
+ else {
430
+ if (bodyRes)
431
+ exits.push(...bodyRes.exits);
432
+ if (catchRes)
433
+ exits.push(...catchRes.exits);
434
+ }
435
+ const entry = bodyRes?.entry ?? finallyRes?.entry ?? catchRes?.entry;
436
+ if (entry === undefined)
437
+ return null;
438
+ return { entry, exits: [...new Set(exits)] };
439
+ }
440
+ /** Nearest enclosing exception handler, or the function EXIT. */
441
+ currentHandler() {
442
+ return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex;
443
+ }
444
+ /** Consume the label awaiting the loop/switch this call is building. */
445
+ takeLabel() {
446
+ const label = this.pendingLabel;
447
+ this.pendingLabel = undefined;
448
+ return label;
449
+ }
450
+ labelOf(stmt) {
451
+ const id = stmt.childForFieldName('label') ??
452
+ stmt.namedChildren.find((c) => c.type === 'statement_identifier');
453
+ return id?.text;
454
+ }
455
+ }
456
+ /** Build the CFG for one TS/JS function node (or `undefined` if not a function). */
457
+ function buildFunctionCfg(fnNode, filePath) {
458
+ if (!TS_FUNCTION_TYPES.has(fnNode.type))
459
+ return undefined;
460
+ const startLine = startLineOf(fnNode);
461
+ const endLine = endLineOf(fnNode);
462
+ const startColumn = fnNode.startPosition.column;
463
+ const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
464
+ const body = fnNode.childForFieldName('body');
465
+ if (!body)
466
+ return undefined; // overload signature / abstract method — no body
467
+ if (body.type !== 'statement_block') {
468
+ // Expression-bodied arrow: `() => expr` — one block whose value is returned.
469
+ const blk = builder.newBlock(startLineOf(body), endLineOf(body), body.text);
470
+ builder.edge(builder.entryIndex, blk, 'seq');
471
+ builder.edge(blk, builder.exitIndex, 'return');
472
+ return builder.finish();
473
+ }
474
+ const walk = new TsCfgWalk(builder);
475
+ const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
476
+ if (!res) {
477
+ builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
478
+ return builder.finish();
479
+ }
480
+ builder.edge(builder.entryIndex, res.entry, 'seq');
481
+ builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT
482
+ return builder.finish();
483
+ }
484
+ /** Whether a node is a TS/JS function this visitor builds a CFG for. */
485
+ function isFunction(node) {
486
+ return TS_FUNCTION_TYPES.has(node.type);
487
+ }
488
+ /** The TS/JS CFG visitor (shared by TypeScript and JavaScript). */
489
+ export function createTypeScriptCfgVisitor() {
490
+ return { buildFunctionCfg, isFunction };
491
+ }
492
+ export { TS_FUNCTION_TYPES };
@@ -19,6 +19,7 @@ import type { MethodExtractor } from './method-types.js';
19
19
  import type { VariableExtractor } from './variable-types.js';
20
20
  import type { ImportResolverFn } from './import-resolvers/types.js';
21
21
  import type { SyntaxNode } from './utils/ast-helpers.js';
22
+ import type { CfgVisitor } from './cfg/types.js';
22
23
  import type { NodeLabel } from '../../_shared/index.js';
23
24
  import type Parser from 'tree-sitter';
24
25
  import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
@@ -284,6 +285,16 @@ interface LanguageProviderConfig {
284
285
  * Default: undefined (provider has no capture-time module-level side effects).
285
286
  */
286
287
  readonly collectCaptureSideChannel?: (filePath: string) => unknown;
288
+ /**
289
+ * Per-language control-flow-graph builder (#2081 M1, PDG/taint substrate).
290
+ * Invoked IN THE PARSE WORKER (where the AST lives) for each function node,
291
+ * gated on the `--pdg` opt-in; the resulting per-function CFGs are serialized
292
+ * onto `ParsedFile.cfgSideChannel` and emitted as BasicBlock nodes + CFG
293
+ * edges during scope-resolution. `TNode` is `SyntaxNode` for the tree-sitter
294
+ * languages. Default: undefined (language has no CFG support yet — TS/JS are
295
+ * the M1 set).
296
+ */
297
+ readonly cfgVisitor?: CfgVisitor<SyntaxNode>;
287
298
  /**
288
299
  * Interpret a raw `@import.statement` capture group into a `ParsedImport`.
289
300
  * The central finalize algorithm resolves `ParsedImport.targetRaw` to a
@@ -9,6 +9,7 @@ import { SupportedLanguages } from '../../../_shared/index.js';
9
9
  import { defineLanguage } from '../language-provider.js';
10
10
  import { createClassExtractor } from '../class-extractors/generic.js';
11
11
  import { typescriptClassConfig, javascriptClassConfig, } from '../class-extractors/configs/typescript-javascript.js';
12
+ import { createTypeScriptCfgVisitor } from '../cfg/visitors/typescript.js';
12
13
  import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
13
14
  import { tsExportChecker } from '../export-detection.js';
14
15
  import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -293,6 +294,8 @@ export const typescriptProvider = defineLanguage({
293
294
  // canonical capture vocabulary in ./typescript/query.ts
294
295
  // (TYPESCRIPT_SCOPE_QUERY constant).
295
296
  emitScopeCaptures: emitTsScopeCaptures,
297
+ // CFG/PDG substrate (#2081 M1) — runs in the worker on a --pdg run.
298
+ cfgVisitor: createTypeScriptCfgVisitor(),
296
299
  interpretImport: interpretTsImport,
297
300
  interpretTypeBinding: interpretTsTypeBinding,
298
301
  bindingScopeFor: tsBindingScopeFor,
@@ -352,6 +355,8 @@ export const javascriptProvider = defineLanguage({
352
355
  // JSDoc type bindings) live in ./javascript/captures.ts.
353
356
  // See ./javascript/index.ts for the full per-module rationale.
354
357
  emitScopeCaptures: emitJsScopeCaptures,
358
+ // CFG/PDG substrate (#2081 M1) — TS and JS share the same grammar family.
359
+ cfgVisitor: createTypeScriptCfgVisitor(),
355
360
  interpretImport: interpretJsImport,
356
361
  interpretTypeBinding: interpretJsTypeBinding,
357
362
  bindingScopeFor: jsBindingScopeFor,
@@ -18,6 +18,7 @@ import { BindingAccumulator, enrichExportedTypeMap, } from '../binding-accumulat
18
18
  import { mergeChunkResults, dispatchChunkParse } from '../parsing-processor.js';
19
19
  import { fileContentHash, computeChunkHash, loadParseCacheChunk, persistParseCacheChunk, PARSE_CACHE_VERSION, } from '../../../storage/parse-cache.js';
20
20
  import { clearParsedFileStore, persistParsedFileChunk, getDurableParsedFileDir, loadDurableParsedFileIndex, restoreDurableParsedFileShard, } from '../../../storage/parsedfile-store.js';
21
+ import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
21
22
  import { processRoutesFromExtracted, buildExportedTypeMapFromGraph, } from '../call-processor.js';
22
23
  import { createSemanticModel } from '../model/index.js';
23
24
  import { getLanguageFromFilename, } from '../../../_shared/index.js';
@@ -319,6 +320,10 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
319
320
  // Initialized below before the chunk loop (same deferred-init pattern
320
321
  // as `parsedFileStorePath`); this closure only runs from the loop.
321
322
  durableParsedFileStoragePath: durableParsedFileDir,
323
+ // CFG/PDG opt-in (#2081 M1) — baked into each worker's workerData so the
324
+ // worker builds + attaches cfgSideChannel. Off by default.
325
+ pdg: options?.pdg === true,
326
+ pdgMaxFunctionLines: options?.pdgMaxFunctionLines,
322
327
  // Fan each chunk across the whole pool (#worker-idle): without this a
323
328
  // chunk smaller than the 8 MB sub-batch cap became a single job on a
324
329
  // single worker. Honors an explicit `subBatchMaxBytes` / env override.
@@ -566,7 +571,19 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
566
571
  filePath: f.path,
567
572
  contentHash: fileContentHash(f.content),
568
573
  }));
569
- chunkHash = computeChunkHash(entries);
574
+ chunkHash = computeChunkHash(entries,
575
+ // Only worker-visible pdg config participates in the key —
576
+ // pdgMaxEdgesPerFunction is emit-time-only and deliberately
577
+ // excluded (see PdgCacheKey in parse-cache.ts; #2099 F3). The line
578
+ // cap is RESOLVED to the worker's default before folding so an
579
+ // explicit-default run shares the default run's keys (the worker
580
+ // output is byte-identical either way).
581
+ options?.pdg === true
582
+ ? {
583
+ pdg: true,
584
+ maxFunctionLines: options?.pdgMaxFunctionLines ?? DEFAULT_PDG_MAX_FUNCTION_LINES,
585
+ }
586
+ : false);
570
587
  }
571
588
  const cachedRaw = chunkHash && parseCache ? await loadParseCacheChunk(parseCache, chunkHash) : undefined;
572
589
  // Track every chunk hash we touched so the orchestrator can
@@ -25,6 +25,29 @@ export interface PipelineOptions {
25
25
  * to retain those nodes under `skipGraphPhases`.
26
26
  */
27
27
  skipGraphPhases?: boolean;
28
+ /**
29
+ * Build the control-flow-graph / PDG substrate (#2081 M1, opt-in via `--pdg`).
30
+ * Off by default: workers skip all CFG work and emit no `cfgSideChannel`, and
31
+ * scope-resolution emits no BasicBlock nodes or CFG edges — so the default
32
+ * graph is byte-identical to a pre-#2081 run. Folded into the parse-cache key
33
+ * so a pdg-off warm cache is not reused on a `--pdg` run.
34
+ */
35
+ pdg?: boolean;
36
+ /**
37
+ * Per-function source-line cap for worker-side CFG construction.
38
+ * `undefined` ⇒ the worker applies `DEFAULT_PDG_MAX_FUNCTION_LINES`; `0` ⇒ no
39
+ * cap (unlimited). Bounds the cost of a pathological mega-function; over-cap
40
+ * functions are skipped (no CFG emitted for them). No CLI flag in M1 —
41
+ * programmatic / server analyze-worker path only.
42
+ */
43
+ pdgMaxFunctionLines?: number;
44
+ /**
45
+ * Per-function CFG edge cap for the scope-resolution emit step.
46
+ * `undefined` ⇒ `DEFAULT_MAX_CFG_EDGES_PER_FUNCTION`; `0` ⇒ no cap (unlimited).
47
+ * Over-cap functions stop at the cap and log a structured drop warning (no
48
+ * silent truncation). No CLI flag in M1 — programmatic / server path only.
49
+ */
50
+ pdgMaxEdgesPerFunction?: number;
28
51
  /**
29
52
  * Request parsing with the worker pool disabled. The sequential parser was
30
53
  * removed — the worker pool is the sole parse path — so setting this now