@clear-capabilities/agentic-security-scanner 0.128.1 → 0.132.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 (87) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/bin/agentic-security.js +52 -2
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +498 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +220 -0
  7. package/dist/238.index.js +218 -0
  8. package/dist/259.index.js +975 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/415.index.js +1 -1
  11. package/dist/435.index.js +4 -4
  12. package/dist/526.index.js +844 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/830.index.js +1 -1
  15. package/dist/agentic-security.mjs +106 -194
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +33 -17
  18. package/src/dataflow/CLAUDE.md +4 -1
  19. package/src/dataflow/async-sequencing.js +8 -3
  20. package/src/dataflow/catalog.js +278 -11
  21. package/src/dataflow/cross-repo.js +1 -1
  22. package/src/dataflow/cross-service-taint.js +1 -1
  23. package/src/dataflow/engine.js +182 -61
  24. package/src/dataflow/ifds.js +10 -5
  25. package/src/dataflow/index.js +15 -3
  26. package/src/dataflow/points-to.js +8 -2
  27. package/src/dataflow/proof-gate.js +7 -0
  28. package/src/dataflow/sanitizer-gate.js +89 -0
  29. package/src/dataflow/tabulation.js +14 -3
  30. package/src/engine.js +170 -7
  31. package/src/integrations/index.js +1 -1
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +13 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +2 -2
  49. package/src/posture/CLAUDE.md +193 -1
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +202 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/corpus-enroll.js +303 -0
  56. package/src/posture/corpus-match.js +52 -0
  57. package/src/posture/cross-lang-openapi.js +1 -1
  58. package/src/posture/custom-rules.js +3 -3
  59. package/src/posture/execution-proof.js +92 -0
  60. package/src/posture/exploitability-probability.js +1 -1
  61. package/src/posture/falsification.js +45 -1
  62. package/src/posture/fix-metrics.js +197 -0
  63. package/src/posture/fix-verify.js +129 -2
  64. package/src/posture/license-policy.js +1 -1
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +0 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/report/index.js +11 -0
  76. package/src/runScan.js +5 -7
  77. package/src/sandbox/CLAUDE.md +340 -0
  78. package/src/sandbox/backend-disabled.js +14 -0
  79. package/src/sandbox/backend-namespace.js +335 -0
  80. package/src/sandbox/backend-userspace.js +83 -0
  81. package/src/sandbox/capabilities.js +181 -0
  82. package/src/sandbox/index.js +30 -0
  83. package/src/sandbox/limits.js +63 -0
  84. package/src/sandbox/result.js +104 -0
  85. package/src/sca/dep-confusion.js +1 -1
  86. package/src/util/glob.js +173 -0
  87. package/src/util/yaml.js +24 -0
