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.
- package/dist/_shared/scope-resolution/parsed-file.d.ts +21 -0
- package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
- package/dist/cli/analyze-config.js +1 -0
- package/dist/cli/analyze.d.ts +6 -0
- package/dist/cli/analyze.js +2 -0
- package/dist/cli/index.js +2 -0
- package/dist/core/ingestion/cfg/cfg-builder.d.ts +51 -0
- package/dist/core/ingestion/cfg/cfg-builder.js +100 -0
- package/dist/core/ingestion/cfg/collect.d.ts +30 -0
- package/dist/core/ingestion/cfg/collect.js +34 -0
- package/dist/core/ingestion/cfg/control-flow-context.d.ts +27 -0
- package/dist/core/ingestion/cfg/control-flow-context.js +49 -0
- package/dist/core/ingestion/cfg/emit.d.ts +64 -0
- package/dist/core/ingestion/cfg/emit.js +95 -0
- package/dist/core/ingestion/cfg/traversal-result.d.ts +20 -0
- package/dist/core/ingestion/cfg/traversal-result.js +2 -0
- package/dist/core/ingestion/cfg/types.d.ts +66 -0
- package/dist/core/ingestion/cfg/types.js +13 -0
- package/dist/core/ingestion/cfg/visitors/typescript.d.ts +49 -0
- package/dist/core/ingestion/cfg/visitors/typescript.js +492 -0
- package/dist/core/ingestion/language-provider.d.ts +11 -0
- package/dist/core/ingestion/languages/typescript.js +5 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +18 -1
- package/dist/core/ingestion/pipeline.d.ts +23 -0
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +3 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +10 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +62 -0
- package/dist/core/ingestion/workers/parse-worker.js +40 -1
- package/dist/core/ingestion/workers/worker-pool.d.ts +9 -0
- package/dist/core/ingestion/workers/worker-pool.js +5 -2
- package/dist/core/run-analyze.d.ts +32 -0
- package/dist/core/run-analyze.js +75 -1
- package/dist/storage/parse-cache.d.ts +22 -1
- package/dist/storage/parse-cache.js +20 -10
- package/dist/storage/repo-manager.d.ts +31 -7
- 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
|