@clear-capabilities/agentic-security-scanner 0.151.3 → 0.153.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/1336.index.js +16010 -0
  3. package/dist/agentic-security.mjs +80 -80
  4. package/dist/agentic-security.mjs.sha256 +1 -1
  5. package/package.json +7 -6
  6. package/src/dataflow/CLAUDE.md +1 -1
  7. package/src/dataflow/backward.js +104 -7
  8. package/src/dataflow/catalog.js +802 -13
  9. package/src/dataflow/cross-service-taint.js +154 -11
  10. package/src/dataflow/engine.js +875 -90
  11. package/src/dataflow/exploit-prover.js +111 -23
  12. package/src/dataflow/incremental.js +13 -0
  13. package/src/dataflow/index.js +29 -6
  14. package/src/dataflow/path-feasibility.js +86 -9
  15. package/src/dataflow/string-domain.js +168 -0
  16. package/src/dataflow/summaries.js +69 -8
  17. package/src/engine.js +118 -9
  18. package/src/ir/CLAUDE.md +2 -1
  19. package/src/ir/balanced-call.js +33 -1
  20. package/src/ir/callgraph.js +26 -2
  21. package/src/ir/class-hierarchy.js +261 -7
  22. package/src/ir/csharp-ir.js +97 -8
  23. package/src/ir/index.js +21 -1
  24. package/src/ir/parser-cs.js +706 -24
  25. package/src/ir/parser-java.js +498 -17
  26. package/src/ir/parser-js.js +22 -0
  27. package/src/ir/parser-php.js +446 -43
  28. package/src/ir/parser-rust.js +1338 -0
  29. package/src/ir/php-include-merge.js +190 -0
  30. package/src/lineage/sink-registry.js +18 -0
  31. package/src/lineage/source-registry.js +23 -0
  32. package/src/posture/csharp-analysis.js +341 -43
  33. package/src/posture/finding-defaults.js +5 -1
  34. package/src/posture/fix-coverage.js +1 -0
  35. package/src/posture/proof-coverage.js +1 -0
  36. package/src/report/index.js +12 -0
  37. package/src/sast/CLAUDE.md +1 -1
  38. package/src/sast/authz.js +144 -1
  39. package/src/sast/crypto-protocol.js +65 -7
  40. package/src/sast/csharp-cross-file.js +125 -0
  41. package/src/sast/csharp-structural.js +78 -2
  42. package/src/sast/csharp.js +574 -42
  43. package/src/sast/java-ast-folding.js +268 -11
  44. package/src/sast/java-bench-extras.js +530 -2
  45. package/src/sast/java-deserialization.js +31 -2
  46. package/src/sast/java-structural-cross-file.js +146 -0
  47. package/src/sast/java-structural.js +499 -6
  48. package/src/sast/ldap-injection.js +133 -3
  49. package/src/sast/rust.js +57 -0
  50. package/src/sast/xss-reflected-multilang.js +252 -3
  51. package/src/dataflow/smt-feasibility.js +0 -189