@@ -0,0 +1,829 @@
1
+ // C / C++ IR frontend.
2
+ //
3
+ // Hand-rolled, following the parser-cs.js / parser-go.js template. See
4
+ // docs/PROOF_CORPUS_PRD.md §6.3 for why this is not tree-sitter or libclang:
5
+ // the build excludes the tree-sitter deps from the bundle, and libclang would
6
+ // require native bindings plus a compile database we deliberately never build.
7
+ //
8
+ // The translation-unit splitter is the brace-balanced algorithm proven in
9
+ // sast/cpp-dataflow.js (written that way because a regex approach exhibited
10
+ // catastrophic backtracking on real headers), extended here to capture
11
+ // qualified names like `core::Buffer::size`.
12
+ //
13
+ // What we model:
14
+ // - free functions, out-of-line methods (Ns::Class::method), constructors,
15
+ // destructors, in-class method definitions
16
+ // - header declarations (no body) recorded with isDeclaration: true
17
+ // - parameters including refs, pointers, const, templates, default args
18
+ // - class records with base classes, for class-hierarchy analysis
19
+ //
20
+ // What we do NOT model (PRD §6.5 — this list is a contract):
21
+ // - templates (parsed; type parameters erased — one record per template,
22
+ // not one per instantiation)
23
+ // - operator overloading semantics beyond string building
24
+ // - exact virtual dispatch (approximated via CHA)
25
+ // - try/catch edges (throw is a node; handler edges are not built)
26
+ // - pointer aliasing beyond direct assignment
27
+ // - function-like macros, token pasting, conditional-compilation selection
28
+ // - goto, multiple-inheritance vtable layout, placement new
29
+ // - raw string literals are BLANKED but their delimiter grammar is only
30
+ // partially modelled: `R"delim(...)delim"` is recognised (including the
31
+ // u8/u/U/L encoding prefixes) and blanked whole; an unterminated raw
32
+ // string blanks to end-of-file, which is what a compiler sees too. A
33
+ // malformed opener (no `(`, or a delimiter containing whitespace/parens/
34
+ // backslash) falls back to the ordinary string rule.
35
+ // - C++14 digit separators (`1'000'000`) are recognised as separators, not
36
+ // as char-literal quotes, by a local token test (see `_isDigitSeparator`);
37
+ // a pathological `'` that is neither is still treated as a quote.
38
+ // - an expression chain of more than `_MAX_BINARY_TERMS` top-level binary
39
+ // terms is lowered to a FLAT `tpl` of its first 32 terms rather than a
40
+ // deep binary tree (see `_lowerExpr`) — taint still flows through those
41
+ // terms, but the tree shape is not faithful.
42
+
43
+ import * as crypto from 'node:crypto';
44
+ import { callSitesFromCfg as _callSitesFromCfg } from './call-sites.js';
45
+
46
+ const CPP_EXT_RE = /\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/i;
47
+
48
+ // Keywords that are followed by `(...) {` but are not functions.
49
+ const _NON_FN_KEYWORDS = new Set([
50
+ 'if', 'else', 'for', 'while', 'switch', 'catch', 'do', 'return', 'sizeof',
51
+ 'and', 'or', 'not', 'new', 'delete', 'throw', 'case', 'default',
52
+ ]);
53
+
54
+ // Guard rails so a pathological file cannot dominate a scan.
55
+ const _MAX_FUNCTIONS = 5000;
56
+ const _LOOKBACK = 400;
57
+ // Longest `R"delim(` delimiter the standard allows is 16 characters.
58
+ const _MAX_RAW_DELIM = 16;
59
+ // Above this many top-level terms in one binary chain, `_lowerExpr` emits a
60
+ // flat node instead of a nested tree. A nested tree of depth N is built by N
61
+ // recursive calls AND re-walked recursively by every consumer
62
+ // (`_collectCallExprs`, the dataflow engine), so an N-term chain in a
63
+ // generated source file is a stack-overflow bomb. 32 terms is far beyond any
64
+ // hand-written expression while keeping the tree depth trivially safe.
65
+ const _MAX_BINARY_TERMS = 32;
66
+
67
+ export function cppExtRe() { return CPP_EXT_RE; }
68
+
69
+ // ── comment / string blanking ───────────────────────────────────────────────
70
+ // Replace comment and string bodies with spaces, preserving length and line
71
+ // structure so every index and line number computed later stays valid.
72
+ //
73
+ // Two C++ literal forms below are here because getting them wrong is SILENT:
74
+ // the file still counts as "parsed" in the coverage metric while functions
75
+ // vanish from its IR.
76
+ //
77
+ // - C++14 digit separators (`1'000'000`). Treating that `'` as a char-literal
78
+ // opener blanks everything up to the next quote anywhere later in the file.
79
+ // Measured on a real corpus file (Godot's editor/editor_node.cpp): 298
80
+ // functions with the bug, 302 with the separators stripped.
81
+ // - Raw strings (`R"(...)"`). A raw string containing a lone `"` or `'`
82
+ // desynchronises the blanker for the remainder of the file, which yielded
83
+ // ZERO functions for the whole translation unit.
84
+
85
+ // True when the `'` at `src[i]` is a C++14 digit separator rather than a
86
+ // char-literal opener: it sits between two hex digits AND the token it is
87
+ // embedded in starts with a decimal digit (so `1'000` and `0xFF'FF` are
88
+ // separators, while `x'a'` — not legal C++ anyway — is still a quote).
89
+ function _isDigitSeparator(src, i) {
90
+ const prev = src[i - 1], next = src[i + 1];
91
+ if (!prev || !next) return false;
92
+ if (!/[0-9a-fA-F]/.test(prev) || !/[0-9a-fA-F]/.test(next)) return false;
93
+ let k = i - 1;
94
+ while (k > 0 && /[0-9A-Za-z_.']/.test(src[k - 1])) k--;
95
+ return /[0-9]/.test(src[k]);
96
+ }
97
+
98
+ // True when the `"` at `src[i]` opens a raw string literal — i.e. it is
99
+ // preceded by `R`, optionally prefixed by one of the encoding prefixes
100
+ // (`u8R"`, `uR"`, `UR"`, `LR"`), and not by a longer identifier that merely
101
+ // happens to end in `R`.
102
+ function _isRawStringOpen(src, i) {
103
+ if (src[i - 1] !== 'R') return false;
104
+ let j = i - 2, pre = '';
105
+ while (j >= 0 && /[0-9A-Za-z_]/.test(src[j]) && pre.length < 2) { pre = src[j] + pre; j--; }
106
+ if (j >= 0 && /[0-9A-Za-z_]/.test(src[j])) return false;
107
+ return pre === '' || pre === 'L' || pre === 'u' || pre === 'U' || pre === 'u8';
108
+ }
109
+
110
+ function _blank(src) {
111
+ const out = src.split('');
112
+ let i = 0;
113
+ const n = src.length;
114
+ while (i < n) {
115
+ const c = src[i], d = src[i + 1];
116
+ if (c === '/' && d === '/') {
117
+ while (i < n && src[i] !== '\n') { out[i] = ' '; i++; }
118
+ continue;
119
+ }
120
+ if (c === '/' && d === '*') {
121
+ out[i] = ' '; out[i + 1] = ' '; i += 2;
122
+ while (i < n && !(src[i] === '*' && src[i + 1] === '/')) {
123
+ if (src[i] !== '\n') out[i] = ' ';
124
+ i++;
125
+ }
126
+ if (i < n) { out[i] = ' '; out[i + 1] = ' '; i += 2; }
127
+ continue;
128
+ }
129
+ if (c === "'" && _isDigitSeparator(src, i)) { i++; continue; }
130
+ if (c === '"' && _isRawStringOpen(src, i)) {
131
+ const open = src.indexOf('(', i + 1);
132
+ const delim = open > i ? src.slice(i + 1, open) : null;
133
+ if (delim !== null && delim.length <= _MAX_RAW_DELIM && !/[\s()\\]/.test(delim)) {
134
+ const close = src.indexOf(`)${delim}"`, open + 1);
135
+ // Unterminated raw string: blank to EOF, which is what a compiler
136
+ // sees. Losing the tail of a file whose raw string never closes beats
137
+ // desynchronising the blanker and losing the file's whole IR.
138
+ const end = close === -1 ? n : close + delim.length + 2;
139
+ for (let k = i; k < end && k < n; k++) if (src[k] !== '\n') out[k] = ' ';
140
+ i = end;
141
+ continue;
142
+ }
143
+ // Malformed opener — fall through to the ordinary string rule below.
144
+ }
145
+ if (c === '"' || c === "'") {
146
+ const quote = c;
147
+ out[i] = ' '; // blank the opening quote too, symmetric with the closing one below
148
+ i++;
149
+ while (i < n) {
150
+ if (src[i] === '\\') {
151
+ out[i] = ' ';
152
+ if (i + 1 < n && src[i + 1] !== '\n') out[i + 1] = ' ';
153
+ i += 2;
154
+ continue;
155
+ }
156
+ if (src[i] === quote) { out[i] = ' '; i++; break; }
157
+ if (src[i] !== '\n') out[i] = ' ';
158
+ i++;
159
+ }
160
+ continue;
161
+ }
162
+ i++;
163
+ }
164
+ return out.join('');
165
+ }
166
+
167
+ function _lineAt(src, idx) {
168
+ let line = 1;
169
+ for (let i = 0; i < idx && i < src.length; i++) if (src[i] === '\n') line++;
170
+ return line;
171
+ }
172
+
173
+ // Build a sorted array of line-start offsets for `text` (index 0 holds the
174
+ // start of line 1, i.e. always 0). Used with `_lineForOffset` to turn a
175
+ // character offset into a line number in O(log n) instead of O(n) — a
176
+ // per-statement `_lineAt` rescan from offset 0 is O(n) per call and O(n^2)
177
+ // over a whole function body, which is exactly the "hangs on a huge
178
+ // generated/unity-build source file" failure mode this parser must avoid.
179
+ function _lineStarts(text) {
180
+ const starts = [0];
181
+ for (let i = 0; i < text.length; i++) {
182
+ if (text[i] === '\n') starts.push(i + 1);
183
+ }
184
+ return starts;
185
+ }
186
+
187
+ // Binary search `lineStarts` (as built by `_lineStarts`) for the 1-based
188
+ // line number containing offset `idx`.
189
+ function _lineForOffset(lineStarts, idx) {
190
+ let lo = 0, hi = lineStarts.length - 1;
191
+ while (lo < hi) {
192
+ const mid = (lo + hi + 1) >> 1;
193
+ if (lineStarts[mid] <= idx) lo = mid; else hi = mid - 1;
194
+ }
195
+ return lo + 1;
196
+ }
197
+
198
+ function _qid(file, tail, line, body) {
199
+ const sha = crypto.createHash('sha256').update(body).digest('hex').slice(0, 8);
200
+ return `${file}::${tail}@${line}#${sha}`;
201
+ }
202
+
203
+ // Split `a, b<c, d>, e(f, g)` on top-level commas only.
204
+ function _splitTopLevelCommas(s) {
205
+ const out = [];
206
+ let depth = 0, buf = '';
207
+ for (const ch of String(s || '')) {
208
+ if (ch === '(' || ch === '[' || ch === '<' || ch === '{') depth++;
209
+ else if (ch === ')' || ch === ']' || ch === '>' || ch === '}') depth--;
210
+ if (ch === ',' && depth === 0) { out.push(buf); buf = ''; continue; }
211
+ buf += ch;
212
+ }
213
+ if (buf.trim()) out.push(buf);
214
+ return out;
215
+ }
216
+
217
+ // `const std::string& name` → 'name'; `int n = 10` → 'n'; `void` → null.
218
+ function _parseParams(text) {
219
+ const params = [];
220
+ for (const raw of _splitTopLevelCommas(text)) {
221
+ let t = raw.replace(/=.*$/s, '').trim();
222
+ if (!t || t === 'void' || t === '...') continue;
223
+ // Array suffix: `char buf[10]` → `char buf`
224
+ t = t.replace(/\[[^\]]*\]\s*$/, '').trim();
225
+ const m = t.match(/([A-Za-z_]\w*)\s*$/);
226
+ if (m && !_NON_FN_KEYWORDS.has(m[1])) params.push(m[1]);
227
+ }
228
+ return params;
229
+ }
230
+
231
+ // Body extraction by brace counting over already-blanked source.
232
+ function _extractBody(blank, openBrace) {
233
+ let depth = 1;
234
+ let i = openBrace + 1;
235
+ while (i < blank.length && depth > 0) {
236
+ const c = blank[i];
237
+ if (c === '{') depth++;
238
+ else if (c === '}') depth--;
239
+ if (depth === 0) return { start: openBrace + 1, end: i };
240
+ i++;
241
+ }
242
+ return null; // unterminated — caller skips
243
+ }
244
+
245
+ // Walk back from a `(` to capture a possibly-qualified name: `core::Buffer::size`,
246
+ // `~Widget`, `operator+` is deliberately NOT captured (out of scope).
247
+ function _nameBefore(blank, lparen) {
248
+ let end = lparen - 1;
249
+ while (end >= 0 && /\s/.test(blank[end])) end--;
250
+ if (end < 0) return null;
251
+ let start = end;
252
+ while (start >= 0 && /[A-Za-z0-9_:~]/.test(blank[start])) start--;
253
+ start++;
254
+ const raw = blank.slice(start, end + 1);
255
+ if (!raw || !/^[~A-Za-z_][\w:~]*$/.test(raw)) return null;
256
+ if (raw.includes(':') && !raw.includes('::')) return null; // label, not scope
257
+ return raw;
258
+ }
259
+
260
+ // Find enclosing class for an index, from collected class spans.
261
+ function _enclosingClass(classSpans, idx) {
262
+ let best = null;
263
+ for (const c of classSpans) {
264
+ if (idx > c.open && idx < c.close) {
265
+ if (!best || c.open > best.open) best = c;
266
+ }
267
+ }
268
+ return best ? best.name : null;
269
+ }
270
+
271
+ // Collect `class X : public A, private B {` / `struct X {` spans.
272
+ function _findClasses(blank) {
273
+ const spans = [];
274
+ const re = /\b(?:class|struct)\s+([A-Za-z_]\w*)\s*(?::([^{;]*))?\{/g;
275
+ let m;
276
+ while ((m = re.exec(blank)) !== null) {
277
+ const open = m.index + m[0].length - 1;
278
+ const body = _extractBody(blank, open);
279
+ if (!body) continue;
280
+ const bases = [];
281
+ if (m[2]) {
282
+ for (const part of _splitTopLevelCommas(m[2])) {
283
+ const b = part.replace(/\b(?:public|private|protected|virtual)\b/g, '').trim();
284
+ const last = b.split('::').pop();
285
+ if (last && /^[A-Za-z_]\w*$/.test(last)) bases.push(last);
286
+ }
287
+ }
288
+ spans.push({ name: m[1], bases, line: _lineAt(blank, m.index), open, close: body.end });
289
+ }
290
+ return spans;
291
+ }
292
+
293
+ // Collect `namespace foo { ... }` spans (including nested), resolving each to
294
+ // its fully-qualified path (`outer::inner`) so a free function or method
295
+ // defined inline inside a namespace block — with no `::` written at the
296
+ // definition site — still gets a project-wide-unique qname. Explicit
297
+ // out-of-line qualification (`void Ns::Class::method()`) is handled
298
+ // separately by the caller and takes precedence.
299
+ function _findNamespaces(blank) {
300
+ const re = /\bnamespace\s+([A-Za-z_]\w*)\s*\{/g;
301
+ const spans = [];
302
+ let m;
303
+ while ((m = re.exec(blank)) !== null) {
304
+ const open = m.index + m[0].length - 1;
305
+ const body = _extractBody(blank, open);
306
+ if (!body) continue;
307
+ spans.push({ name: m[1], open, close: body.end, full: null });
308
+ }
309
+ const fullName = (s, seen) => {
310
+ if (s.full) return s.full;
311
+ seen.add(s);
312
+ let parent = null;
313
+ for (const other of spans) {
314
+ if (other === s || seen.has(other)) continue;
315
+ if (s.open > other.open && s.close < other.close) {
316
+ if (!parent || other.open > parent.open) parent = other;
317
+ }
318
+ }
319
+ s.full = parent ? `${fullName(parent, seen)}::${s.name}` : s.name;
320
+ return s.full;
321
+ };
322
+ for (const s of spans) fullName(s, new Set());
323
+ return spans;
324
+ }
325
+
326
+ function _enclosingNamespace(nsSpans, idx) {
327
+ let best = null;
328
+ for (const s of nsSpans) {
329
+ if (idx > s.open && idx < s.close) {
330
+ if (!best || s.open > best.open) best = s;
331
+ }
332
+ }
333
+ return best ? best.full : null;
334
+ }
335
+
336
+ // Pre-compute, in ONE linear pass, the index of the `)` that closes each `(`
337
+ // (-1 when it never closes). The previous inline rescan-to-EOF per `(` was
338
+ // O(n^2) whenever parens don't match — and preprocessor-heavy C++ produces
339
+ // unmatched parens routinely, since `#if`-guarded halves of a construct are
340
+ // both present in the raw text. Measured before this change: a file with
341
+ // 200,000 unmatched `(` took 32.9 s, and `_MAX_FUNCTIONS` did not bound it
342
+ // because unmatched parens push nothing into `found`.
343
+ function _matchParens(blank) {
344
+ const match = new Int32Array(blank.length).fill(-1);
345
+ const stack = [];
346
+ for (let i = 0; i < blank.length; i++) {
347
+ const ch = blank[i];
348
+ if (ch === '(') stack.push(i);
349
+ else if (ch === ')' && stack.length) match[stack.pop()] = i;
350
+ }
351
+ return match;
352
+ }
353
+
354
+ // Find every function definition (with body) and declaration (no body).
355
+ function _findFunctions(blank, classSpans) {
356
+ const found = [];
357
+ const n = blank.length;
358
+ const closeOf = _matchParens(blank);
359
+ let i = 0;
360
+ while (i < n && found.length < _MAX_FUNCTIONS) {
361
+ const ch = blank[i];
362
+ if (ch !== '(') { i++; continue; }
363
+ const close = closeOf[i];
364
+ if (close < 0) { i++; continue; }
365
+ const j = close + 1;
366
+ const paramText = blank.slice(i + 1, j - 1);
367
+ const name = _nameBefore(blank, i);
368
+ if (!name) { i++; continue; }
369
+ const bare = name.split('::').pop();
370
+ if (!bare || _NON_FN_KEYWORDS.has(bare)) { i++; continue; }
371
+ if (name.length > _LOOKBACK) { i++; continue; }
372
+
373
+ // After the params: skip const/noexcept/override/final/ref-qualifiers.
374
+ let k = j;
375
+ while (k < n && /[\s\w&]/.test(blank[k])) {
376
+ // Stop if we hit something that starts a body or ends a declaration.
377
+ if (blank[k] === '{' || blank[k] === ';') break;
378
+ k++;
379
+ }
380
+ const term = blank[k];
381
+ if (term === '{') {
382
+ const body = _extractBody(blank, k);
383
+ if (!body) { i = j; continue; }
384
+ found.push({
385
+ name, paramText,
386
+ line: _lineAt(blank, i),
387
+ bodyStart: body.start, bodyEnd: body.end,
388
+ isDeclaration: false,
389
+ nameIdx: i,
390
+ });
391
+ i = body.end + 1;
392
+ continue;
393
+ }
394
+ if (term === ';') {
395
+ // A declaration only counts when it sits inside a class body — otherwise
396
+ // it is very likely a call statement or a prototype we do not need.
397
+ const cls = _enclosingClass(classSpans, i);
398
+ if (cls) {
399
+ found.push({
400
+ name, paramText,
401
+ line: _lineAt(blank, i),
402
+ bodyStart: null, bodyEnd: null,
403
+ isDeclaration: true,
404
+ nameIdx: i,
405
+ });
406
+ }
407
+ i = k + 1;
408
+ continue;
409
+ }
410
+ i = j;
411
+ }
412
+ return found;
413
+ }
414
+
415
+ // `fn.calls` (`[{ site, callee, args, line }]`, documented at parser-js.js:19)
416
+ // is built by the shared `callSitesFromCfg` in ./call-sites.js — see that
417
+ // module's header for why it is shared rather than a local copy.
418
+
419
+ export function parseCppFile(file, code) {
420
+ if (typeof file !== 'string' || typeof code !== 'string') return null;
421
+ const blank = _blank(code);
422
+ const classSpans = _findClasses(blank);
423
+ const namespaceSpans = _findNamespaces(blank);
424
+ const raw = _findFunctions(blank, classSpans);
425
+
426
+ const functions = [];
427
+ for (const f of raw) {
428
+ const bare = f.name.split('::').pop();
429
+ const explicitScope = f.name.includes('::')
430
+ ? f.name.slice(0, f.name.lastIndexOf('::'))
431
+ : null;
432
+ const enclosing = _enclosingClass(classSpans, f.nameIdx);
433
+ const enclosingNs = _enclosingNamespace(namespaceSpans, f.nameIdx);
434
+ // Fully-qualified name for the cross-TU index. A definition can be
435
+ // qualified two ways: explicitly at the definition site (`void
436
+ // Ns::Class::method()`) or implicitly by sitting inside a `namespace {}`
437
+ // block with an unqualified name. Prefer the explicit form; only prepend
438
+ // the enclosing namespace when the written scope doesn't already carry it
439
+ // (avoids doubling `core::` when both are present).
440
+ let scope = explicitScope || enclosing || null;
441
+ if (enclosingNs && !(scope && scope === enclosingNs || scope && scope.startsWith(`${enclosingNs}::`))) {
442
+ scope = scope ? `${enclosingNs}::${scope}` : enclosingNs;
443
+ }
444
+ const qname = scope ? `${scope}::${bare}` : bare;
445
+ // The class used in the qid tail is the IMMEDIATE class — the last scope
446
+ // segment — because class-hierarchy.js splits the tail on its first dot.
447
+ const ownerClass = explicitScope ? explicitScope.split('::').pop() : enclosing;
448
+ const tail = ownerClass ? `${ownerClass}.${bare}` : bare;
449
+ const bodyText = f.isDeclaration ? '' : code.slice(f.bodyStart, f.bodyEnd);
450
+ // Structure (statement/brace/paren boundaries) is decided from the
451
+ // blanked body so a brace or paren inside a string/comment can't
452
+ // silently swallow the statements that follow it; the matching RAW
453
+ // slice (same offsets — `_blank` preserves length) is what actually
454
+ // gets lowered, so identifiers and call names still survive.
455
+ const blankBodyText = f.isDeclaration ? '' : blank.slice(f.bodyStart, f.bodyEnd);
456
+ const cfg = _buildCfg(bodyText, blankBodyText, f.line);
457
+
458
+ functions.push({
459
+ qid: _qid(file, tail, f.line, bodyText || `${qname}@decl`),
460
+ name: bare,
461
+ qname,
462
+ line: f.line,
463
+ params: _parseParams(f.paramText),
464
+ file,
465
+ isDeclaration: f.isDeclaration,
466
+ cfg,
467
+ calls: _callSitesFromCfg(cfg),
468
+ });
469
+ }
470
+
471
+ return {
472
+ file,
473
+ functions,
474
+ classes: classSpans.map(c => ({ name: c.name, bases: c.bases, line: c.line })),
475
+ topLevel: null,
476
+ };
477
+ }
478
+
479
+ // ── expression lowering ─────────────────────────────────────────────────────
480
+
481
+ // Split on a top-level binary operator, respecting nesting.
482
+ function _splitTopLevel(s, op) {
483
+ const out = [];
484
+ let depth = 0, buf = '';
485
+ for (let i = 0; i < s.length; i++) {
486
+ const ch = s[i];
487
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
488
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
489
+ if (depth === 0 && ch === op) { out.push(buf); buf = ''; continue; }
490
+ buf += ch;
491
+ }
492
+ out.push(buf);
493
+ return out;
494
+ }
495
+
496
+ // Normalise `a->b->c` and `a.b.c` to dotted form; `::` is preserved as scope.
497
+ function _normaliseCallee(s) {
498
+ return s.replace(/->/g, '.').trim();
499
+ }
500
+
501
+ function _lowerExpr(text) {
502
+ const s = String(text || '').trim().replace(/;$/, '').trim();
503
+ if (!s) return { kind: 'unknown' };
504
+
505
+ // String concatenation — checked before the literal rule, or `"a" + x`
506
+ // would be swallowed whole as a literal and taint could not flow.
507
+ if (s.includes('+')) {
508
+ const parts = _splitTopLevel(s, '+');
509
+ if (parts.length > 1 && parts.every(p => p.trim())) {
510
+ return { kind: 'tpl', parts: parts.map(_lowerExpr) };
511
+ }
512
+ }
513
+ if (/^"/.test(s) || /^'/.test(s)) return { kind: 'literal', value: s };
514
+ if (/^-?\d/.test(s)) return { kind: 'literal', value: s };
515
+ if (/^(?:true|false|nullptr|NULL)$/.test(s)) return { kind: 'literal', value: s };
516
+
517
+ // Cast: `(char*)expr` → lower the inner expression.
518
+ const cast = s.match(/^\(\s*[A-Za-z_][\w:\s*&<>]*\)\s*(.+)$/s);
519
+ if (cast && !/^\(\s*\)/.test(s)) return _lowerExpr(cast[1]);
520
+
521
+ // Call: name(args) / a.b(args) / a->b(args) / ns::f(args)
522
+ const call = s.match(/^([A-Za-z_][\w:.]*(?:->[A-Za-z_]\w*)*)\s*\((.*)\)$/s);
523
+ if (call) {
524
+ const callee = _normaliseCallee(call[1]);
525
+ const bare = callee.split(/[.:]/).pop();
526
+ if (!_NON_FN_KEYWORDS.has(bare)) {
527
+ return { kind: 'call', callee, args: _splitTopLevelCommas(call[2]).map(_lowerExpr) };
528
+ }
529
+ }
530
+
531
+ // Address-of / dereference: taint passes through transparently.
532
+ const unary = s.match(/^[&*]\s*(.+)$/s);
533
+ if (unary) return _lowerExpr(unary[1]);
534
+
535
+ // Member read: a.b / a->b / ns::CONST
536
+ if (/^[A-Za-z_][\w:.]*(?:->[A-Za-z_]\w*)*$/.test(s) && /[.:>]/.test(s)) {
537
+ const d = _normaliseCallee(s);
538
+ const idx = d.lastIndexOf('.');
539
+ if (idx > 0) {
540
+ return { kind: 'member', object: _lowerExpr(d.slice(0, idx)), prop: d.slice(idx + 1) };
541
+ }
542
+ return { kind: 'ident', name: d };
543
+ }
544
+
545
+ // Array index: `buf[i]` → treat as the base identifier.
546
+ const idxm = s.match(/^([A-Za-z_]\w*)\s*\[.*\]$/s);
547
+ if (idxm) return { kind: 'ident', name: idxm[1] };
548
+
549
+ if (/^[A-Za-z_]\w*$/.test(s)) return { kind: 'ident', name: s };
550
+
551
+ // Comparison / arithmetic — keep both sides so taint survives.
552
+ for (const op of ['==', '!=', '<=', '>=', '<', '>', '-', '*', '/', '%']) {
553
+ const parts = _splitTopLevel(s, op.length === 1 ? op : '\u0000');
554
+ if (op.length === 1 && parts.length > 1 && parts.every(p => p.trim())) {
555
+ // ITERATIVE left fold. The previous shape re-entered `_lowerExpr` with
556
+ // `parts.slice(1).join(op)`, i.e. once per remaining term, so an N-term
557
+ // chain recursed N deep: `a-a-a-…` with N=20,000 threw RangeError after
558
+ // 11.4 s. Beyond `_MAX_BINARY_TERMS` the result is a FLAT `tpl`,
559
+ // because even an iteratively built N-deep tree overflows the stack in
560
+ // the recursive consumers that read it (`_collectCallExprs`, the
561
+ // dataflow engine's expression walkers).
562
+ if (parts.length > _MAX_BINARY_TERMS) {
563
+ return { kind: 'tpl', parts: parts.slice(0, _MAX_BINARY_TERMS).map(p => _lowerExpr(p)) };
564
+ }
565
+ let node = _lowerExpr(parts[0]);
566
+ for (let k = 1; k < parts.length; k++) {
567
+ node = { kind: 'binary', op, left: node, right: _lowerExpr(parts[k]) };
568
+ }
569
+ return node;
570
+ }
571
+ }
572
+ return { kind: 'unknown' };
573
+ }
574
+
575
+ // ── statement splitting ─────────────────────────────────────────────────────
576
+
577
+ // Split a (blanked) body into top-level statements, returning character
578
+ // OFFSETS rather than text. The caller slices the same offsets out of both
579
+ // the blanked text (for further structural decisions) and the raw text (for
580
+ // content to lower) — see `_buildCfg`'s "structure from blank, content from
581
+ // raw" split. Operating on the blanked text means a `{`, `(`, `}` or `)`
582
+ // that only exists inside a string literal or comment in the RAW source
583
+ // (already turned to spaces by `_blank`) cannot be mistaken for real
584
+ // structure and swallow the statements that follow it.
585
+ //
586
+ // Blocks (`{...}`) are returned whole so the caller can recurse into them.
587
+ // A prior line-per-chunk approach (pre-splitting on '\n' before this
588
+ // function ran) silently dropped any statement whose opening brace and body
589
+ // lived on different physical lines, because a lone `{` or a lone
590
+ // continuation fragment doesn't round-trip through the statement grammar.
591
+ // This function is handed the WHOLE body in one call for exactly that
592
+ // reason — never split on '\n' first.
593
+ function _splitStatements(body) {
594
+ const out = [];
595
+ // `depth` tracks braces (statement/block boundaries); `parenDepth` tracks
596
+ // parens separately so the `;` separators inside a `for (init; test; step)`
597
+ // header don't get mistaken for statement terminators.
598
+ let depth = 0, parenDepth = 0, buf = '', atStart = true, stmtStart = 0;
599
+ for (let i = 0; i < body.length; i++) {
600
+ const c = body[i];
601
+ if (atStart && /\s/.test(c)) continue;
602
+ if (atStart) { atStart = false; stmtStart = i; }
603
+ if (c === '(') parenDepth++;
604
+ else if (c === ')') parenDepth = Math.max(0, parenDepth - 1);
605
+ if (c === '{') depth++;
606
+ if (c === '}') {
607
+ depth--;
608
+ buf += c;
609
+ if (depth === 0) { out.push({ start: stmtStart, end: i + 1 }); buf = ''; atStart = true; }
610
+ continue;
611
+ }
612
+ if (c === ';' && depth === 0 && parenDepth === 0) {
613
+ if (buf.trim()) out.push({ start: stmtStart, end: i });
614
+ buf = '';
615
+ atStart = true;
616
+ continue;
617
+ }
618
+ buf += c;
619
+ }
620
+ if (buf.trim()) out.push({ start: stmtStart, end: body.length });
621
+ return out;
622
+ }
623
+
624
+ // Find the index of the delimiter in `openCh`/`closeCh` that matches the one
625
+ // at `openIdx`, respecting nesting. Returns -1 if unmatched (caller must
626
+ // treat that as "give up gracefully", never hang or throw).
627
+ function _matchDelim(text, openIdx, openCh, closeCh) {
628
+ let depth = 0;
629
+ for (let i = openIdx; i < text.length; i++) {
630
+ if (text[i] === openCh) depth++;
631
+ else if (text[i] === closeCh) {
632
+ depth--;
633
+ if (depth === 0) return i;
634
+ }
635
+ }
636
+ return -1;
637
+ }
638
+
639
+ // Split `blankText`/`rawText` (same length, index-aligned — same contract as
640
+ // the blank/raw pairing everywhere else in this file) on a top-level
641
+ // occurrence of `sepChar`, deciding nesting depth from `blankText` so a
642
+ // separator character hidden inside a blanked string/comment in the raw
643
+ // text can't be mistaken for a real one. Returns the corresponding RAW
644
+ // substrings — used for the `for (init; test; step)` header split.
645
+ function _splitTopLevelAligned(blankText, rawText, sepChar) {
646
+ const out = [];
647
+ let depth = 0, start = 0;
648
+ for (let i = 0; i < blankText.length; i++) {
649
+ const ch = blankText[i];
650
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
651
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
652
+ if (depth === 0 && ch === sepChar) { out.push(rawText.slice(start, i)); start = i + 1; }
653
+ }
654
+ out.push(rawText.slice(start));
655
+ return out;
656
+ }
657
+
658
+ function _lowerStmt(stmt, line) {
659
+ const s = stmt.trim();
660
+ if (!s || s === '{' || s === '}') return null;
661
+
662
+ if (/^return\b/.test(s)) {
663
+ const v = s.replace(/^return\b/, '').trim();
664
+ return { kind: 'return', line, value: v ? _lowerExpr(v) : null };
665
+ }
666
+ if (/^throw\b/.test(s)) {
667
+ return { kind: 'throw', line, value: _lowerExpr(s.replace(/^throw\b/, '')) };
668
+ }
669
+ if (/^(?:break|continue|goto\b)/.test(s)) return { kind: 'noop', line };
670
+
671
+ // Assignment. The type prefix is optional: `int x = e`, `x = e`, `a->b = e`.
672
+ // `==`, `!=`, `<=`, `>=` must not match, hence the negative lookarounds.
673
+ const asg = s.match(/^(?:[A-Za-z_][\w:<>,*&\s]*?\s+)?([A-Za-z_][\w:.\->\[\]]*?)\s*(?<![=!<>+\-*/%])=(?!=)\s*(.+)$/s);
674
+ if (asg) {
675
+ const target = _normaliseCallee(asg[1]).replace(/\[.*\]$/, '');
676
+ return { kind: 'assign', line, target, source: _lowerExpr(asg[2]) };
677
+ }
678
+
679
+ // Statement-form call.
680
+ const call = s.match(/^([A-Za-z_][\w:.]*(?:->[A-Za-z_]\w*)*)\s*\((.*)\)$/s);
681
+ if (call) {
682
+ const callee = _normaliseCallee(call[1]);
683
+ const bare = callee.split(/[.:]/).pop();
684
+ if (!_NON_FN_KEYWORDS.has(bare)) {
685
+ return { kind: 'call', line, callee, args: _splitTopLevelCommas(call[2]).map(_lowerExpr) };
686
+ }
687
+ }
688
+ return { kind: 'unknown', line, text: s.slice(0, 200) };
689
+ }
690
+
691
+ // ── CFG construction ────────────────────────────────────────────────────────
692
+
693
+ // `rawBody` and `blankBody` are the same function body, index-aligned:
694
+ // `blankBody` (comments/strings replaced with spaces, same length) decides
695
+ // STRUCTURE — where statements/blocks/parens begin and end; `rawBody` is
696
+ // sliced at those same offsets to provide the CONTENT that actually gets
697
+ // lowered into expressions. This is what stops a brace, paren, or semicolon
698
+ // that only exists inside a string literal from being mistaken for real
699
+ // code structure and swallowing (or mis-splitting) the statements after it.
700
+ function _buildCfg(rawBody, blankBody, startLine) {
701
+ const nodes = {
702
+ entry: { kind: 'entry', line: startLine, succ: [], pred: [] },
703
+ exit: { kind: 'exit', line: startLine, succ: [], pred: [] },
704
+ };
705
+ let counter = 0;
706
+ let prev = 'entry';
707
+ const link = (id) => {
708
+ nodes[prev].succ.push(id);
709
+ nodes[id].pred.push(prev);
710
+ prev = id;
711
+ };
712
+
713
+ // Built once per function body (O(n)); looked up per statement in
714
+ // O(log n) via `_lineForOffset` — see that helper's comment for why a
715
+ // per-statement `_lineAt` rescan is unacceptable here.
716
+ const lineStarts = _lineStarts(blankBody);
717
+
718
+ // Emit statements linearly. Control-flow headers become `if` nodes and
719
+ // their blocks are recursed into, so bodies are never dropped — the same
720
+ // straight-line treatment parser-go.js and parser-cs.js use.
721
+ //
722
+ // `baseAbs` is the absolute offset of `blankText[0]` within the top-level
723
+ // `blankBody`. Every node's line number is computed from that absolute
724
+ // offset via `_lineForOffset(lineStarts, ...)` rather than from a
725
+ // per-recursion relative counter, so nested blocks get their true
726
+ // physical line instead of one measured from the function's start line
727
+ // regardless of nesting depth (a counter that restarts per recursion
728
+ // compounds error as nesting gets deeper).
729
+ const emit = (blankText, rawText, baseAbs, depth) => {
730
+ if (depth > 12) return;
731
+ for (const { start, end } of _splitStatements(blankText)) {
732
+ const blankStmt = blankText.slice(start, end);
733
+ const rawStmt = rawText.slice(start, end);
734
+ const absStart = baseAbs + start;
735
+ const line = startLine + _lineForOffset(lineStarts, absStart) - 1;
736
+
737
+ const hm = blankStmt.match(/^(if|while|for|switch|else\s+if|else|do|try|catch)\b/);
738
+ if (hm) {
739
+ const kwNorm = hm[1].replace(/\s+/g, ' ').trim();
740
+ let p = hm[0].length;
741
+ while (p < blankStmt.length && /\s/.test(blankStmt[p])) p++;
742
+
743
+ // A parenthesised group directly follows most of these keywords
744
+ // (`if (`, `while (`, `for (`, `switch (`, `catch (Type& e)`); find
745
+ // its TRUE matching close paren via a balanced scan (not a
746
+ // non-greedy regex, which stops at the first `)` and truncates on
747
+ // any nested call like `fgets(buf, sizeof(buf), stdin)`).
748
+ let condBlank = null, condRaw = null, afterHeader = p;
749
+ if (blankStmt[p] === '(') {
750
+ const closeIdx = _matchDelim(blankStmt, p, '(', ')');
751
+ if (closeIdx !== -1) {
752
+ condBlank = blankStmt.slice(p + 1, closeIdx);
753
+ condRaw = rawStmt.slice(p + 1, closeIdx);
754
+ afterHeader = closeIdx + 1;
755
+ }
756
+ }
757
+
758
+ const needsCond = /^(?:if|while|for|switch|else if)$/.test(kwNorm);
759
+ if (needsCond && condRaw !== null) {
760
+ let condForNode = condRaw;
761
+ let initRaw = null;
762
+ if (kwNorm === 'for') {
763
+ // `for (init; test; step)` — surface the test as the condition
764
+ // and the init as a leading assignment, splitting on the
765
+ // BLANKED cond so a `;` inside a string literal in the header
766
+ // can't be mistaken for a header separator.
767
+ const parts = _splitTopLevelAligned(condBlank, condRaw, ';');
768
+ condForNode = parts.length > 1 ? parts[1] : condRaw;
769
+ initRaw = parts[0];
770
+ }
771
+ const id = `n${counter++}`;
772
+ nodes[id] = { kind: 'if', line, cond: _lowerExpr(condForNode), succ: [], pred: [] };
773
+ link(id);
774
+ if (kwNorm === 'for' && initRaw) {
775
+ const initNode = _lowerStmt(initRaw, line);
776
+ if (initNode && initNode.kind === 'assign') {
777
+ const iid = `n${counter++}`;
778
+ nodes[iid] = { ...initNode, succ: [], pred: [] };
779
+ link(iid);
780
+ }
781
+ }
782
+ }
783
+
784
+ const restBlank = blankText.slice(start + afterHeader, end);
785
+ const restRaw = rawText.slice(start + afterHeader, end);
786
+ const lead = restBlank.match(/^\s*/)[0].length;
787
+ if (restBlank[lead] === '{') {
788
+ const closeRel = _matchDelim(restBlank, lead, '{', '}');
789
+ if (closeRel !== -1) {
790
+ const innerStart = lead + 1;
791
+ emit(restBlank.slice(innerStart, closeRel), restRaw.slice(innerStart, closeRel),
792
+ baseAbs + start + afterHeader + innerStart, depth + 1);
793
+ }
794
+ } else if (restBlank.trim()) {
795
+ emit(restBlank, restRaw, baseAbs + start + afterHeader, depth + 1);
796
+ }
797
+ continue;
798
+ }
799
+
800
+ const bare = blankStmt.match(/^\{([\s\S]*)\}$/);
801
+ if (bare) {
802
+ emit(blankStmt.slice(1, -1), rawStmt.slice(1, -1), baseAbs + start + 1, depth + 1);
803
+ continue;
804
+ }
805
+ // Leaf statement: structure has been fully resolved by the blanked
806
+ // split above, so content lowering uses the RAW text — identifiers,
807
+ // call names and string-literal detection all need the real
808
+ // characters, not the blanked-out ones.
809
+ const node = _lowerStmt(rawStmt, line);
810
+ if (!node) continue;
811
+ const id = `n${counter++}`;
812
+ nodes[id] = { ...node, succ: [], pred: [] };
813
+ link(id);
814
+ }
815
+ };
816
+
817
+ if (rawBody) emit(blankBody, rawBody, 0, 0);
818
+
819
+ nodes[prev].succ.push('exit');
820
+ nodes.exit.pred.push(prev);
821
+ return { entry: 'entry', exit: 'exit', nodes };
822
+ }
823
+
824
+ export const _internals = {
825
+ _blank, _splitTopLevelCommas, _parseParams, _extractBody,
826
+ _lineAt, _qid, _findFunctions, _findClasses, _nameBefore,
827
+ _lowerExpr, _lowerStmt, _splitStatements, _buildCfg,
828
+ _matchDelim, _splitTopLevelAligned, _lineStarts, _lineForOffset,
829
+ };