@clear-capabilities/agentic-security-scanner 0.127.0 → 0.130.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.
- package/CHANGELOG.md +161 -0
- package/bin/agentic-security.js +33 -0
- package/dist/11.index.js +353 -0
- package/dist/113.index.js +727 -0
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +217 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +19 -8
- package/dist/526.index.js +555 -0
- package/dist/637.index.js +1 -1
- package/dist/826.index.js +4 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +113 -163
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +23 -15
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +181 -8
- package/src/integrations/index.js +1 -1
- package/src/integrations/tickets.js +9 -3
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +5 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/mcp/tools.js +17 -6
- package/src/posture/CLAUDE.md +122 -0
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +199 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +1 -1
- package/src/posture/entrypoint-inventory.js +248 -0
- package/src/posture/execution-proof.js +52 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +165 -0
- package/src/posture/fix-honesty-gate.js +175 -0
- package/src/posture/fix-verify.js +71 -3
- package/src/posture/license-policy.js +1 -1
- package/src/posture/model-routing.js +126 -0
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/root-cause-sweep.js +262 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/pr-comment.js +3 -1
- package/src/report/index.js +11 -0
- package/src/runScan.js +3 -1
- package/src/sandbox/CLAUDE.md +218 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +83 -0
- package/src/sandbox/backend-userspace.js +100 -0
- package/src/sandbox/capabilities.js +53 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +42 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/untrusted.js +148 -0
- 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
|
+
};
|