@@ -0,0 +1,1338 @@
1
+ // Rust IR frontend.
2
+ //
3
+ // Hand-rolled, following the parser-go.js / parser-cs.js pattern: one
4
+ // preprocessing pass normalizes the source (comments blanked with line
5
+ // structure preserved, `::` paths flattened to dots, turbofish/`?`/`.await`
6
+ // removed), then a statement splitter and a recursive CFG builder lower each
7
+ // function body into the IR contract documented in ./CLAUDE.md.
8
+ //
9
+ // What we model:
10
+ // - fn items (free, impl, trait default) with pub/async/unsafe/const/extern
11
+ // qualifiers, generics, where clauses; impl methods are named `Type.method`
12
+ // - let / let mut / destructuring let / let-else; assignments; compound
13
+ // assignments (`+=` lowers to a template of old value + rhs)
14
+ // - calls and method chains, dot-joined, args accumulated innermost-first
15
+ // (source order), so `Command::new("sh").arg("-c").arg(x)` has the literal
16
+ // program name at arg 0
17
+ // - macros: format!-family lower to templates (inline `{ident}` args and
18
+ // positional args), vec! to arrays, every other macro to a call whose
19
+ // callee keeps its `!` (so `sqlx::query!` never matches the `query` sink)
20
+ // - if / else if / else, if let, while, while let, loop, for, match arms
21
+ // (pattern bindings assigned from the scrutinee), unsafe/bare/async blocks
22
+ // - control flow in expression position (`let x = match … { … }`) yields a
23
+ // union of the branch tails
24
+ // - closures passed as call arguments are inlined, parameters bound to the
25
+ // call's receiver; extractor-typed closure params become synthetic sources
26
+ // - fn.paramAnnotations for framework extractor types (Query/Path/Json/Form/
27
+ // HeaderMap/…) and for route-attributed handlers (`#[get("/<x>")]`)
28
+ //
29
+ // What we do NOT model:
30
+ // - trait dispatch, generics as types, lifetimes (stripped as text)
31
+ // - closures stored in variables and invoked later
32
+ // - struct field taint through method receivers beyond simple dotted paths
33
+ // - labeled breaks, `?` early-return control flow (the `?` is stripped, the
34
+ // happy path is what taint needs)
35
+
36
+ import * as crypto from 'node:crypto';
37
+ import { callSitesFromCfg } from './call-sites.js';
38
+
39
+ const U = { kind: 'unknown' };
40
+
41
+ // ─── literal scanning ────────────────────────────────────────────────────────
42
+ // Returns the index just past a string/char literal starting at `i`, or `i`
43
+ // when no literal starts there. Every splitter below routes through this so
44
+ // braces, commas and semicolons inside literals never count.
45
+ function _literalEnd(s, i) {
46
+ const c = s[i];
47
+ const prevIsWord = i > 0 && /[\w]/.test(s[i - 1]);
48
+ if (c === '"') return _scanDq(s, i + 1);
49
+ if (c === '\'') {
50
+ if (s[i + 1] === '\\') {
51
+ // '\n', '\'', '\u{1F600}'
52
+ let j = i + 2;
53
+ if (s[j] === 'u' && s[j + 1] === '{') { j = s.indexOf('}', j); if (j < 0) return i; j++; } else j++;
54
+ return s[j] === '\'' ? j + 1 : i;
55
+ }
56
+ const cp = s.codePointAt(i + 1);
57
+ const w = cp > 0xffff ? 2 : 1;
58
+ if (s[i + 1 + w] === '\'' && s[i + 1] !== '\'') return i + 2 + w;
59
+ return i; // lifetime or label
60
+ }
61
+ if (prevIsWord) return i;
62
+ // raw / byte / c-string prefixes
63
+ const m = /^(?:br|cr|r)(#*)"/.exec(s.slice(i, i + 12));
64
+ if (m) {
65
+ const hashes = m[1];
66
+ const close = '"' + hashes;
67
+ const j = s.indexOf(close, i + m[0].length);
68
+ return j < 0 ? s.length : j + close.length;
69
+ }
70
+ if ((c === 'b' || c === 'c') && s[i + 1] === '"') return _scanDq(s, i + 2);
71
+ if (c === 'b' && s[i + 1] === '\'') {
72
+ const e = _literalEnd(s, i + 1);
73
+ return e > i + 1 ? e : i;
74
+ }
75
+ return i;
76
+ }
77
+
78
+ function _scanDq(s, i) {
79
+ for (; i < s.length; i++) {
80
+ if (s[i] === '\\') { i++; continue; }
81
+ if (s[i] === '"') return i + 1;
82
+ }
83
+ return s.length;
84
+ }
85
+
86
+ // ─── preprocessing ───────────────────────────────────────────────────────────
87
+ // Newlines are preserved exactly so every offset in the preprocessed text maps
88
+ // to the same line in the original file.
89
+ function _preprocess(src) {
90
+ let out = '';
91
+ let i = 0;
92
+ const n = src.length;
93
+ while (i < n) {
94
+ const c = src[i];
95
+ const d = src[i + 1];
96
+ if (c === '/' && d === '/') {
97
+ while (i < n && src[i] !== '\n') { out += ' '; i++; }
98
+ continue;
99
+ }
100
+ if (c === '/' && d === '*') {
101
+ let depth = 1;
102
+ out += ' '; i += 2;
103
+ while (i < n && depth > 0) {
104
+ if (src[i] === '/' && src[i + 1] === '*') { depth++; out += ' '; i += 2; continue; }
105
+ if (src[i] === '*' && src[i + 1] === '/') { depth--; out += ' '; i += 2; continue; }
106
+ out += src[i] === '\n' ? '\n' : ' ';
107
+ i++;
108
+ }
109
+ continue;
110
+ }
111
+ const lit = _literalEnd(src, i);
112
+ if (lit > i) { out += src.slice(i, lit); i = lit; continue; }
113
+ if (c === ':' && d === ':') {
114
+ if (src[i + 2] === '<') {
115
+ // turbofish `::<T>`: drop entirely, keep any newlines inside
116
+ let j = i + 2;
117
+ let depth = 0;
118
+ for (; j < n; j++) {
119
+ if (src[j] === '<') depth++;
120
+ else if (src[j] === '>') { depth--; if (depth === 0) { j++; break; } }
121
+ else if (src[j] === '\n') out += '\n';
122
+ }
123
+ i = j;
124
+ continue;
125
+ }
126
+ out += '.'; i += 2;
127
+ continue;
128
+ }
129
+ if (c === '.' && src.startsWith('.await', i) && !/\w/.test(src[i + 6] || '')) { i += 6; continue; }
130
+ if (c === '?') { i++; continue; }
131
+ out += c; i++;
132
+ }
133
+ return out;
134
+ }
135
+
136
+ // ─── generic scanners ────────────────────────────────────────────────────────
137
+ function _matchingClose(s, openIdx) {
138
+ const open = s[openIdx];
139
+ const close = open === '(' ? ')' : open === '[' ? ']' : open === '{' ? '}' : null;
140
+ if (!close) return -1;
141
+ let depth = 0;
142
+ for (let i = openIdx; i < s.length;) {
143
+ const e = _literalEnd(s, i);
144
+ if (e > i) { i = e; continue; }
145
+ const c = s[i];
146
+ if (c === '(' || c === '[' || c === '{') depth++;
147
+ else if (c === ')' || c === ']' || c === '}') { depth--; if (depth === 0) return i; }
148
+ i++;
149
+ }
150
+ return -1;
151
+ }
152
+
153
+ // Split on `sep` at depth 0 (parens/brackets/braces, and optionally angle
154
+ // brackets for type contexts). Returns [{text, start}] with `start` = offset of
155
+ // the part's first non-space char within `s`.
156
+ function _splitTopLevel(s, sep, { angles = false } = {}) {
157
+ const out = [];
158
+ let depth = 0;
159
+ let angle = 0;
160
+ let partStart = 0;
161
+ const push = (end) => {
162
+ let a = partStart;
163
+ while (a < end && /\s/.test(s[a])) a++;
164
+ let b = end;
165
+ while (b > a && /\s/.test(s[b - 1])) b--;
166
+ out.push({ text: s.slice(a, b), start: a });
167
+ };
168
+ for (let i = 0; i < s.length;) {
169
+ const e = _literalEnd(s, i);
170
+ if (e > i) { i = e; continue; }
171
+ const c = s[i];
172
+ if (c === '(' || c === '[' || c === '{') depth++;
173
+ else if (c === ')' || c === ']' || c === '}') depth--;
174
+ else if (angles && c === '<' && depth >= 0) angle++;
175
+ else if (angles && c === '>' && s[i - 1] !== '-' && angle > 0) angle--;
176
+ else if (c === sep && depth === 0 && angle === 0) { push(i); partStart = i + 1; i++; continue; }
177
+ i++;
178
+ }
179
+ push(s.length);
180
+ if (out.length === 1 && !out[0].text) return [];
181
+ return out;
182
+ }
183
+
184
+ // `+` at depth 0, not part of `+=`, `->`, or a unary sign after an operator.
185
+ function _splitTopLevelPlus(s) {
186
+ const out = [];
187
+ let depth = 0;
188
+ let partStart = 0;
189
+ for (let i = 0; i < s.length;) {
190
+ const e = _literalEnd(s, i);
191
+ if (e > i) { i = e; continue; }
192
+ const c = s[i];
193
+ if (c === '(' || c === '[' || c === '{') depth++;
194
+ else if (c === ')' || c === ']' || c === '}') depth--;
195
+ else if (c === '+' && depth === 0 && s[i + 1] !== '=') {
196
+ const t = s.slice(partStart, i).trim();
197
+ if (t) { out.push(t); partStart = i + 1; }
198
+ }
199
+ i++;
200
+ }
201
+ const last = s.slice(partStart).trim();
202
+ if (last) out.push(last);
203
+ return out;
204
+ }
205
+
206
+ function _extractBody(src, openBrace) {
207
+ const end = _matchingClose(src, openBrace);
208
+ if (end < 0) return null;
209
+ return { body: src.slice(openBrace + 1, end), end };
210
+ }
211
+
212
+ // Statement splitter. Flushes on `;` at depth 0 and on a `}` that closes a
213
+ // depth-0 block, unless the block is continued by `else`, a method call, a
214
+ // cast, or is itself an argument. Returns [{text, start, terminated}].
215
+ const _BLOCK_CONTINUERS = /^(?:else\b|as\b|\.|,|\)|\]|;|\}|\|\||&&|==|!=|[-+*\/%]?=|\?)/;
216
+ function _splitStatements(body) {
217
+ const out = [];
218
+ let depth = 0;
219
+ let stmtStart = 0;
220
+ const flush = (end, terminated) => {
221
+ let a = stmtStart;
222
+ while (a < end && /\s/.test(body[a])) a++;
223
+ let b = end;
224
+ while (b > a && /\s/.test(body[b - 1])) b--;
225
+ if (b > a) out.push({ text: body.slice(a, b), start: a, terminated });
226
+ };
227
+ for (let i = 0; i < body.length;) {
228
+ const e = _literalEnd(body, i);
229
+ if (e > i) { i = e; continue; }
230
+ const c = body[i];
231
+ if (c === '(' || c === '[' || c === '{') { depth++; i++; continue; }
232
+ if (c === ')' || c === ']') { depth--; i++; continue; }
233
+ if (c === '}') {
234
+ depth--;
235
+ i++;
236
+ if (depth === 0) {
237
+ let j = i;
238
+ while (j < body.length && /\s/.test(body[j])) j++;
239
+ if (j >= body.length || !_BLOCK_CONTINUERS.test(body.slice(j, j + 4))) {
240
+ flush(i, false);
241
+ stmtStart = i;
242
+ }
243
+ }
244
+ continue;
245
+ }
246
+ if (c === ';' && depth === 0) { flush(i, true); stmtStart = i + 1; i++; continue; }
247
+ i++;
248
+ }
249
+ flush(body.length, false);
250
+ return out;
251
+ }
252
+
253
+ // ─── chain parsing ───────────────────────────────────────────────────────────
254
+ // Parses `base . seg (args) [idx] …` into a flat op list. `base` is an ident
255
+ // path (`a.b.c`), a literal, a paren group, or a macro invocation.
256
+ const _IDENT_PATH_RE = /^[A-Za-z_][\w.]*/;
257
+ const _MACRO_RE = /^([A-Za-z_][\w.]*)!\s*([(\[{])/;
258
+
259
+ function _parseChain(s) {
260
+ let i = 0;
261
+ let base = null;
262
+ const lit = _literalEnd(s, 0);
263
+ if (lit > 0) {
264
+ base = { type: 'literal', text: s.slice(0, lit) };
265
+ i = lit;
266
+ } else if (s[0] === '(') {
267
+ const close = _matchingClose(s, 0);
268
+ if (close < 0) return null;
269
+ base = { type: 'paren', text: s.slice(1, close) };
270
+ i = close + 1;
271
+ } else {
272
+ const mac = _MACRO_RE.exec(s);
273
+ if (mac) {
274
+ const open = mac[0].length - 1;
275
+ const close = _matchingClose(s, open);
276
+ if (close < 0) return null;
277
+ base = { type: 'macro', name: mac[1], argsText: s.slice(open + 1, close), argsStart: open + 1 };
278
+ i = close + 1;
279
+ } else {
280
+ const id = _IDENT_PATH_RE.exec(s);
281
+ if (!id) return null;
282
+ base = { type: 'path', text: id[0] };
283
+ i = id[0].length;
284
+ }
285
+ }
286
+ const ops = [];
287
+ for (;;) {
288
+ while (i < s.length && /\s/.test(s[i])) i++;
289
+ if (i >= s.length) break;
290
+ const c = s[i];
291
+ if (c === '(' && (base.type !== 'literal' || ops.length)) {
292
+ const close = _matchingClose(s, i);
293
+ if (close < 0) return null;
294
+ ops.push({ kind: 'call', argsText: s.slice(i + 1, close), argsStart: i + 1 });
295
+ i = close + 1;
296
+ continue;
297
+ }
298
+ if (c === '[') {
299
+ const close = _matchingClose(s, i);
300
+ if (close < 0) return null;
301
+ ops.push({ kind: 'index', text: s.slice(i + 1, close) });
302
+ i = close + 1;
303
+ continue;
304
+ }
305
+ if (c === '.') {
306
+ const m = /^\.\s*([A-Za-z_]\w*|\d+)/.exec(s.slice(i));
307
+ if (!m) break;
308
+ ops.push({ kind: 'member', name: m[1] });
309
+ i += m[0].length;
310
+ continue;
311
+ }
312
+ break;
313
+ }
314
+ return { base, ops, end: i };
315
+ }
316
+
317
+ // ─── expression lowering ─────────────────────────────────────────────────────
318
+ const _FORMAT_MACROS = new Set(['format', 'print', 'println', 'eprint', 'eprintln', 'panic', 'format_args', 'write', 'writeln', 'assert', 'unreachable', 'todo', 'concat']);
319
+ const _WRITER_FIRST = new Set(['write', 'writeln']);
320
+ const _CLOSURE_RE = /^(?:move\s+)?\|([^|]*)\|\s*(?:async\s+(?:move\s+)?)?/;
321
+ const _ASYNC_BLOCK_RE = /^async\s+(?:move\s+)?\{/;
322
+
323
+ function _stripCast(s) {
324
+ // drop a trailing top-level ` as Type`
325
+ let depth = 0;
326
+ let cut = -1;
327
+ for (let i = 0; i < s.length;) {
328
+ const e = _literalEnd(s, i);
329
+ if (e > i) { i = e; continue; }
330
+ const c = s[i];
331
+ if (c === '(' || c === '[' || c === '{') depth++;
332
+ else if (c === ')' || c === ']' || c === '}') depth--;
333
+ else if (depth === 0 && c === 'a' && s[i + 1] === 's' && /\s/.test(s[i + 2] || '') && /\s/.test(s[i - 1] || '')) cut = i;
334
+ i++;
335
+ }
336
+ return cut > 0 ? s.slice(0, cut).trim() : s;
337
+ }
338
+
339
+ function _lowerFormatArgs(argsText, dropFirst) {
340
+ const parts = [];
341
+ let args = _splitTopLevel(argsText, ',').map(p => p.text);
342
+ if (dropFirst) args = args.slice(1);
343
+ if (!args.length) return { kind: 'tpl', parts };
344
+ const fmt = args[0];
345
+ const positional = args.slice(1);
346
+ const named = new Map();
347
+ const positionalExprs = [];
348
+ for (const a of positional) {
349
+ const nm = /^([A-Za-z_]\w*)\s*=\s*([\s\S]+)$/.exec(a);
350
+ if (nm) named.set(nm[1], _lowerExpr(nm[2]));
351
+ else positionalExprs.push(_lowerExpr(a));
352
+ }
353
+ if (_literalEnd(fmt, 0) === fmt.length && fmt.length > 1) {
354
+ parts.push({ kind: 'literal', value: fmt });
355
+ let next = 0;
356
+ const used = new Set();
357
+ const re = /\{\{|\}\}|\{([^{}]*)\}/g;
358
+ let m;
359
+ while ((m = re.exec(fmt)) !== null) {
360
+ if (m[0] === '{{' || m[0] === '}}') continue;
361
+ const inner = (m[1] || '').split(':')[0].trim();
362
+ if (!inner) {
363
+ if (next < positionalExprs.length) { parts.push(positionalExprs[next]); used.add(next); }
364
+ next++;
365
+ } else if (/^\d+$/.test(inner)) {
366
+ const idx = Number(inner);
367
+ if (idx < positionalExprs.length) { parts.push(positionalExprs[idx]); used.add(idx); }
368
+ } else if (named.has(inner)) {
369
+ parts.push(named.get(inner));
370
+ } else if (/^[A-Za-z_]\w*$/.test(inner)) {
371
+ parts.push({ kind: 'ident', name: inner });
372
+ }
373
+ }
374
+ positionalExprs.forEach((e, idx) => { if (!used.has(idx)) parts.push(e); });
375
+ for (const [, e] of named) if (!parts.includes(e)) parts.push(e);
376
+ } else {
377
+ parts.push(_lowerExpr(fmt), ...positionalExprs, ...named.values());
378
+ }
379
+ return { kind: 'tpl', parts };
380
+ }
381
+
382
+ // `format!`/`format_args!`/`concat!` ARE the value they build, so they lower
383
+ // straight to a template. Every other format-family macro (`println!`,
384
+ // `write!`, `panic!`, and log macros later) is a statement-shaped call that
385
+ // consumes a template: it lowers to a call whose callee keeps the `!`, with
386
+ // the template as its single argument, so a catalog sink can key on the
387
+ // macro name (`info!` for log injection) exactly like a function.
388
+ const _VALUE_MACROS = new Set(['format', 'format_args', 'concat']);
389
+ function _lowerMacro(name, argsText) {
390
+ const bare = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1) : name;
391
+ if (_FORMAT_MACROS.has(bare)) {
392
+ const tpl = _lowerFormatArgs(argsText, _WRITER_FIRST.has(bare));
393
+ if (_VALUE_MACROS.has(bare)) return tpl;
394
+ return { kind: 'call', callee: `${name}!`, args: [tpl] };
395
+ }
396
+ if (bare === 'vec') {
397
+ const semi = _splitTopLevel(argsText, ';');
398
+ const items = semi.length === 2 ? [semi[0].text] : _splitTopLevel(argsText, ',').map(p => p.text);
399
+ return { kind: 'array', elements: items.map(_lowerExpr) };
400
+ }
401
+ return { kind: 'call', callee: `${name}!`, args: _splitTopLevel(argsText, ',').map(p => _lowerExpr(p.text)) };
402
+ }
403
+
404
+ const _PASSTHROUGH_CALLS = new Set(['unwrap', 'expect']);
405
+
406
+ function _memberChain(segs) {
407
+ let cur = { kind: 'ident', name: segs[0] };
408
+ for (let i = 1; i < segs.length; i++) cur = { kind: 'member', object: cur, prop: segs[i] };
409
+ return cur;
410
+ }
411
+
412
+ function _lowerChain(chain) {
413
+ const { base, ops } = chain;
414
+ let segs;
415
+ let args = [];
416
+ let _baseIsCall = false;
417
+ if (base.type === 'path') segs = base.text.split('.');
418
+ else if (base.type === 'macro') {
419
+ const m = _lowerMacro(base.name, base.argsText);
420
+ if (!ops.length) return m;
421
+ // A macro starting a chain keeps its own name (with the `!`) as the
422
+ // chain root so `sqlx::query!(…).fetch_one(pool)` reads as
423
+ // `sqlx.query!.fetch_one`, and its lowered value is the innermost arg.
424
+ segs = `${base.name}!`.split('.');
425
+ args = m.kind === 'call' ? m.args.slice() : [m];
426
+ // A call-shaped macro (`write!`, `println!`, …) IS the call for this
427
+ // statement even if every op that follows is a stripped passthrough
428
+ // (`write!(w, "…", x).unwrap();`) — without this, a macro-only
429
+ // statement whose sole continuation is `.unwrap()` would fall through
430
+ // to `if (!hasCall)` below and silently stop being a call node at all.
431
+ _baseIsCall = m.kind === 'call';
432
+ } else if (base.type === 'literal') {
433
+ const l = { kind: 'literal', value: base.text };
434
+ if (!ops.length) return l;
435
+ segs = ['_lit'];
436
+ args = [l];
437
+ } else { // paren
438
+ const inner = _lowerExpr(base.text);
439
+ if (!ops.length) return inner;
440
+ segs = ['_expr'];
441
+ args = [inner];
442
+ }
443
+ let hasCall = _baseIsCall;
444
+ // `.unwrap()`/`.expect(msg)` are Result/Option combinators that neither
445
+ // change WHAT the value is nor take a taint-relevant argument — they exist
446
+ // purely to panic on an error path. Left in the chain, they shift the
447
+ // TERMINAL segment away from the real sink/method name (the same "terminal
448
+ // segment shift" defect class documented for Go/Java/Kotlin in ir/CLAUDE.md),
449
+ // so `sqlx::query(&sql).fetch_all(pool).await.unwrap()` would otherwise end
450
+ // in `.unwrap`, never `.fetch_all`, and no sink catalog entry could ever
451
+ // match it — nearly every real async Rust call site ends this way. Popped
452
+ // (segment AND args) as soon as the CALL that follows them is seen, so the
453
+ // chain reads exactly as if they were never written.
454
+ let pendingPassthrough = false;
455
+ for (const op of ops) {
456
+ if (op.kind === 'member') {
457
+ pendingPassthrough = _PASSTHROUGH_CALLS.has(op.name);
458
+ segs.push(op.name);
459
+ } else if (op.kind === 'index') {
460
+ pendingPassthrough = false;
461
+ segs.push('[]');
462
+ } else if (pendingPassthrough) {
463
+ pendingPassthrough = false;
464
+ segs.pop();
465
+ } else {
466
+ hasCall = true;
467
+ const lowered = _splitTopLevel(op.argsText, ',').map(p => _lowerExpr(p.text));
468
+ args = args.concat(lowered);
469
+ }
470
+ }
471
+ if (!hasCall) {
472
+ if (base.type === 'path') return _memberChain(segs);
473
+ return args[0] || U;
474
+ }
475
+ return { kind: 'call', callee: segs.join('.'), args };
476
+ }
477
+
478
+ function _lowerExpr(text) {
479
+ let s = String(text || '').trim();
480
+ if (!s) return U;
481
+ // fully parenthesized
482
+ while (s.startsWith('(') && _matchingClose(s, 0) === s.length - 1) {
483
+ const inner = s.slice(1, -1).trim();
484
+ if (_splitTopLevel(inner, ',').length > 1) return { kind: 'array', elements: _splitTopLevel(inner, ',').map(p => _lowerExpr(p.text)) };
485
+ s = inner;
486
+ if (!s) return U;
487
+ }
488
+ const pre = /^(?:&\s*mut\s+|&\s*|\*\s*|mut\s+|!\s*|-\s*)/.exec(s);
489
+ if (pre && pre[0].length < s.length) return _lowerExpr(s.slice(pre[0].length));
490
+ s = _stripCast(s);
491
+ // closure as a value: its body expression is what flows onward
492
+ const cl = _CLOSURE_RE.exec(s);
493
+ if (cl) {
494
+ const body = s.slice(cl[0].length).trim();
495
+ if (body.startsWith('{')) return U;
496
+ return _lowerExpr(body);
497
+ }
498
+ if (/^(?:if|match|loop|while|for|unsafe|async)\b/.test(s) || s.startsWith('{')) return U;
499
+ // array literal
500
+ if (s.startsWith('[') && _matchingClose(s, 0) === s.length - 1) {
501
+ const inner = s.slice(1, -1);
502
+ const semi = _splitTopLevel(inner, ';');
503
+ const items = semi.length === 2 ? [semi[0].text] : _splitTopLevel(inner, ',').map(p => p.text);
504
+ return { kind: 'array', elements: items.map(_lowerExpr) };
505
+ }
506
+ // string concat with +
507
+ const plusParts = _splitTopLevelPlus(s);
508
+ if (plusParts.length > 1) return { kind: 'tpl', parts: plusParts.map(_lowerExpr) };
509
+ // comparison / logical operators at top level: keep both sides visible
510
+ const bin = _splitBinary(s);
511
+ if (bin) return { kind: 'logical', op: bin.op, left: _lowerExpr(bin.left), right: _lowerExpr(bin.right) };
512
+ // range `a..b`
513
+ const range = /^([\s\S]*?)\.\.=?([\s\S]*)$/.exec(s);
514
+ if (range && !s.startsWith('.') && range[1].trim() && !/[("[]/.test(range[1])) {
515
+ return { kind: 'array', elements: [_lowerExpr(range[1]), _lowerExpr(range[2])].filter(e => e !== U) };
516
+ }
517
+ if (/^\d/.test(s)) return { kind: 'literal', value: s };
518
+ if (/^(?:true|false|None|Self|self)$/.test(s)) return s === 'self' || s === 'Self' ? { kind: 'ident', name: s } : { kind: 'literal', value: s };
519
+ // struct literal `Type { a: x, b }` / `Type.Variant { .. }`
520
+ const sl = /^([A-Za-z_][\w.]*)\s*\{/.exec(s);
521
+ if (sl && _matchingClose(s, sl[0].length - 1) === s.length - 1) {
522
+ const inner = s.slice(sl[0].length, -1);
523
+ const props = [];
524
+ for (const f of _splitTopLevel(inner, ',')) {
525
+ const t = f.text;
526
+ if (!t) continue;
527
+ if (t.startsWith('..')) { props.push({ value: _lowerExpr(t.slice(2)) }); continue; }
528
+ const kv = /^([A-Za-z_]\w*)\s*:\s*([\s\S]+)$/.exec(t);
529
+ props.push({ value: _lowerExpr(kv ? kv[2] : t) });
530
+ }
531
+ return { kind: 'object', props };
532
+ }
533
+ const chain = _parseChain(s);
534
+ if (chain && chain.end >= s.length) return _lowerChain(chain);
535
+ if (chain && chain.base.type === 'path' && !chain.ops.length) return _memberChain(chain.base.text.split('.'));
536
+ return U;
537
+ }
538
+
539
+ // Top-level `==`, `!=`, `<`, `>`, `<=`, `>=`, `&&`, `||`: returns the split or
540
+ // null. Kept simple: first top-level operator found wins.
541
+ function _splitBinary(s) {
542
+ let depth = 0;
543
+ for (let i = 0; i < s.length;) {
544
+ const e = _literalEnd(s, i);
545
+ if (e > i) { i = e; continue; }
546
+ const c = s[i];
547
+ if (c === '(' || c === '[' || c === '{') depth++;
548
+ else if (c === ')' || c === ']' || c === '}') depth--;
549
+ else if (depth === 0) {
550
+ const two = s.slice(i, i + 2);
551
+ if (two === '&&' || two === '||' || two === '==' || two === '!=' || two === '<=' || two === '>=') {
552
+ return { op: two, left: s.slice(0, i), right: s.slice(i + 2) };
553
+ }
554
+ }
555
+ i++;
556
+ }
557
+ return null;
558
+ }
559
+
560
+ // ─── patterns ────────────────────────────────────────────────────────────────
561
+ const _PAT_KEYWORDS = new Set(['ref', 'mut', 'true', 'false', 'box', 'if', 'in', 'as', 'self', 'Self', 'crate', 'super']);
562
+
563
+ // Lowercase-initial identifiers bound by a pattern. Enum variants and struct
564
+ // names are capitalized in idiomatic Rust; field names before `:` are keys,
565
+ // not bindings.
566
+ function _patternIdents(pat) {
567
+ const out = [];
568
+ const p = String(pat || '');
569
+ const re = /[A-Za-z_]\w*/g;
570
+ let m;
571
+ while ((m = re.exec(p)) !== null) {
572
+ const id = m[0];
573
+ if (_PAT_KEYWORDS.has(id) || id === '_') continue;
574
+ if (!/^[a-z_]/.test(id)) continue;
575
+ const after = p.slice(m.index + id.length);
576
+ if (/^\s*:(?!:)/.test(after)) continue; // `field: pat`
577
+ if (/^\s*\(/.test(after)) continue; // tuple-struct / call-like
578
+ if (/^\s*!/.test(after)) continue; // macro
579
+ if (m.index > 0 && p[m.index - 1] === '.') continue; // path segment
580
+ if (/^\s*\./.test(after)) continue; // path prefix
581
+ if (!out.includes(id)) out.push(id);
582
+ }
583
+ return out;
584
+ }
585
+
586
+ function _stripGuard(patText) {
587
+ // `Some(x) if x > 3` → `Some(x)`
588
+ let depth = 0;
589
+ for (let i = 0; i < patText.length;) {
590
+ const e = _literalEnd(patText, i);
591
+ if (e > i) { i = e; continue; }
592
+ const c = patText[i];
593
+ if (c === '(' || c === '[' || c === '{') depth++;
594
+ else if (c === ')' || c === ']' || c === '}') depth--;
595
+ else if (depth === 0 && /\s/.test(patText[i - 1] || ' ') && patText.startsWith('if', i) && /\s/.test(patText[i + 2] || '')) {
596
+ return patText.slice(0, i).trim();
597
+ }
598
+ i++;
599
+ }
600
+ return patText.trim();
601
+ }
602
+
603
+ // ─── params ──────────────────────────────────────────────────────────────────
604
+ const _EXTRACTORS = new Set([
605
+ 'Query', 'Path', 'Json', 'Form', 'HeaderMap', 'TypedHeader', 'RawQuery', 'RawForm',
606
+ 'Multipart', 'MultipartForm', 'HttpRequest', 'Request', 'CookieJar', 'Cookies', 'Host',
607
+ 'OriginalUri', 'RawPathParams', 'Bytes',
608
+ ]);
609
+ const _ROUTE_ATTR_RE = /#\[\s*(?:rocket\.|actix_web\.|salvo\.)?(?:get|post|put|delete|patch|head|options|route|handler)\s*[(\]]/;
610
+
611
+ function _typeRoot(typeText) {
612
+ const t = String(typeText || '').replace(/^\s*(?:&\s*(?:'\w+\s+)?(?:mut\s+)?|mut\s+)/, '').trim();
613
+ const head = t.split('<')[0].trim();
614
+ const last = head.includes('.') ? head.slice(head.lastIndexOf('.') + 1) : head;
615
+ return last;
616
+ }
617
+
618
+ // `(?:#\[[^\]]*\]\s*)+` (here, in `_buildCfg`'s attribute-skip below, and
619
+ // inside `_FN_RE`) matches this repo's self-scan gate's "optional/quantified
620
+ // group flanked by `\s*`" ReDoS heuristic, but each iteration is bounded by
621
+ // the MANDATORY literal `#[`…`]` — there is no shared character class between
622
+ // the outer `+` and the inner `\s*`/`[^\]]*` for the engine to backtrack
623
+ // across, so the shape is linear by construction, not merely linear on the
624
+ // inputs tried. Confirmed by direct timing (this repo's own standard for
625
+ // this exact judgment call, per `ir/CLAUDE.md`'s ReDoS notes): 50 000
626
+ // back-to-back `#[a]` attributes and 200 000 non-matching whitespace
627
+ // characters both resolve in single-digit milliseconds.
628
+ function _parseParams(paramsText, hasRouteAttr) {
629
+ const params = [];
630
+ const paramAnnotations = [];
631
+ for (const part of _splitTopLevel(paramsText, ',', { angles: true })) {
632
+ let t = part.text.replace(/^(?:#\[[^\]]*\]\s*)+/, '').trim();
633
+ if (!t) continue;
634
+ if (/^(?:&\s*(?:'\w+\s+)?(?:mut\s+)?|mut\s+)?self\b/.test(t)) { params.push('self'); continue; }
635
+ // split `pattern: Type` at the first top-level colon
636
+ let colon = -1;
637
+ let depth = 0;
638
+ for (let i = 0; i < t.length; i++) {
639
+ const c = t[i];
640
+ if (c === '(' || c === '[' || c === '{' || c === '<') depth++;
641
+ else if (c === ')' || c === ']' || c === '}' || c === '>') depth--;
642
+ else if (c === ':' && depth === 0) { colon = i; break; }
643
+ }
644
+ const pat = colon >= 0 ? t.slice(0, colon).trim() : t;
645
+ const type = colon >= 0 ? t.slice(colon + 1).trim() : '';
646
+ const wrapper = /^([A-Z]\w*)\s*\(/.exec(pat);
647
+ const idents = _patternIdents(pat.replace(/^mut\s+/, ''));
648
+ const root = _typeRoot(type);
649
+ let decorator = null;
650
+ if (wrapper && _EXTRACTORS.has(wrapper[1])) decorator = wrapper[1];
651
+ else if (_EXTRACTORS.has(root)) decorator = root;
652
+ else if (hasRouteAttr) decorator = 'RouteParam';
653
+ for (const id of idents) {
654
+ if (decorator) paramAnnotations.push({ index: params.length, name: id, decorator });
655
+ params.push(id);
656
+ }
657
+ }
658
+ return { params, paramAnnotations };
659
+ }
660
+
661
+ // Skip backward over `fn`'s qualifier keywords (pub/pub(...)/const/async/
662
+ // unsafe/default/extern "…") so `_precedingAttributes` (below) can still see
663
+ // an attribute that precedes them, e.g. `#[get("/x")] async fn handler()` —
664
+ // the common shape for every Rust web-framework route macro. Plain bounded
665
+ // literal comparisons, not a regex: this is exactly the kind of fixed,
666
+ // small, disjoint-keyword set that is safe to skip character-by-character
667
+ // with no backtracking possible by construction (same reasoning
668
+ // `_precedingAttributes` itself already relies on for `#[...]`/`]`).
669
+ const _FN_QUALIFIER_KEYWORDS = ['pub', 'const', 'async', 'unsafe', 'default'];
670
+ function _skipFnQualifiersBackward(src, idx) {
671
+ let pos = idx;
672
+ for (;;) {
673
+ while (pos > 0 && /\s/.test(src[pos - 1])) pos--;
674
+ if (src[pos - 1] === ')') {
675
+ // pub(crate)/pub(super)/pub(in ...) — find the matching '(' and check
676
+ // it's immediately preceded by "pub".
677
+ let depth = 0;
678
+ let j = pos - 1;
679
+ for (; j >= 0; j--) {
680
+ if (src[j] === ')') depth++;
681
+ else if (src[j] === '(') { depth--; if (depth === 0) break; }
682
+ }
683
+ if (j >= 3 && src.slice(j - 3, j) === 'pub' && !/\w/.test(src[j - 4] || '')) { pos = j - 3; continue; }
684
+ break;
685
+ }
686
+ if (src[pos - 1] === '"') {
687
+ // extern "C"/"Rust"/etc.
688
+ let j = pos - 2;
689
+ while (j >= 0 && src[j] !== '"') j--;
690
+ if (j <= 0) break;
691
+ const before = src.slice(0, j);
692
+ const m = /extern\s*$/.exec(before);
693
+ if (m) { pos = m.index; continue; }
694
+ break;
695
+ }
696
+ let matched = false;
697
+ for (const kw of _FN_QUALIFIER_KEYWORDS) {
698
+ const start = pos - kw.length;
699
+ if (start >= 0 && src.slice(start, pos) === kw && (start === 0 || !/\w/.test(src[start - 1]))) {
700
+ pos = start;
701
+ matched = true;
702
+ break;
703
+ }
704
+ }
705
+ if (!matched) break;
706
+ }
707
+ return pos;
708
+ }
709
+
710
+ // Attributes immediately preceding `idx`, scanned backwards over `#[…]` groups.
711
+ function _precedingAttributes(src, idx) {
712
+ let pos = idx;
713
+ let attrs = '';
714
+ for (;;) {
715
+ while (pos > 0 && /\s/.test(src[pos - 1])) pos--;
716
+ if (src[pos - 1] !== ']') break;
717
+ let depth = 0;
718
+ let j = pos - 1;
719
+ for (; j >= 0; j--) {
720
+ if (src[j] === ']') depth++;
721
+ else if (src[j] === '[') { depth--; if (depth === 0) break; }
722
+ }
723
+ if (j <= 0 || src[j - 1] !== '#') break;
724
+ attrs = src.slice(j - 1, pos) + '\n' + attrs;
725
+ pos = j - 1;
726
+ }
727
+ return attrs;
728
+ }
729
+
730
+ // ─── CFG construction ────────────────────────────────────────────────────────
731
+ let _nid = 0;
732
+ let _src = '';
733
+ let _lineStarts = [];
734
+
735
+ function _computeLineStarts(s) {
736
+ const starts = [0];
737
+ for (let i = 0; i < s.length; i++) if (s[i] === '\n') starts.push(i + 1);
738
+ return starts;
739
+ }
740
+
741
+ function _lineForOffset(off) {
742
+ let lo = 0;
743
+ let hi = _lineStarts.length - 1;
744
+ while (lo < hi) {
745
+ const mid = (lo + hi + 1) >> 1;
746
+ if (_lineStarts[mid] <= off) lo = mid; else hi = mid - 1;
747
+ }
748
+ return lo + 1;
749
+ }
750
+
751
+ function _addNode(nodes, node) {
752
+ const id = `rn${++_nid}`;
753
+ node.succ = node.succ || [];
754
+ node.pred = node.pred || [];
755
+ nodes[id] = node;
756
+ return id;
757
+ }
758
+
759
+ function _link(nodes, src, dst) {
760
+ if (!nodes[src] || !nodes[dst]) return;
761
+ if (!nodes[src].succ.includes(dst)) nodes[src].succ.push(dst);
762
+ if (!nodes[dst].pred.includes(src)) nodes[dst].pred.push(src);
763
+ }
764
+
765
+ const _MAX_DEPTH = 60;
766
+ const _DECL_RE = /^(?:pub(?:\([^)]*\))?\s+)?(?:(?:const|async|unsafe|extern\s+"[^"]*")\s+)*(?:fn|struct|enum|impl|trait|use|mod|type|macro_rules)\b/;
767
+ const _CTRL_RE = /^(?:if|while|loop|for|match|unsafe|async)\b|^\{/;
768
+
769
+ function _bindPattern(nodes, prev, patText, sourceExpr, line) {
770
+ let cur = prev;
771
+ for (const id of _patternIdents(patText)) {
772
+ const n = _addNode(nodes, { kind: 'assign', target: id, source: sourceExpr, line });
773
+ _link(nodes, cur, n);
774
+ cur = n;
775
+ }
776
+ return cur;
777
+ }
778
+
779
+ // Where the `{` of a control-flow header is: first `{` at depth 0 in s (after
780
+ // the keyword), literal-aware.
781
+ function _headerBrace(s, from) {
782
+ let depth = 0;
783
+ for (let i = from; i < s.length;) {
784
+ const e = _literalEnd(s, i);
785
+ if (e > i) { i = e; continue; }
786
+ const c = s[i];
787
+ if (c === '(' || c === '[') depth++;
788
+ else if (c === ')' || c === ']') depth--;
789
+ else if (c === '{' && depth === 0) return i;
790
+ i++;
791
+ }
792
+ return -1;
793
+ }
794
+
795
+ // Inline every closure / async block found among a chain's call args.
796
+ function _inlineClosures(chain, stmtText, stmtStart, nodes, prev, depth) {
797
+ let cur = prev;
798
+ const segsSoFar = chain.base.type === 'path' ? chain.base.text.split('.') : ['_expr'];
799
+ for (const op of chain.ops) {
800
+ if (op.kind === 'member') { segsSoFar.push(op.name); continue; }
801
+ if (op.kind === 'index') { segsSoFar.push('[]'); continue; }
802
+ const receiverSegs = segsSoFar.slice(0, -1);
803
+ for (const part of _splitTopLevel(op.argsText, ',')) {
804
+ const t = part.text;
805
+ const absStart = stmtStart + op.argsStart + part.start;
806
+ const cl = _CLOSURE_RE.exec(t);
807
+ let bodyText = null;
808
+ let bodyAbs = 0;
809
+ if (cl) {
810
+ const rest = t.slice(cl[0].length);
811
+ const line = _lineForOffset(absStart);
812
+ for (const p of _splitTopLevel(cl[1], ',', { angles: true })) {
813
+ const pt = p.text.trim();
814
+ if (!pt) continue;
815
+ let colon = -1;
816
+ let d = 0;
817
+ for (let i = 0; i < pt.length; i++) {
818
+ const c = pt[i];
819
+ if (c === '(' || c === '[' || c === '{' || c === '<') d++;
820
+ else if (c === ')' || c === ']' || c === '}' || c === '>') d--;
821
+ else if (c === ':' && d === 0) { colon = i; break; }
822
+ }
823
+ const pat = colon >= 0 ? pt.slice(0, colon) : pt;
824
+ const type = colon >= 0 ? pt.slice(colon + 1) : '';
825
+ const root = _typeRoot(type);
826
+ const wrapper = /^([A-Z]\w*)\s*\(/.exec(pat.trim());
827
+ const extractor = wrapper && _EXTRACTORS.has(wrapper[1]) ? wrapper[1] : (_EXTRACTORS.has(root) ? root : null);
828
+ let source;
829
+ if (extractor) source = { kind: 'call', callee: `__rs_extract__.${extractor}`, args: [] };
830
+ else if (receiverSegs.length && receiverSegs[0] !== '_expr') source = _memberChain(receiverSegs);
831
+ else source = null;
832
+ if (source) cur = _bindPattern(nodes, cur, pat, source, line);
833
+ }
834
+ if (rest.startsWith('{')) {
835
+ const ex = _extractBody(rest, 0);
836
+ if (!ex) continue;
837
+ bodyText = ex.body;
838
+ bodyAbs = absStart + cl[0].length + 1;
839
+ } else {
840
+ bodyText = rest;
841
+ bodyAbs = absStart + cl[0].length;
842
+ }
843
+ } else if (_ASYNC_BLOCK_RE.test(t)) {
844
+ const open = t.indexOf('{');
845
+ const ex = _extractBody(t, open);
846
+ if (!ex) continue;
847
+ bodyText = ex.body;
848
+ bodyAbs = absStart + open + 1;
849
+ }
850
+ if (bodyText != null) cur = _buildCfg(bodyText, bodyAbs, nodes, cur, null, depth + 1);
851
+ }
852
+ }
853
+ return cur;
854
+ }
855
+
856
+ function _hasClosureArg(chain) {
857
+ return chain.ops.some(op => op.kind === 'call' && _splitTopLevel(op.argsText, ',').some(p => _CLOSURE_RE.test(p.text) || _ASYNC_BLOCK_RE.test(p.text)));
858
+ }
859
+
860
+ // Lower a chain, dropping closure/async-block args (they are inlined as CFG
861
+ // nodes instead of being passed as opaque unknowns).
862
+ function _lowerChainNoClosures(chain) {
863
+ const clone = { base: chain.base, ops: chain.ops.map(op => op.kind !== 'call' ? op : {
864
+ ...op,
865
+ argsText: _splitTopLevel(op.argsText, ',').filter(p => !_CLOSURE_RE.test(p.text) && !_ASYNC_BLOCK_RE.test(p.text)).map(p => p.text).join(', '),
866
+ }) };
867
+ return _lowerChain(clone);
868
+ }
869
+
870
+ // Builds a control-flow construct that starts at `s` (statement text) whose
871
+ // absolute start is `start`. `tails` collects branch tail expressions when the
872
+ // construct is in expression position. Returns the join node id.
873
+ function _buildControl(s, start, nodes, prev, tails, depth) {
874
+ const line = _lineForOffset(start);
875
+ // if / if let … else if … else
876
+ if (/^if\b/.test(s)) {
877
+ const brace = _headerBrace(s, 2);
878
+ if (brace < 0) return prev;
879
+ const header = s.slice(2, brace).trim();
880
+ const ex = _extractBody(s, brace);
881
+ if (!ex) return prev;
882
+ const ifLet = /^let\s+([\s\S]+?)\s*=\s*([\s\S]+)$/.exec(header);
883
+ const condExpr = _lowerExpr(ifLet ? ifLet[2] : header);
884
+ const ifNode = _addNode(nodes, { kind: 'if', cond: condExpr, line });
885
+ _link(nodes, prev, ifNode);
886
+ const join = _addNode(nodes, { kind: 'noop', line });
887
+ let thenPrev = ifNode;
888
+ if (ifLet) thenPrev = _bindPattern(nodes, ifNode, ifLet[1], condExpr, line);
889
+ const thenTail = _buildCfg(ex.body, start + brace + 1, nodes, thenPrev, tails, depth + 1);
890
+ _link(nodes, thenTail, join);
891
+ const rest = s.slice(ex.end + 1).trim();
892
+ const restStart = start + ex.end + 1 + (s.slice(ex.end + 1).length - s.slice(ex.end + 1).trimStart().length);
893
+ const elseM = /^else\b\s*/.exec(rest);
894
+ if (elseM) {
895
+ const after = rest.slice(elseM[0].length);
896
+ const afterStart = restStart + elseM[0].length;
897
+ if (/^if\b/.test(after)) {
898
+ const t = _buildControl(after, afterStart, nodes, ifNode, tails, depth + 1);
899
+ _link(nodes, t, join);
900
+ } else if (after.startsWith('{')) {
901
+ const eb = _extractBody(after, 0);
902
+ if (eb) {
903
+ const t = _buildCfg(eb.body, afterStart + 1, nodes, ifNode, tails, depth + 1);
904
+ _link(nodes, t, join);
905
+ }
906
+ }
907
+ } else {
908
+ _link(nodes, ifNode, join);
909
+ }
910
+ return join;
911
+ }
912
+ // while / while let
913
+ if (/^while\b/.test(s)) {
914
+ const brace = _headerBrace(s, 5);
915
+ if (brace < 0) return prev;
916
+ const header = s.slice(5, brace).trim();
917
+ const ex = _extractBody(s, brace);
918
+ if (!ex) return prev;
919
+ const whileLet = /^let\s+([\s\S]+?)\s*=\s*([\s\S]+)$/.exec(header);
920
+ const condExpr = _lowerExpr(whileLet ? whileLet[2] : header);
921
+ const head = _addNode(nodes, { kind: 'loop-header', line });
922
+ _link(nodes, prev, head);
923
+ const ifNode = _addNode(nodes, { kind: 'if', cond: condExpr, line });
924
+ _link(nodes, head, ifNode);
925
+ let bodyPrev = ifNode;
926
+ if (whileLet) bodyPrev = _bindPattern(nodes, ifNode, whileLet[1], condExpr, line);
927
+ const tail = _buildCfg(ex.body, start + brace + 1, nodes, bodyPrev, null, depth + 1);
928
+ _link(nodes, tail, head);
929
+ const join = _addNode(nodes, { kind: 'noop', line });
930
+ _link(nodes, ifNode, join);
931
+ return join;
932
+ }
933
+ // loop
934
+ if (/^loop\b/.test(s)) {
935
+ const brace = _headerBrace(s, 4);
936
+ if (brace < 0) return prev;
937
+ const ex = _extractBody(s, brace);
938
+ if (!ex) return prev;
939
+ const head = _addNode(nodes, { kind: 'loop-header', line });
940
+ _link(nodes, prev, head);
941
+ const tail = _buildCfg(ex.body, start + brace + 1, nodes, head, tails, depth + 1);
942
+ _link(nodes, tail, head);
943
+ const join = _addNode(nodes, { kind: 'noop', line });
944
+ _link(nodes, head, join);
945
+ _link(nodes, tail, join);
946
+ return join;
947
+ }
948
+ // for PAT in EXPR
949
+ if (/^for\b/.test(s)) {
950
+ const brace = _headerBrace(s, 3);
951
+ if (brace < 0) return prev;
952
+ const header = s.slice(3, brace).trim();
953
+ const ex = _extractBody(s, brace);
954
+ if (!ex) return prev;
955
+ const m = /^([\s\S]+?)\s+in\s+([\s\S]+)$/.exec(header);
956
+ const head = _addNode(nodes, { kind: 'loop-header', line });
957
+ _link(nodes, prev, head);
958
+ let bodyPrev = head;
959
+ if (m) bodyPrev = _bindPattern(nodes, head, m[1], _lowerExpr(m[2]), line);
960
+ const tail = _buildCfg(ex.body, start + brace + 1, nodes, bodyPrev, null, depth + 1);
961
+ _link(nodes, tail, head);
962
+ const join = _addNode(nodes, { kind: 'noop', line });
963
+ _link(nodes, head, join);
964
+ return join;
965
+ }
966
+ // match EXPR { arms }
967
+ if (/^match\b/.test(s)) {
968
+ const brace = _headerBrace(s, 5);
969
+ if (brace < 0) return prev;
970
+ const scrutinee = _lowerExpr(s.slice(5, brace).trim());
971
+ const ex = _extractBody(s, brace);
972
+ if (!ex) return prev;
973
+ const matchNode = _addNode(nodes, { kind: 'if', cond: scrutinee, line });
974
+ _link(nodes, prev, matchNode);
975
+ const join = _addNode(nodes, { kind: 'noop', line });
976
+ const body = ex.body;
977
+ const bodyAbs = start + brace + 1;
978
+ let i = 0;
979
+ let armCount = 0;
980
+ while (i < body.length) {
981
+ // find `=>` at depth 0
982
+ let d = 0;
983
+ let arrow = -1;
984
+ let j = i;
985
+ for (; j < body.length;) {
986
+ const e = _literalEnd(body, j);
987
+ if (e > j) { j = e; continue; }
988
+ const c = body[j];
989
+ if (c === '(' || c === '[' || c === '{') d++;
990
+ else if (c === ')' || c === ']' || c === '}') d--;
991
+ else if (d === 0 && c === '=' && body[j + 1] === '>') { arrow = j; break; }
992
+ j++;
993
+ }
994
+ if (arrow < 0) break;
995
+ const patText = _stripGuard(body.slice(i, arrow).replace(/^\s*,?\s*/, ''));
996
+ let k = arrow + 2;
997
+ while (k < body.length && /\s/.test(body[k])) k++;
998
+ let armBody;
999
+ let armAbs;
1000
+ let next;
1001
+ if (body[k] === '{') {
1002
+ const eb = _extractBody(body, k);
1003
+ if (!eb) break;
1004
+ armBody = eb.body;
1005
+ armAbs = bodyAbs + k + 1;
1006
+ next = eb.end + 1;
1007
+ } else {
1008
+ // expression arm: up to the next top-level comma
1009
+ let dd = 0;
1010
+ let q = k;
1011
+ for (; q < body.length;) {
1012
+ const e = _literalEnd(body, q);
1013
+ if (e > q) { q = e; continue; }
1014
+ const c = body[q];
1015
+ if (c === '(' || c === '[' || c === '{') dd++;
1016
+ else if (c === ')' || c === ']' || c === '}') dd--;
1017
+ else if (c === ',' && dd === 0) break;
1018
+ q++;
1019
+ }
1020
+ armBody = body.slice(k, q);
1021
+ armAbs = bodyAbs + k;
1022
+ next = q;
1023
+ }
1024
+ const armLine = _lineForOffset(bodyAbs + i + (body.slice(i).length - body.slice(i).trimStart().length));
1025
+ const armPrev = _bindPattern(nodes, matchNode, patText, scrutinee, armLine);
1026
+ const tail = _buildCfg(armBody, armAbs, nodes, armPrev, tails, depth + 1);
1027
+ _link(nodes, tail, join);
1028
+ armCount++;
1029
+ i = next;
1030
+ while (i < body.length && (/\s/.test(body[i]) || body[i] === ',')) i++;
1031
+ }
1032
+ if (!armCount) _link(nodes, matchNode, join);
1033
+ return join;
1034
+ }
1035
+ // unsafe { } / async { } / async move { } / bare { }
1036
+ const open = s.indexOf('{');
1037
+ if (open >= 0) {
1038
+ const ex = _extractBody(s, open);
1039
+ if (!ex) return prev;
1040
+ return _buildCfg(ex.body, start + open + 1, nodes, prev, tails, depth + 1);
1041
+ }
1042
+ return prev;
1043
+ }
1044
+
1045
+ function _lowerStmt(s, line) {
1046
+ if (/^return\b/.test(s)) {
1047
+ const rest = s.replace(/^return\b\s*/, '').trim();
1048
+ return { kind: 'return', line, value: rest ? _lowerExpr(rest) : null };
1049
+ }
1050
+ if (/^(?:break|continue)\b/.test(s)) return { kind: 'noop', line };
1051
+ // index write: base[idx] = rhs
1052
+ const idxWrite = /^([A-Za-z_][\w.]*)\s*\[([\s\S]*?)\]\s*(\+|-|\*|\/)?=(?!=)\s*([\s\S]+)$/.exec(s);
1053
+ if (idxWrite && _matchingClose(s, s.indexOf('[')) === s.indexOf(']', s.indexOf('['))) {
1054
+ return { kind: 'call', line, callee: `${idxWrite[1]}.__setitem__`, args: [_lowerExpr(idxWrite[2]), _lowerExpr(idxWrite[4])] };
1055
+ }
1056
+ const assign = /^(\*?[A-Za-z_][\w.]*)\s*(\+|-|\*|\/|%|&|\||\^)?=(?!=)\s*([\s\S]+)$/.exec(s);
1057
+ if (assign && !/^(?:if|while|for|match|let)\b/.test(s)) {
1058
+ const target = assign[1].replace(/^\*/, '');
1059
+ const rhs = _lowerExpr(assign[3]);
1060
+ if (assign[2]) return { kind: 'assign', line, target, source: { kind: 'tpl', parts: [_memberChain(target.split('.')), rhs] } };
1061
+ return { kind: 'assign', line, target, source: rhs };
1062
+ }
1063
+ const expr = _lowerExpr(s);
1064
+ if (expr.kind === 'call') return { kind: 'call', line, callee: expr.callee, args: expr.args };
1065
+ return null;
1066
+ }
1067
+
1068
+ function _buildCfg(bodyText, bodyStart, nodes, prevId, tails, depth) {
1069
+ if (depth > _MAX_DEPTH) return prevId;
1070
+ const stmts = _splitStatements(bodyText);
1071
+ let prev = prevId;
1072
+ for (let si = 0; si < stmts.length; si++) {
1073
+ const { text: raw, start: localStart, terminated } = stmts[si];
1074
+ const isLast = si === stmts.length - 1;
1075
+ let s = raw;
1076
+ let start = bodyStart + localStart;
1077
+ // leading attributes on a statement
1078
+ const attr = /^(?:#\[[^\]]*\]\s*)+/.exec(s);
1079
+ if (attr) { s = s.slice(attr[0].length); start += attr[0].length; }
1080
+ if (!s) continue;
1081
+ const line = _lineForOffset(start);
1082
+ if (_DECL_RE.test(s) && !/^(?:const|static)\s+[A-Z_]\w*\s*:/.test(s)) continue; // nested items handled by the top-level scan
1083
+ // const/static locals
1084
+ const cst = /^(?:pub\s+)?(?:const|static)\s+(?:mut\s+)?([A-Za-z_]\w*)\s*:[^=]*=\s*([\s\S]+)$/.exec(s);
1085
+ if (cst) {
1086
+ const n = _addNode(nodes, { kind: 'assign', target: cst[1], source: _lowerExpr(cst[2]), line });
1087
+ _link(nodes, prev, n);
1088
+ prev = n;
1089
+ continue;
1090
+ }
1091
+ // let
1092
+ const letM = /^let\s+([\s\S]+?)\s*=\s*([\s\S]+)$/.exec(s);
1093
+ if (letM || /^let\b/.test(s)) {
1094
+ if (!letM) continue; // `let x;` declares without a value
1095
+ let patText = letM[1];
1096
+ const typeIdx = _topLevelColon(patText);
1097
+ if (typeIdx >= 0) patText = patText.slice(0, typeIdx);
1098
+ let rhs = letM[2].trim();
1099
+ const rhsStart = start + s.indexOf(letM[2], letM[0].length - letM[2].length);
1100
+ // let-else: `let PAT = EXPR else { … };`
1101
+ const letElse = /^([\s\S]+?)\s+else\s*\{[\s\S]*\}$/.exec(rhs);
1102
+ let elseBody = null;
1103
+ if (letElse && !/^(?:if|match)\b/.test(rhs)) {
1104
+ const ob = rhs.lastIndexOf('else');
1105
+ const eb = _extractBody(rhs, rhs.indexOf('{', ob));
1106
+ if (eb) { elseBody = { text: eb.body, abs: rhsStart + rhs.indexOf('{', ob) + 1 }; rhs = letElse[1].trim(); }
1107
+ }
1108
+ let sourceExpr;
1109
+ if (_CTRL_RE.test(rhs)) {
1110
+ const branchTails = [];
1111
+ prev = _buildControl(rhs, rhsStart, nodes, prev, branchTails, depth + 1);
1112
+ const branches = branchTails.filter(b => b && b.kind !== 'unknown');
1113
+ sourceExpr = branches.length === 1 ? branches[0] : branches.length ? { kind: 'union', branches } : U;
1114
+ } else if (_CLOSURE_RE.test(rhs)) {
1115
+ continue; // closures stored for later invocation are not modeled
1116
+ } else {
1117
+ const chain = _parseChain(rhs);
1118
+ if (chain && chain.end >= rhs.length && _hasClosureArg(chain)) {
1119
+ sourceExpr = _lowerChainNoClosures(chain);
1120
+ prev = _inlineClosures(chain, rhs, rhsStart, nodes, prev, depth);
1121
+ } else {
1122
+ sourceExpr = _lowerExpr(rhs);
1123
+ }
1124
+ }
1125
+ prev = _bindPattern(nodes, prev, patText, sourceExpr, line);
1126
+ if (elseBody) prev = _buildCfg(elseBody.text, elseBody.abs, nodes, prev, null, depth + 1);
1127
+ continue;
1128
+ }
1129
+ // control flow at statement position
1130
+ if (_CTRL_RE.test(s)) {
1131
+ const isTail = isLast && !terminated && tails !== undefined;
1132
+ const branchTails = isTail ? [] : null;
1133
+ prev = _buildControl(s, start, nodes, prev, isTail ? (tails || branchTails) : null, depth + 1);
1134
+ if (isTail && tails === null) {
1135
+ const branches = branchTails.filter(b => b && b.kind !== 'unknown');
1136
+ if (branches.length) {
1137
+ const r = _addNode(nodes, { kind: 'return', line, value: branches.length === 1 ? branches[0] : { kind: 'union', branches } });
1138
+ _link(nodes, prev, r);
1139
+ prev = r;
1140
+ }
1141
+ }
1142
+ continue;
1143
+ }
1144
+ // expression statement with closure args: keep the call, inline bodies
1145
+ const chain = _parseChain(s);
1146
+ if (chain && chain.end >= s.length && _hasClosureArg(chain)) {
1147
+ const lowered = _lowerChainNoClosures(chain);
1148
+ if (lowered.kind === 'call') {
1149
+ const n = _addNode(nodes, { kind: 'call', line, callee: lowered.callee, args: lowered.args });
1150
+ _link(nodes, prev, n);
1151
+ prev = n;
1152
+ }
1153
+ prev = _inlineClosures(chain, s, start, nodes, prev, depth);
1154
+ continue;
1155
+ }
1156
+ // tail expression (no `;`): the block's value
1157
+ if (isLast && !terminated && !/^return\b/.test(s)) {
1158
+ const expr = _lowerExpr(s);
1159
+ if (tails) { tails.push(expr); continue; }
1160
+ if (tails === null) {
1161
+ if (expr.kind === 'call') {
1162
+ const c = _addNode(nodes, { kind: 'call', line, callee: expr.callee, args: expr.args });
1163
+ _link(nodes, prev, c);
1164
+ prev = c;
1165
+ }
1166
+ const r = _addNode(nodes, { kind: 'return', line, value: expr });
1167
+ _link(nodes, prev, r);
1168
+ prev = r;
1169
+ continue;
1170
+ }
1171
+ }
1172
+ const node = _lowerStmt(s, line);
1173
+ if (!node) continue;
1174
+ const id = _addNode(nodes, node);
1175
+ _link(nodes, prev, id);
1176
+ prev = id;
1177
+ }
1178
+ return prev;
1179
+ }
1180
+
1181
+ function _topLevelColon(s) {
1182
+ let depth = 0;
1183
+ for (let i = 0; i < s.length; i++) {
1184
+ const c = s[i];
1185
+ if (c === '(' || c === '[' || c === '{' || c === '<') depth++;
1186
+ else if (c === ')' || c === ']' || c === '}' || c === '>') depth--;
1187
+ else if (c === ':' && depth === 0) return i;
1188
+ }
1189
+ return -1;
1190
+ }
1191
+
1192
+ // ─── items ───────────────────────────────────────────────────────────────────
1193
+ // A REAL ReDoS, found via this repo's own self-scan gate after this file
1194
+ // merged (confirmed by direct timing, not the heuristic alone: ~50s on
1195
+ // 1000 repeated `#[a] ` attributes with no trailing `fn`, i.e. the ordinary
1196
+ // shape of a file this parser is scanning that simply never reaches a match).
1197
+ // The original version of this regex chained THREE separately-quantified
1198
+ // constructs before `fn` — an attribute-skip star, an optional `pub`, and a
1199
+ // qualifier-skip star — each one individually timed as linear in isolation
1200
+ // (see the file-header note this repo's own review left on the sibling
1201
+ // `(?:#\[[^\]]*\]\s*)+` shape in `_parseParams`), but a backtracking engine
1202
+ // does not know the alternatives inside a `(?:A|B|C)*` are mutually
1203
+ // exclusive on real input: on eventual overall-match failure (no `fn` ever
1204
+ // reached) it explores every way the star COULD have divided its iterations
1205
+ // among A/B/C, which is exponential regardless of how "obviously
1206
+ // unambiguous" the alternation looks to a human reader — confirmed by
1207
+ // re-timing a version that merged all three into one alternation-based star
1208
+ // and finding it was STILL exponential (100ms+ on just 20 attributes).
1209
+ // The actual fix is not a smarter regex at all: none of the skipped prefix
1210
+ // text was ever used for anything. `_precedingAttributes` (below) already
1211
+ // independently, safely (plain character-by-character backward scan, no
1212
+ // regex) recovers real attribute text for route detection, and neither
1213
+ // `fnKw` nor `name` extraction in the scan loop needs the qualifiers
1214
+ // matched — `\bfn\b` already can't match inside a longer identifier
1215
+ // (`myfn`, `fn_pointer`) on its own, since `\w` includes `_` and a word
1216
+ // boundary requires a transition. Verified linear up to 500 000 repeated
1217
+ // attributes+qualifiers with no trailing `fn` (single-digit ms).
1218
+ const _FN_RE = /\bfn\s+([A-Za-z_]\w*)/g;
1219
+ const _IMPL_RE = /(?:^|[\n;{}])\s*(?:unsafe\s+)?(?:impl|trait)\b/g;
1220
+
1221
+ function _findImplRanges(src) {
1222
+ const ranges = [];
1223
+ _IMPL_RE.lastIndex = 0;
1224
+ let m;
1225
+ while ((m = _IMPL_RE.exec(src)) !== null) {
1226
+ const kwIdx = m.index + m[0].search(/(?:impl|trait)\b/);
1227
+ const brace = _headerBrace(src, kwIdx);
1228
+ if (brace < 0) continue;
1229
+ const ex = _extractBody(src, brace);
1230
+ if (!ex) continue;
1231
+ let header = src.slice(kwIdx, brace).replace(/^(?:impl|trait)\s*/, '');
1232
+ header = header.replace(/^<[^>]*>\s*/, '');
1233
+ const forIdx = header.search(/\bfor\b/);
1234
+ if (forIdx >= 0) header = header.slice(forIdx + 3);
1235
+ const whereIdx = header.search(/\bwhere\b/);
1236
+ if (whereIdx >= 0) header = header.slice(0, whereIdx);
1237
+ header = header.replace(/^\s*&\s*(?:'\w+\s+)?(?:mut\s+)?/, '').trim();
1238
+ const name = _typeRoot(header);
1239
+ ranges.push({ start: brace, end: ex.end, name });
1240
+ _IMPL_RE.lastIndex = brace + 1;
1241
+ }
1242
+ return ranges;
1243
+ }
1244
+
1245
+ function _enclosingImpl(ranges, idx) {
1246
+ let best = null;
1247
+ for (const r of ranges) {
1248
+ if (idx > r.start && idx < r.end && (!best || r.start > best.start)) best = r;
1249
+ }
1250
+ return best ? best.name : null;
1251
+ }
1252
+
1253
+ function _qid(file, name, line, body) {
1254
+ const sha = crypto.createHash('sha256').update(body).digest('hex').slice(0, 8);
1255
+ return `${file}::${name}@${line}#${sha}`;
1256
+ }
1257
+
1258
+ export function parseRustFile(file, code) {
1259
+ if (!file || typeof code !== 'string') return null;
1260
+ if (!/\.rs$/i.test(file)) return null;
1261
+ if (!code.trim() || code.length > 1_000_000) return null;
1262
+
1263
+ const src = _preprocess(code);
1264
+ _src = src;
1265
+ _lineStarts = _computeLineStarts(src);
1266
+ _nid = 0;
1267
+ const implRanges = _findImplRanges(src);
1268
+ const functions = [];
1269
+ _FN_RE.lastIndex = 0;
1270
+ let m;
1271
+ while ((m = _FN_RE.exec(src)) !== null) {
1272
+ const name = m[1];
1273
+ // _FN_RE is now the bare `\bfn\s+(name)` pattern (see its own header
1274
+ // comment for why), so the match always starts exactly at "fn" itself —
1275
+ // no leading anchor/whitespace/qualifier text is ever consumed into
1276
+ // m[0] anymore.
1277
+ const fnKw = m.index;
1278
+ let i = m.index + m[0].length;
1279
+ // generics
1280
+ while (i < src.length && /\s/.test(src[i])) i++;
1281
+ if (src[i] === '<') {
1282
+ let d = 0;
1283
+ for (; i < src.length; i++) {
1284
+ if (src[i] === '<') d++;
1285
+ else if (src[i] === '>' && src[i - 1] !== '-') { d--; if (d === 0) { i++; break; } }
1286
+ }
1287
+ }
1288
+ while (i < src.length && /\s/.test(src[i])) i++;
1289
+ if (src[i] !== '(') continue;
1290
+ const paramsClose = _matchingClose(src, i);
1291
+ if (paramsClose < 0) continue;
1292
+ const paramsText = src.slice(i + 1, paramsClose);
1293
+ // find body `{` or a declaration `;`
1294
+ let j = paramsClose + 1;
1295
+ let brace = -1;
1296
+ let d = 0;
1297
+ for (; j < src.length;) {
1298
+ const e = _literalEnd(src, j);
1299
+ if (e > j) { j = e; continue; }
1300
+ const c = src[j];
1301
+ if (c === '(' || c === '[') d++;
1302
+ else if (c === ')' || c === ']') d--;
1303
+ else if (c === ';' && d === 0) break;
1304
+ else if (c === '{' && d === 0) { brace = j; break; }
1305
+ j++;
1306
+ }
1307
+ if (brace < 0) continue;
1308
+ const extracted = _extractBody(src, brace);
1309
+ if (!extracted) continue;
1310
+ const attrs = _precedingAttributes(src, _skipFnQualifiersBackward(src, fnKw));
1311
+ const hasRouteAttr = _ROUTE_ATTR_RE.test(attrs);
1312
+ const { params, paramAnnotations } = _parseParams(paramsText, hasRouteAttr);
1313
+ const startLine = _lineForOffset(fnKw);
1314
+ const className = _enclosingImpl(implRanges, fnKw);
1315
+ const nodes = {};
1316
+ const entry = _addNode(nodes, { kind: 'entry', line: startLine });
1317
+ const exit = _addNode(nodes, { kind: 'exit', line: startLine });
1318
+ let tail;
1319
+ try {
1320
+ tail = _buildCfg(extracted.body, brace + 1, nodes, entry, null, 0);
1321
+ } catch (err) {
1322
+ if (err instanceof RangeError) { tail = entry; } else throw err;
1323
+ }
1324
+ _link(nodes, tail, exit);
1325
+ const cfg = { entry, exit, nodes };
1326
+ const qualified = className ? `${className}.${name}` : name;
1327
+ functions.push({
1328
+ qid: _qid(file, qualified, startLine, extracted.body),
1329
+ name: qualified, line: startLine, params, file,
1330
+ cfg,
1331
+ calls: callSitesFromCfg(cfg),
1332
+ ...(paramAnnotations.length ? { paramAnnotations } : {}),
1333
+ });
1334
+ // nested fns are found by continuing the scan from inside the body
1335
+ _FN_RE.lastIndex = brace + 1;
1336
+ }
1337
+ return functions.length ? { file, functions, topLevel: null } : null;
1338
+ }