@clear-capabilities/agentic-security-scanner 0.136.9 → 0.137.1
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 +659 -0
- package/bin/agentic-security.js +3 -0
- package/dist/435.index.js +9 -1
- package/dist/agentic-security.mjs +3 -3
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +8 -8
- package/src/dataflow/CLAUDE.md +3 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +318 -55
- package/src/dataflow/index.js +15 -0
- package/src/dataflow/points-to.js +19 -6
- package/src/engine.js +281 -207
- package/src/ir/CLAUDE.md +14 -3
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +451 -31
- package/src/ir/parser-java.js +205 -2
- package/src/ir/parser-js.js +149 -2
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +587 -29
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +124 -19
- package/src/lsp/server.js +7 -1
- package/src/mcp/tools.js +9 -1
- package/src/posture/clustering.js +12 -1
- package/src/posture/sbom.js +2 -2
package/src/ir/parser-php.js
CHANGED
|
@@ -10,12 +10,35 @@
|
|
|
10
10
|
// - return
|
|
11
11
|
// - foreach as loop-header + assign
|
|
12
12
|
// - PHP superglobals ($_GET, $_POST, $_REQUEST, etc.) as ident sources
|
|
13
|
+
// - control flow (R8): `if`/`else`/`while`/`foreach`/`try`/`catch`/
|
|
14
|
+
// `finally`/`switch` bodies are recursed into — the statement splitter
|
|
15
|
+
// now flushes on a closing `}` (see the `}`-flush comment near
|
|
16
|
+
// `_splitStatements` below) instead of only on `;`, so a sink nested
|
|
17
|
+
// inside a braced control-flow body is reachable rather than being
|
|
18
|
+
// dropped or folded into a bogus call node. This took three fix rounds
|
|
19
|
+
// to get line-number-exact (see the PRD R8 status entry in
|
|
20
|
+
// `docs/DETECTION_GAP_REMEDIATION_PRD.md` for the full history) — the
|
|
21
|
+
// CFG shape itself was correct from the first round. NOTE: C-style
|
|
22
|
+
// `for` is deliberately NOT in this list — PHP has no `for`-loop
|
|
23
|
+
// recognizer at all (never in R8's scope, still true today); a sink
|
|
24
|
+
// inside `for ($i=0; $i<3; $i++) { ... }` still folds into a bogus
|
|
25
|
+
// `call:for` node and is lost entirely, the exact failure mode R8
|
|
26
|
+
// exists to fix, unfixed for this one construct.
|
|
13
27
|
//
|
|
14
28
|
// What we do NOT model:
|
|
15
29
|
// - arrow functions (fn($x) => expr)
|
|
16
30
|
// - traits / interfaces
|
|
17
31
|
// - anonymous classes
|
|
18
|
-
// -
|
|
32
|
+
// - C-style `for` loops (see the note above — no recognizer exists)
|
|
33
|
+
// - the PHP 8 `match` expression (analogous gap to Java's arrow-form
|
|
34
|
+
// `switch` — this is the one modern control-flow SHAPE R8 did not
|
|
35
|
+
// cover, as opposed to the R8 fix's own scope, which is bodies of
|
|
36
|
+
// control-flow statements PHP already recognized)
|
|
37
|
+
// - `elseif`/`else if` chains (pre-existing, not touched by R8)
|
|
38
|
+
// - `if`/`else`'s pre-existing greedy-capture-group bug: the then-body
|
|
39
|
+
// capture group unconditionally swallows through to the else-body's
|
|
40
|
+
// own closing `}`, dropping the else-body's first statement
|
|
41
|
+
// (pre-existing, confirmed present before R8, not fixed by it)
|
|
19
42
|
|
|
20
43
|
import * as crypto from 'node:crypto';
|
|
21
44
|
import { callSitesFromCfg } from './call-sites.js';
|
|
@@ -30,37 +53,221 @@ const FUNC_RE = new RegExp(
|
|
|
30
53
|
'(?:\\s*:\\s*\\??[A-Za-z_]\\w*)?' + // optional return type
|
|
31
54
|
'\\s*\\{', 'g');
|
|
32
55
|
|
|
56
|
+
// Returns `{ text, line }[]` — `line` is the 1-indexed line, relative to the
|
|
57
|
+
// START of `body`, of the first non-whitespace character of that statement.
|
|
58
|
+
// This is computed from the ACTUAL scan position (a running `curLine`
|
|
59
|
+
// incremented on every physical `\n` encountered, including ones skipped
|
|
60
|
+
// inside a `//` comment) rather than by re-counting newlines inside the
|
|
61
|
+
// already-trimmed statement text afterwards — that reconstruction is lossy:
|
|
62
|
+
// `.trim()` discards any leading blank lines (or blanked-out characters —
|
|
63
|
+
// see parser-php.js's `_blankSpans`/`_buildCfg` module-level lowering) before
|
|
64
|
+
// a statement's real content, so a caller that tried to recover the line by
|
|
65
|
+
// counting embedded newlines would silently undercount by exactly the
|
|
66
|
+
// number of blank lines that preceded the statement. This was the root
|
|
67
|
+
// cause of PHP module-level findings reporting the wrong line and, in turn,
|
|
68
|
+
// making the line-scoped `agentic-security-ignore` suppression pragma inert
|
|
69
|
+
// for them (Finding 2 of the R14(b) final whole-branch review).
|
|
70
|
+
// True when the next non-whitespace, non-comment token starting at
|
|
71
|
+
// `body[i + 1]` is one of the `else`/`catch`/`finally` continuation
|
|
72
|
+
// keywords — the ones that must stay glued to a preceding `}` rather than
|
|
73
|
+
// starting a new split entry (see the R8 comment at the `}`-flush call
|
|
74
|
+
// site below). Both whitespace AND comments (`//` and `/* */`) are
|
|
75
|
+
// skipped, mirroring `_splitStatements`' own comment-skip logic.
|
|
76
|
+
//
|
|
77
|
+
// R8 fix round 3: whitespace-only skipping was a real regression this
|
|
78
|
+
// task introduced (not a pre-existing limitation, as an earlier version of
|
|
79
|
+
// this comment incorrectly claimed). `} /* mid */ else { ... }` or
|
|
80
|
+
// `}\n// explain\nelse { ... }` are unremarkable, real PHP shapes — a
|
|
81
|
+
// comment explaining WHY an else/catch/finally branch exists is a normal
|
|
82
|
+
// thing to write immediately above it. With whitespace-only skipping, the
|
|
83
|
+
// comment defeated the lookahead, the `}`-flush fired anyway, and the
|
|
84
|
+
// entire `else`/`catch`/`finally` body was silently dropped from the CFG.
|
|
85
|
+
// For `catch`/`finally` specifically this is confirmed a clean, provable
|
|
86
|
+
// fix (see the `catch`/`finally` regression tests in
|
|
87
|
+
// `test/parser-php-control-flow.test.js`, which fail without this change
|
|
88
|
+
// and pass with it) — `_scanTryCatchFinally`'s balanced-brace scanning has
|
|
89
|
+
// no competing bug to interact with. For `else` specifically, this
|
|
90
|
+
// lookahead fix is still correct and necessary, but its OUTCOME is masked
|
|
91
|
+
// in practice by `ifMatch`'s own separate, pre-existing, out-of-scope
|
|
92
|
+
// greedy-capture bug (the then-body group unconditionally swallows
|
|
93
|
+
// through to the else-body's own closing `}`, dropping the else body
|
|
94
|
+
// regardless of whether a comment was ever involved — confirmed by
|
|
95
|
+
// testing commit `735ef63`, before this task started, with an identical
|
|
96
|
+
// comment-free else fixture: already broken then, for an unrelated
|
|
97
|
+
// reason). `ifMatch` and `_scanTryCatchFinally` both already tolerate an
|
|
98
|
+
// ordinary `\s*`/whitespace gap between `}` and the keyword; skipping
|
|
99
|
+
// comments here too keeps this lookahead in sync with what those
|
|
100
|
+
// recognizers can actually parse once the flush is correctly suppressed.
|
|
101
|
+
function _continuationKeywordAhead(body, i) {
|
|
102
|
+
let j = i + 1;
|
|
103
|
+
let moved = true;
|
|
104
|
+
while (moved) {
|
|
105
|
+
moved = false;
|
|
106
|
+
while (j < body.length && /\s/.test(body[j])) { j++; moved = true; }
|
|
107
|
+
if (body[j] === '/' && body[j + 1] === '/') {
|
|
108
|
+
while (j < body.length && body[j] !== '\n') j++;
|
|
109
|
+
moved = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (body[j] === '/' && body[j + 1] === '*') {
|
|
113
|
+
j += 2;
|
|
114
|
+
while (j < body.length && !(body[j] === '*' && body[j + 1] === '/')) j++;
|
|
115
|
+
if (j < body.length) j += 2; // past the closing '*/'
|
|
116
|
+
moved = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const kw of ['else', 'catch', 'finally']) {
|
|
121
|
+
if (body.startsWith(kw, j)) {
|
|
122
|
+
const after = body[j + kw.length];
|
|
123
|
+
if (after === undefined || !/\w/.test(after)) return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
33
129
|
function _splitStatements(body) {
|
|
34
130
|
const out = [];
|
|
35
131
|
let buf = '';
|
|
132
|
+
let bufLine = null; // line of the first non-whitespace char seen in `buf` so far
|
|
133
|
+
let curLine = 1; // line of body[i], the character currently under the cursor
|
|
36
134
|
let depth = 0;
|
|
37
135
|
let inStr = null;
|
|
38
136
|
let escape = false;
|
|
137
|
+
const push = (c) => {
|
|
138
|
+
buf += c;
|
|
139
|
+
if (bufLine === null && !/\s/.test(c)) bufLine = curLine;
|
|
140
|
+
};
|
|
39
141
|
for (let i = 0; i < body.length; i++) {
|
|
40
142
|
const c = body[i];
|
|
41
|
-
if (escape) {
|
|
143
|
+
if (escape) { push(c); escape = false; if (c === '\n') curLine++; continue; }
|
|
42
144
|
if (inStr) {
|
|
43
|
-
|
|
44
|
-
if (c === '\\') { escape = true; continue; }
|
|
145
|
+
push(c);
|
|
146
|
+
if (c === '\\') { escape = true; if (c === '\n') curLine++; continue; }
|
|
45
147
|
if (c === inStr) inStr = null;
|
|
148
|
+
if (c === '\n') curLine++;
|
|
46
149
|
continue;
|
|
47
150
|
}
|
|
48
|
-
if (c === '"' || c === '\'') { inStr = c;
|
|
151
|
+
if (c === '"' || c === '\'') { inStr = c; push(c); continue; }
|
|
49
152
|
if (c === '/' && body[i + 1] === '/') {
|
|
50
153
|
while (i < body.length && body[i] !== '\n') i++;
|
|
154
|
+
// R8 fix round 2: push the newline that terminated the comment into
|
|
155
|
+
// `buf` (not just bump `curLine`). Comment text itself is still
|
|
156
|
+
// dropped (never pushed) — only the LINE it displaced is preserved,
|
|
157
|
+
// same principle `_blankSpans`/`_blank` already use elsewhere in
|
|
158
|
+
// this file (blank content out, keep line structure intact, don't
|
|
159
|
+
// delete it outright). Without this, the flushed statement text
|
|
160
|
+
// this comment lived inside ends up with FEWER newlines than the
|
|
161
|
+
// real source has, so `_buildCfg`'s newline-counting line
|
|
162
|
+
// computation for a nested body silently undercounts by exactly the
|
|
163
|
+
// number of newlines lost to comments — the sink line reported to
|
|
164
|
+
// the caller (and therefore the `agentic-security-ignore` pragma
|
|
165
|
+
// line it must match) is wrong for any control-flow body containing
|
|
166
|
+
// an otherwise-unrelated `//` comment.
|
|
167
|
+
if (i < body.length) { push('\n'); curLine++; }
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (c === '/' && body[i + 1] === '*') {
|
|
171
|
+
// Block comment (incl. PHPDoc, e.g. `/** @param string $x */`).
|
|
172
|
+
// Skip to the matching `*/`, counting any newlines crossed so line
|
|
173
|
+
// tracking stays accurate for whatever follows — same contract the
|
|
174
|
+
// `//` handling above upholds. Bounded even when unterminated (`i`
|
|
175
|
+
// simply runs to `body.length` and the outer `for` loop ends).
|
|
176
|
+
// R8 fix round 2: same newline-preservation fix as the `//` handler
|
|
177
|
+
// above, but a block comment can span MANY lines — push one `\n`
|
|
178
|
+
// into `buf` for every newline it displaces, not just one, or a
|
|
179
|
+
// multi-line block comment inside a control-flow body would still
|
|
180
|
+
// undercount by (newlines - 1).
|
|
181
|
+
i += 2; // past the opening '/*'
|
|
182
|
+
while (i < body.length && !(body[i] === '*' && body[i + 1] === '/')) {
|
|
183
|
+
if (body[i] === '\n') { curLine++; push('\n'); }
|
|
184
|
+
i++;
|
|
185
|
+
}
|
|
186
|
+
if (i < body.length) i++; // land on the '/' of '*/'; skipped, contributing no statement text
|
|
51
187
|
continue;
|
|
52
188
|
}
|
|
53
189
|
if (c === '{' || c === '(' || c === '[') depth++;
|
|
54
|
-
if (c === '}' || c === ')' || c === ']')
|
|
190
|
+
if (c === '}' || c === ')' || c === ']') {
|
|
191
|
+
depth--;
|
|
192
|
+
// R8: a `}` that returns the shared depth counter to 0 ends a
|
|
193
|
+
// braced control-flow body (if/while/foreach/try/switch) — flush a
|
|
194
|
+
// statement boundary here too, not just on `;` at depth 0. PHP has
|
|
195
|
+
// no `{}`-based object/array-literal syntax (arrays use `[...]`,
|
|
196
|
+
// tracked by the same counter but not this trigger), so this cannot
|
|
197
|
+
// mis-fire mid-expression the way it would for a language with `{}`
|
|
198
|
+
// object initializers. A `}` that closes a lambda/closure body
|
|
199
|
+
// passed as a call argument (`usort($arr, function($a,$b){...})`)
|
|
200
|
+
// does NOT trigger this — that `}` returns depth from 2 to 1 (still
|
|
201
|
+
// inside usort's outer `(`), not to 0.
|
|
202
|
+
//
|
|
203
|
+
// EXCEPTION: do not flush when the next non-whitespace token is
|
|
204
|
+
// `else`, `catch`, or `finally` — those must stay glued onto the
|
|
205
|
+
// SAME statement as the preceding `}` for `ifMatch` and the
|
|
206
|
+
// try/catch/finally recognizer in `_buildCfg` to see
|
|
207
|
+
// `if (...) { ... } else { ... }` or
|
|
208
|
+
// `try { ... } catch (...) { ... } finally { ... }` as one
|
|
209
|
+
// contiguous blob (`ifMatch` is anchored end-to-end with `$` and
|
|
210
|
+
// spans the whole construct; the try/catch/finally recognizer scans
|
|
211
|
+
// the whole construct by hand for the same reason). A blind
|
|
212
|
+
// flush-on-every-`}` here would silently split `if`/`else` and
|
|
213
|
+
// multi-clause `try` into two statements each, which is a strictly
|
|
214
|
+
// WORSE regression than the "must be last statement in scope" bug
|
|
215
|
+
// this task fixes — an
|
|
216
|
+
// `else`/`catch`/`finally` body would be dropped from the CFG
|
|
217
|
+
// entirely, not just occasionally mis-split.
|
|
218
|
+
if (c === '}' && depth === 0 && !_continuationKeywordAhead(body, i)) {
|
|
219
|
+
push(c);
|
|
220
|
+
const t = buf.trim();
|
|
221
|
+
if (t) out.push({ text: t, line: bufLine ?? curLine });
|
|
222
|
+
buf = '';
|
|
223
|
+
bufLine = null;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
55
227
|
if (c === ';' && depth === 0) {
|
|
56
228
|
const t = buf.trim();
|
|
57
|
-
if (t) out.push(t);
|
|
229
|
+
if (t) out.push({ text: t, line: bufLine ?? curLine });
|
|
58
230
|
buf = '';
|
|
231
|
+
bufLine = null;
|
|
59
232
|
continue;
|
|
60
233
|
}
|
|
61
|
-
|
|
234
|
+
// R8: a `switch` body's `case <expr>:` / `default:` labels are
|
|
235
|
+
// terminated by `:`, not `;` or `}` — without this, the label text
|
|
236
|
+
// stays glued onto whatever real statement follows it (no `;` or `}`
|
|
237
|
+
// separates them), and that combined blob then fails BOTH the
|
|
238
|
+
// assignment and call-statement shapes in `_lowerStmt` (neither
|
|
239
|
+
// starts with `$var =` nor a bare identifier-call), silently
|
|
240
|
+
// dropping the case body's first statement entirely — worse than the
|
|
241
|
+
// brief's anticipated "label falls through to `_lowerStmt` and
|
|
242
|
+
// returns null harmlessly" behavior. Scoped tightly (buf must be
|
|
243
|
+
// EXACTLY `case <expr>` or `default`) so it can't mis-fire on a
|
|
244
|
+
// ternary's `:`, which leaves buf holding something that never
|
|
245
|
+
// matches either pattern.
|
|
246
|
+
//
|
|
247
|
+
// Excludes `::` (PHP's static-access / class-constant / PHP 8.1
|
|
248
|
+
// enum-case operator, e.g. `case Foo::BAR:` or `case Status::Active:`)
|
|
249
|
+
// via the adjacent-char check below — without it, `case Foo::BAR:`'s
|
|
250
|
+
// FIRST `:` already makes buf read "case Foo", which matches the case
|
|
251
|
+
// pattern just as eagerly as the real terminating `:` after `BAR`
|
|
252
|
+
// does, mis-splitting mid-token and dropping that case's body. Both
|
|
253
|
+
// colons of a `::` pair are skipped (checking one neighbor character
|
|
254
|
+
// each is enough: the first colon sees `body[i+1] === ':'`, the
|
|
255
|
+
// second sees `body[i-1] === ':'`), so only a genuine lone `:` can
|
|
256
|
+
// ever terminate a label.
|
|
257
|
+
if (c === ':' && depth === 0 && body[i + 1] !== ':' && body[i - 1] !== ':') {
|
|
258
|
+
const t = buf.trim();
|
|
259
|
+
if (/^case\s+[\s\S]+$/.test(t) || t === 'default') {
|
|
260
|
+
out.push({ text: t, line: bufLine ?? curLine });
|
|
261
|
+
buf = '';
|
|
262
|
+
bufLine = null;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
push(c);
|
|
267
|
+
if (c === '\n') curLine++;
|
|
62
268
|
}
|
|
63
|
-
|
|
269
|
+
const t = buf.trim();
|
|
270
|
+
if (t) out.push({ text: t, line: bufLine ?? curLine });
|
|
64
271
|
return out;
|
|
65
272
|
}
|
|
66
273
|
|
|
@@ -129,9 +336,21 @@ function _lowerExpr(text) {
|
|
|
129
336
|
return { kind: 'call', callee: funcCall.callee, args: _splitTopLevelCommas(funcCall.argsText).map(_lowerExpr) };
|
|
130
337
|
}
|
|
131
338
|
// Concat with .
|
|
339
|
+
//
|
|
340
|
+
// The `parts.length > 1` guard is load-bearing, not defensive tidiness:
|
|
341
|
+
// when every `.` in `s` is nested inside brackets or strings (e.g.
|
|
342
|
+
// `s = '"y.z"'` in `["health" => "check.status"]`), `_splitTopLevelDot`
|
|
343
|
+
// returns `[s]` — the input unchanged, as a single part — and mapping
|
|
344
|
+
// `_lowerExpr` over it recurses on the IDENTICAL string forever (stack
|
|
345
|
+
// overflow). Before R14(b) this was only reachable from inside function
|
|
346
|
+
// bodies; the module-level lowering now feeds every top-level statement
|
|
347
|
+
// through here too, so real files (e.g. a top-level array literal with a
|
|
348
|
+
// dotted string value) hit it and the per-file catch in `ir/index.js`
|
|
349
|
+
// silently dropped the whole file from Layer-2 analysis. Same shape as
|
|
350
|
+
// parser-cs.js's `_splitTopLevelPlus` guard.
|
|
132
351
|
if (s.includes('.') && /["'\$]/.test(s)) {
|
|
133
|
-
const
|
|
134
|
-
return { kind: 'tpl', parts };
|
|
352
|
+
const rawParts = _splitTopLevelDot(s);
|
|
353
|
+
if (rawParts.length > 1) return { kind: 'tpl', parts: rawParts.map(_lowerExpr) };
|
|
135
354
|
}
|
|
136
355
|
// Member: $obj->prop
|
|
137
356
|
if (/^\$[\w]+(?:->[\w]+)+$/.test(s)) {
|
|
@@ -246,6 +465,70 @@ function _qid(file, name, line, body) {
|
|
|
246
465
|
return `${file}::${name}@${line}#${sha}`;
|
|
247
466
|
}
|
|
248
467
|
|
|
468
|
+
// FUNC_RE's leading alternation `(?:^|[\n;{}]|<\?php|<\?)` matches a single
|
|
469
|
+
// "boundary" character/token that belongs to whatever precedes the function
|
|
470
|
+
// (a statement terminator, a brace, or the PHP open tag) — not to the
|
|
471
|
+
// function itself. `m.index` always points at the START of that boundary,
|
|
472
|
+
// so including it verbatim in the function's span would blank away e.g. the
|
|
473
|
+
// `;` that terminates the PRECEDING top-level statement once the whole file
|
|
474
|
+
// is lowered in a single _buildCfg pass (see _blankSpans below), silently
|
|
475
|
+
// merging that statement with whatever gap text follows the function. This
|
|
476
|
+
// returns how many characters of the boundary token were actually consumed
|
|
477
|
+
// so the function's span can be made to start right after it.
|
|
478
|
+
function _funcBoundaryLen(code, idx) {
|
|
479
|
+
if (idx > 0) {
|
|
480
|
+
const c = code[idx];
|
|
481
|
+
if (c === '\n' || c === ';' || c === '{' || c === '}') return 1;
|
|
482
|
+
}
|
|
483
|
+
if (/^<\?php/i.test(code.slice(idx, idx + 5))) return 5;
|
|
484
|
+
if (/^<\?/.test(code.slice(idx, idx + 2))) return 2;
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Blank out every real function's span in a COPY of the full source
|
|
489
|
+
// (replace its characters with spaces, preserving every newline exactly).
|
|
490
|
+
// This lets the WHOLE file be lowered in a single _buildCfg call at
|
|
491
|
+
// startLine=1 for the module-level CFG, which keeps every remaining
|
|
492
|
+
// statement's reported line number exactly equal to its real source line —
|
|
493
|
+
// no character is ever deleted, only turned into a space, so nothing can
|
|
494
|
+
// shift. This replaces the old per-gap slicing + per-gap startLine
|
|
495
|
+
// re-derivation, which mis-tracked lines whenever a gap slice started with
|
|
496
|
+
// leading blank/newline characters and broke the line-scoped
|
|
497
|
+
// `agentic-security-ignore` suppression pragma for module-level findings.
|
|
498
|
+
function _blankSpans(code, spans) {
|
|
499
|
+
let out = '';
|
|
500
|
+
let cursor = 0;
|
|
501
|
+
for (const span of spans) {
|
|
502
|
+
if (span.start > cursor) out += code.slice(cursor, span.start);
|
|
503
|
+
out += _blank(code.slice(span.start, span.end));
|
|
504
|
+
cursor = span.end;
|
|
505
|
+
}
|
|
506
|
+
out += code.slice(cursor);
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function _blank(text) {
|
|
511
|
+
return text.replace(/[^\n]/g, ' ');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Blanks a leading PHP open tag (`<?php` or `<?`) if the file starts with
|
|
515
|
+
// one. Only the true start of the file is handled (matching the previous
|
|
516
|
+
// behavior's `cursor === 0` scope) — this is a real fix, not dead code:
|
|
517
|
+
// when top-level content sits between the open tag and the first function
|
|
518
|
+
// declaration (e.g. `<?php\n$x = [...];\nfunction f(){}`), the tag is NOT
|
|
519
|
+
// part of any function's span (the boundary FUNC_RE actually consumed
|
|
520
|
+
// before `function f` is the `;`, not the tag — see _funcBoundaryLen), so
|
|
521
|
+
// it survives into the blanked text as literal `<?php` characters. Left
|
|
522
|
+
// unblanked, that text glues onto the front of the first top-level
|
|
523
|
+
// statement (`<?php\n$x = [...]`), which then fails every `_lowerStmt`
|
|
524
|
+
// pattern (all anchored at the true start of the statement) and silently
|
|
525
|
+
// drops it. Blanking (not deleting) the tag keeps line numbers exact.
|
|
526
|
+
function _blankLeadingOpenTag(text) {
|
|
527
|
+
const m = text.match(/^<\?(?:php)?/i);
|
|
528
|
+
if (!m) return text;
|
|
529
|
+
return _blank(m[0]) + text.slice(m[0].length);
|
|
530
|
+
}
|
|
531
|
+
|
|
249
532
|
let _nid = 0;
|
|
250
533
|
function _nextId() { return `pn${++_nid}`; }
|
|
251
534
|
|
|
@@ -263,66 +546,284 @@ function _linkNodes(nodes, src, dst) {
|
|
|
263
546
|
if (!nodes[dst].pred.includes(src)) nodes[dst].pred.push(src);
|
|
264
547
|
}
|
|
265
548
|
|
|
266
|
-
|
|
549
|
+
// R8 fix round 1: counts `\n` in `s` up to (not including) `upTo`. `s` here
|
|
550
|
+
// is the RAW, untrimmed statement text `_buildCfg` is currently processing
|
|
551
|
+
// (not a `.trim()`-mangled reconstruction — the exact lossy pattern the
|
|
552
|
+
// module header comment above `_splitStatements` warns against), so a
|
|
553
|
+
// direct count is safe and exact, not an approximation.
|
|
554
|
+
function _countNewlines(s, upTo) {
|
|
555
|
+
let n = 0;
|
|
556
|
+
const end = Math.min(upTo, s.length);
|
|
557
|
+
for (let i = 0; i < end; i++) if (s[i] === '\n') n++;
|
|
558
|
+
return n;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Scans a balanced `{ ... }` block starting at `s[openIdx] === '{'`.
|
|
562
|
+
// String-aware (a `{`/`}` inside a quoted string doesn't perturb depth),
|
|
563
|
+
// mirroring `_splitStatements`' own string handling. Returns the index
|
|
564
|
+
// just past the matching `}`, or -1 if unbalanced/not found.
|
|
565
|
+
function _matchBraceBlock(s, openIdx) {
|
|
566
|
+
if (s[openIdx] !== '{') return -1;
|
|
567
|
+
let depth = 0;
|
|
568
|
+
let inStr = null;
|
|
569
|
+
let escape = false;
|
|
570
|
+
for (let i = openIdx; i < s.length; i++) {
|
|
571
|
+
const c = s[i];
|
|
572
|
+
if (escape) { escape = false; continue; }
|
|
573
|
+
if (inStr) {
|
|
574
|
+
if (c === '\\') { escape = true; continue; }
|
|
575
|
+
if (c === inStr) inStr = null;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (c === '"' || c === '\'') { inStr = c; continue; }
|
|
579
|
+
if (c === '{') depth++;
|
|
580
|
+
else if (c === '}') {
|
|
581
|
+
depth--;
|
|
582
|
+
if (depth === 0) return i + 1;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return -1;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// R8 fix round 1 (Important): manually scans a
|
|
589
|
+
// `try { ... } (catch (...) { ... })* (finally { ... })?` construct by hand
|
|
590
|
+
// rather than delegating to one greedy-capture-group regex. The regex this
|
|
591
|
+
// replaced (`^try\s*\{([\s\S]*)\}\s*catch\s*\(([^)]*)\)\s*\{([\s\S]*)\}
|
|
592
|
+
// (?:\s*finally\s*\{([\s\S]*)\})?\s*$`) has two real bugs: its catch-body
|
|
593
|
+
// group is GREEDY, so for `try{}catch(E $e){A}finally{B}` it swallows
|
|
594
|
+
// through to finally's OWN closing `}` (not catch's), making the trailing
|
|
595
|
+
// `(?:\s*finally...)?` group match nothing — `finally` bodies were dead
|
|
596
|
+
// code, never actually reachable. And it unconditionally REQUIRES a catch
|
|
597
|
+
// clause, so the equally-valid `try { ... } finally { ... }` (no catch) PHP
|
|
598
|
+
// shape was invisible entirely, both bodies dropped. Neither is fixable by
|
|
599
|
+
// tweaking the regex (lazy-matching the catch group just breaks nested-
|
|
600
|
+
// brace bodies the other way); balanced-brace scanning is the correct fix.
|
|
601
|
+
//
|
|
602
|
+
// Only the FIRST catch clause is modeled in the CFG (matches this task's
|
|
603
|
+
// brief scope — multi-catch BLOCK form, `catch(A){}catch(B){}`, was
|
|
604
|
+
// already an accepted, documented partial-capture degrade before this fix,
|
|
605
|
+
// confirmed non-crashing; union-type catches, `catch (A|B $e) {}`, are
|
|
606
|
+
// unaffected and fully supported, same as before). Every catch clause
|
|
607
|
+
// present is still scanned past correctly (not just the first) so a
|
|
608
|
+
// trailing `finally` is located at its real position rather than
|
|
609
|
+
// mismatched against a later catch's own content.
|
|
610
|
+
//
|
|
611
|
+
// Returns null if `s` isn't `try { ... }` shaped at all. Otherwise:
|
|
612
|
+
// { tryBody, tryBodyOffset, catchBody, catchBodyOffset,
|
|
613
|
+
// finallyBody, finallyBodyOffset }
|
|
614
|
+
// A body that isn't present is `null` with `-1` for its offset. Each
|
|
615
|
+
// `*Offset` is `s`'s own character offset of that body's first character —
|
|
616
|
+
// paired with `_countNewlines`, this gives the caller (`_buildCfg`) the
|
|
617
|
+
// EXACT absolute source line for each clause, not just the try-body's
|
|
618
|
+
// (which happens to share `line`'s value for ordinary K&R style, but
|
|
619
|
+
// catch/finally clauses generally start several lines later).
|
|
620
|
+
function _scanTryCatchFinally(s) {
|
|
621
|
+
const head = /^try\s*/.exec(s);
|
|
622
|
+
if (!head) return null;
|
|
623
|
+
let i = head[0].length;
|
|
624
|
+
if (s[i] !== '{') return null;
|
|
625
|
+
const tryBodyOffset = i + 1;
|
|
626
|
+
const tryEnd = _matchBraceBlock(s, i);
|
|
627
|
+
if (tryEnd < 0) return null;
|
|
628
|
+
const tryBody = s.slice(tryBodyOffset, tryEnd - 1);
|
|
629
|
+
i = tryEnd;
|
|
630
|
+
|
|
631
|
+
let catchBody = null;
|
|
632
|
+
let catchBodyOffset = -1;
|
|
633
|
+
let sawCatch = false;
|
|
634
|
+
for (;;) {
|
|
635
|
+
const rest = s.slice(i);
|
|
636
|
+
const ws = /^\s*/.exec(rest)[0];
|
|
637
|
+
i += ws.length;
|
|
638
|
+
const cm = /^catch\s*\(/.exec(s.slice(i));
|
|
639
|
+
if (!cm) break;
|
|
640
|
+
const parenStart = i + cm[0].length - 1;
|
|
641
|
+
const parenEnd = s.indexOf(')', parenStart);
|
|
642
|
+
if (parenEnd < 0) return null;
|
|
643
|
+
let j = parenEnd + 1;
|
|
644
|
+
const ws2 = /^\s*/.exec(s.slice(j))[0];
|
|
645
|
+
j += ws2.length;
|
|
646
|
+
if (s[j] !== '{') return null;
|
|
647
|
+
const bodyOffset = j + 1;
|
|
648
|
+
const bodyEnd = _matchBraceBlock(s, j);
|
|
649
|
+
if (bodyEnd < 0) return null;
|
|
650
|
+
if (!sawCatch) {
|
|
651
|
+
catchBody = s.slice(bodyOffset, bodyEnd - 1);
|
|
652
|
+
catchBodyOffset = bodyOffset;
|
|
653
|
+
sawCatch = true;
|
|
654
|
+
}
|
|
655
|
+
i = bodyEnd;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let finallyBody = null;
|
|
659
|
+
let finallyBodyOffset = -1;
|
|
660
|
+
{
|
|
661
|
+
const ws = /^\s*/.exec(s.slice(i))[0];
|
|
662
|
+
let j = i + ws.length;
|
|
663
|
+
const fm = /^finally\s*/.exec(s.slice(j));
|
|
664
|
+
if (fm) {
|
|
665
|
+
j += fm[0].length;
|
|
666
|
+
if (s[j] === '{') {
|
|
667
|
+
const bodyOffset = j + 1;
|
|
668
|
+
const bodyEnd = _matchBraceBlock(s, j);
|
|
669
|
+
if (bodyEnd < 0) return null;
|
|
670
|
+
finallyBody = s.slice(bodyOffset, bodyEnd - 1);
|
|
671
|
+
finallyBodyOffset = bodyOffset;
|
|
672
|
+
i = bodyEnd;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// PHP requires at least one of catch/finally; a bare `try {}` alone
|
|
678
|
+
// isn't valid PHP and isn't a shape this recognizer should claim.
|
|
679
|
+
if (!sawCatch && finallyBody === null) return null;
|
|
680
|
+
// Whatever remains must be only trailing whitespace, or this wasn't a
|
|
681
|
+
// clean try/catch/finally statement after all — fall through to the
|
|
682
|
+
// generic `_lowerStmt` path (safe no-op) rather than claiming a bad
|
|
683
|
+
// match.
|
|
684
|
+
if (s.slice(i).trim() !== '') return null;
|
|
685
|
+
|
|
686
|
+
return { tryBody, tryBodyOffset, catchBody, catchBodyOffset, finallyBody, finallyBodyOffset };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// `startLine` is the absolute source line of the FIRST character of
|
|
690
|
+
// `bodyText`. Each statement's absolute line is `startLine + stmt.line - 1`
|
|
691
|
+
// (`stmt.line` from _splitStatements is already 1-indexed and relative to
|
|
692
|
+
// `bodyText`), so — unlike the old incremental `line++`/`line += newlines+1`
|
|
693
|
+
// bookkeeping this replaced — no line is ever derived by re-counting
|
|
694
|
+
// newlines in already-trimmed text. That old scheme silently dropped any
|
|
695
|
+
// blank line (or, at module level, any blanked-out function span — see
|
|
696
|
+
// `_blankSpans`) that preceded a statement, which is exactly what made
|
|
697
|
+
// module-level PHP findings report the wrong source line.
|
|
698
|
+
function _buildCfg(bodyText, nodes, prevId, startLine, depth = 0) {
|
|
699
|
+
if (depth > 12) return prevId;
|
|
267
700
|
const stmts = _splitStatements(bodyText);
|
|
268
701
|
let prev = prevId;
|
|
269
|
-
let line = startLine;
|
|
270
702
|
for (const stmt of stmts) {
|
|
271
|
-
const s = stmt.
|
|
272
|
-
|
|
703
|
+
const s = stmt.text;
|
|
704
|
+
const line = startLine + stmt.line - 1;
|
|
705
|
+
if (!s || s.startsWith('//') || s.startsWith('#')) continue;
|
|
273
706
|
|
|
274
|
-
|
|
707
|
+
// R8 fix round 2: `line` (the absolute source line of `s`'s FIRST
|
|
708
|
+
// character — the `if`/`while`/`foreach`/`switch` keyword itself) is
|
|
709
|
+
// NOT a safe stand-in for a body's own start line in general. Fix
|
|
710
|
+
// round 1 used flat `line` for these four recognizers on the
|
|
711
|
+
// assumption that K&R-style `{` on the same physical line as the
|
|
712
|
+
// keyword makes the body start on that same line — true only when
|
|
713
|
+
// the header between the keyword and `{` is a single physical line
|
|
714
|
+
// AND contains no comment that ate a newline without contributing one
|
|
715
|
+
// back to `s` (fix round 1 also missed that a comment anywhere inside
|
|
716
|
+
// `s` — not just inside the header — undercounts newlines the same
|
|
717
|
+
// way; that half is fixed by `_splitStatements` now pushing a `\n`
|
|
718
|
+
// for every newline a skipped comment displaces, above). A `d`-flagged
|
|
719
|
+
// regex exposes each capture group's real character offset within
|
|
720
|
+
// `s` via `.indices`, so `_countNewlines(s, offset)` gives the body's
|
|
721
|
+
// EXACT absolute line regardless of how many lines the header itself
|
|
722
|
+
// spans or how many comments precede the body — the same precise
|
|
723
|
+
// per-clause technique the try/catch/finally recognizer below already
|
|
724
|
+
// uses (there, offsets come from the balanced-brace scanner instead
|
|
725
|
+
// of regex `.indices`, same principle).
|
|
726
|
+
const ifMatch = s.match(/^if\s*\((.+?)\)\s*\{([\s\S]*)\}(?:\s*else\s*\{([\s\S]*)\})?\s*$/ds);
|
|
275
727
|
if (ifMatch) {
|
|
276
728
|
const ifNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(ifMatch[1]), line });
|
|
277
729
|
_linkNodes(nodes, prev, ifNode);
|
|
278
730
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
279
|
-
const
|
|
731
|
+
const thenStartLine = line + _countNewlines(s, ifMatch.indices[2][0]);
|
|
732
|
+
const thenTail = _buildCfg(ifMatch[2], nodes, ifNode, thenStartLine, depth + 1);
|
|
280
733
|
_linkNodes(nodes, thenTail, join);
|
|
281
734
|
if (ifMatch[3]) {
|
|
282
|
-
const
|
|
735
|
+
const elseStartLine = line + _countNewlines(s, ifMatch.indices[3][0]);
|
|
736
|
+
const elseTail = _buildCfg(ifMatch[3], nodes, ifNode, elseStartLine, depth + 1);
|
|
283
737
|
_linkNodes(nodes, elseTail, join);
|
|
284
738
|
} else {
|
|
285
739
|
_linkNodes(nodes, ifNode, join);
|
|
286
740
|
}
|
|
287
741
|
prev = join;
|
|
288
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
289
742
|
continue;
|
|
290
743
|
}
|
|
291
744
|
|
|
292
|
-
const whileMatch = s.match(/^while\s*\((.+?)\)\s*\{([\s\S]*)\}\s*$/
|
|
745
|
+
const whileMatch = s.match(/^while\s*\((.+?)\)\s*\{([\s\S]*)\}\s*$/ds);
|
|
293
746
|
if (whileMatch) {
|
|
294
747
|
const header = _addNode(nodes, { kind: 'loop-header', line });
|
|
295
748
|
_linkNodes(nodes, prev, header);
|
|
296
|
-
const
|
|
749
|
+
const bodyStartLine = line + _countNewlines(s, whileMatch.indices[2][0]);
|
|
750
|
+
const bodyTail = _buildCfg(whileMatch[2], nodes, header, bodyStartLine, depth + 1);
|
|
297
751
|
_linkNodes(nodes, bodyTail, header);
|
|
298
752
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
299
753
|
_linkNodes(nodes, header, join);
|
|
300
754
|
prev = join;
|
|
301
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
302
755
|
continue;
|
|
303
756
|
}
|
|
304
757
|
|
|
305
|
-
const foreachMatch = s.match(/^foreach\s*\((.+?)\s+as\s+(?:\$\w+\s*=>\s*)?(\$\w+)\)\s*\{([\s\S]*)\}\s*$/
|
|
758
|
+
const foreachMatch = s.match(/^foreach\s*\((.+?)\s+as\s+(?:\$\w+\s*=>\s*)?(\$\w+)\)\s*\{([\s\S]*)\}\s*$/ds);
|
|
306
759
|
if (foreachMatch) {
|
|
307
760
|
const header = _addNode(nodes, { kind: 'loop-header', line });
|
|
308
761
|
_linkNodes(nodes, prev, header);
|
|
309
762
|
const assignId = _addNode(nodes, { kind: 'assign', target: foreachMatch[2], source: _lowerExpr(foreachMatch[1]), line });
|
|
310
763
|
_linkNodes(nodes, header, assignId);
|
|
311
|
-
const
|
|
764
|
+
const bodyStartLine = line + _countNewlines(s, foreachMatch.indices[3][0]);
|
|
765
|
+
const bodyTail = _buildCfg(foreachMatch[3], nodes, assignId, bodyStartLine, depth + 1);
|
|
312
766
|
_linkNodes(nodes, bodyTail, header);
|
|
313
767
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
314
768
|
_linkNodes(nodes, header, join);
|
|
315
769
|
prev = join;
|
|
316
|
-
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const tryScan = /^try\s*\{/.test(s) ? _scanTryCatchFinally(s) : null;
|
|
774
|
+
if (tryScan) {
|
|
775
|
+
const tryNode = _addNode(nodes, { kind: 'noop', line });
|
|
776
|
+
_linkNodes(nodes, prev, tryNode);
|
|
777
|
+
const join = _addNode(nodes, { kind: 'noop', line });
|
|
778
|
+
// Same exact-offset technique as the if/while/foreach/switch bodies
|
|
779
|
+
// above, just fed by `_scanTryCatchFinally`'s balanced-brace-scan
|
|
780
|
+
// offsets instead of a `d`-flagged regex's `.indices` (there's no
|
|
781
|
+
// single regex to attach `.indices` to here — that's the whole
|
|
782
|
+
// reason this recognizer scans by hand). catch/finally clauses
|
|
783
|
+
// especially do NOT start on `line` (the `try` keyword's own line)
|
|
784
|
+
// in general — they start wherever their own `catch (...) {` /
|
|
785
|
+
// `finally {` happens to land, often several lines later.
|
|
786
|
+
const tryStartLine = line + _countNewlines(s, tryScan.tryBodyOffset);
|
|
787
|
+
const tryTail = _buildCfg(tryScan.tryBody, nodes, tryNode, tryStartLine, depth + 1);
|
|
788
|
+
let tail = tryTail;
|
|
789
|
+
if (tryScan.catchBody !== null) {
|
|
790
|
+
const catchNode = _addNode(nodes, { kind: 'noop', line });
|
|
791
|
+
_linkNodes(nodes, tail, catchNode);
|
|
792
|
+
const catchStartLine = line + _countNewlines(s, tryScan.catchBodyOffset);
|
|
793
|
+
tail = _buildCfg(tryScan.catchBody, nodes, catchNode, catchStartLine, depth + 1);
|
|
794
|
+
}
|
|
795
|
+
if (tryScan.finallyBody !== null) {
|
|
796
|
+
const finallyNode = _addNode(nodes, { kind: 'noop', line });
|
|
797
|
+
_linkNodes(nodes, tail, finallyNode);
|
|
798
|
+
const finallyStartLine = line + _countNewlines(s, tryScan.finallyBodyOffset);
|
|
799
|
+
tail = _buildCfg(tryScan.finallyBody, nodes, finallyNode, finallyStartLine, depth + 1);
|
|
800
|
+
}
|
|
801
|
+
_linkNodes(nodes, tail, join);
|
|
802
|
+
prev = join;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const switchMatch = s.match(/^switch\s*\((.+?)\)\s*\{([\s\S]*)\}\s*$/ds);
|
|
807
|
+
if (switchMatch) {
|
|
808
|
+
const switchNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(switchMatch[1]), line });
|
|
809
|
+
_linkNodes(nodes, prev, switchNode);
|
|
810
|
+
const join = _addNode(nodes, { kind: 'noop', line });
|
|
811
|
+
// PHP switch/case bodies fall through by default (no per-case
|
|
812
|
+
// braces) — lower the whole switchBlock body as ONE linear sequence
|
|
813
|
+
// under the switch node, matching this plan's "linear-but-complete"
|
|
814
|
+
// target rather than modeling per-case branch/skip semantics.
|
|
815
|
+
const bodyStartLine = line + _countNewlines(s, switchMatch.indices[2][0]);
|
|
816
|
+
const bodyTail = _buildCfg(switchMatch[2], nodes, switchNode, bodyStartLine, depth + 1);
|
|
817
|
+
_linkNodes(nodes, bodyTail, join);
|
|
818
|
+
prev = join;
|
|
317
819
|
continue;
|
|
318
820
|
}
|
|
319
821
|
|
|
320
822
|
const node = _lowerStmt(s, line);
|
|
321
|
-
if (!node)
|
|
823
|
+
if (!node) continue;
|
|
322
824
|
const id = _addNode(nodes, node);
|
|
323
825
|
_linkNodes(nodes, prev, id);
|
|
324
826
|
prev = id;
|
|
325
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
326
827
|
}
|
|
327
828
|
return prev;
|
|
328
829
|
}
|
|
@@ -333,6 +834,7 @@ export function parsePhpFile(file, code) {
|
|
|
333
834
|
if (code.length > 1_000_000) return null;
|
|
334
835
|
|
|
335
836
|
const functions = [];
|
|
837
|
+
const spans = []; // {start, end}: source ranges fully consumed by a matched function (signature through closing brace)
|
|
336
838
|
FUNC_RE.lastIndex = 0;
|
|
337
839
|
_nid = 0;
|
|
338
840
|
let m;
|
|
@@ -353,7 +855,25 @@ export function parsePhpFile(file, code) {
|
|
|
353
855
|
const nodes = {};
|
|
354
856
|
const entry = _addNode(nodes, { kind: 'entry', line: startLine });
|
|
355
857
|
const exit = _addNode(nodes, { kind: 'exit', line: startLine });
|
|
356
|
-
|
|
858
|
+
// R8 fix round 3: the function body's `startLine` must be derived from
|
|
859
|
+
// `braceIdx` (the function's actual opening `{`), not from `startLine + 1`
|
|
860
|
+
// (a flat one-line offset from `m.index`, the FUNC_RE match start —
|
|
861
|
+
// typically a preceding boundary character/token, not the function
|
|
862
|
+
// itself). `startLine + 1` is only correct when `{` sits on the very
|
|
863
|
+
// next physical line after wherever `m.index` landed, which holds for
|
|
864
|
+
// ordinary same-line-brace, single-line-signature functions but breaks
|
|
865
|
+
// for: Allman brace style (`{` on its own line — off by 1), a
|
|
866
|
+
// multi-line function signature (off by 2-3, one per extra signature
|
|
867
|
+
// line), and a function preceded by blank lines (off by however many
|
|
868
|
+
// blank lines precede it — a nearly universal real-world style). This
|
|
869
|
+
// reproduces the exact same pragma-inert/wrong-line-suppresses symptom
|
|
870
|
+
// fix rounds 1-2 already fixed for control-flow bodies, just at the
|
|
871
|
+
// function-body base line instead of a nested recursion site — pre-
|
|
872
|
+
// existing to this task (confirmed byte-identical across the original
|
|
873
|
+
// commit and both prior fix rounds), not something this task's own
|
|
874
|
+
// changes introduced, but it defeats the same user-facing goal so it's
|
|
875
|
+
// fixed here rather than left for a separate task.
|
|
876
|
+
const tail = _buildCfg(extracted.body, nodes, entry, _lineAt(code, braceIdx + 1));
|
|
357
877
|
_linkNodes(nodes, tail, exit);
|
|
358
878
|
const cfg = { entry, exit, nodes };
|
|
359
879
|
functions.push({
|
|
@@ -362,11 +882,49 @@ export function parsePhpFile(file, code) {
|
|
|
362
882
|
cfg,
|
|
363
883
|
calls: callSitesFromCfg(cfg),
|
|
364
884
|
});
|
|
885
|
+
// span.end is exclusive (points after the closing brace), so interior and trailing
|
|
886
|
+
// gaps both work correctly without special casing. extracted.end points AT the brace,
|
|
887
|
+
// so we add 1 to make it exclusive. We keep FUNC_RE.lastIndex at extracted.end
|
|
888
|
+
// (the brace position) so subsequent function matches can use it as a boundary.
|
|
889
|
+
// span.start is m.index PLUS the boundary token FUNC_RE consumed ahead of the
|
|
890
|
+
// function itself (see _funcBoundaryLen) — the boundary char/tag belongs to
|
|
891
|
+
// whatever precedes the function, not to the function's own blanked span.
|
|
892
|
+
spans.push({ start: m.index + _funcBoundaryLen(code, m.index), end: extracted.end + 1 });
|
|
365
893
|
// Don't skip past the closing brace: for `<?php function h(){...} function m(){...}`
|
|
366
894
|
// that brace is the only boundary character available to anchor the next
|
|
367
895
|
// function's match (there's no newline/semicolon between them), and advancing
|
|
368
896
|
// past it here would make the following function declaration unmatchable.
|
|
369
897
|
FUNC_RE.lastIndex = extracted.end;
|
|
370
898
|
}
|
|
371
|
-
|
|
899
|
+
|
|
900
|
+
// R14(b): lower top-level (module-scope) statements into a synthetic
|
|
901
|
+
// <module> function, mirroring parser-js.js's Program-level lowering.
|
|
902
|
+
// Every real function's span is blanked (see _blankSpans) in a copy of
|
|
903
|
+
// the full source, and the WHOLE blanked text is lowered in a single
|
|
904
|
+
// _buildCfg call at startLine=1 — this keeps every remaining statement's
|
|
905
|
+
// reported line number exactly equal to its real source line, since no
|
|
906
|
+
// character is ever deleted, only blanked to a space (newlines always
|
|
907
|
+
// survive). No new statement-classification logic is needed.
|
|
908
|
+
spans.sort((a, b) => a.start - b.start);
|
|
909
|
+
const blanked = _blankLeadingOpenTag(_blankSpans(code, spans));
|
|
910
|
+
const modNodes = {};
|
|
911
|
+
const modEntry = _addNode(modNodes, { kind: 'entry', line: 1 });
|
|
912
|
+
const modExit = _addNode(modNodes, { kind: 'exit', line: 1 });
|
|
913
|
+
const modTail = _buildCfg(blanked, modNodes, modEntry, 1);
|
|
914
|
+
_linkNodes(modNodes, modTail, modExit);
|
|
915
|
+
const modHasContent = Object.values(modNodes).some(n => n.kind !== 'entry' && n.kind !== 'exit');
|
|
916
|
+
let topLevel = null;
|
|
917
|
+
if (modHasContent) {
|
|
918
|
+
const moduleCfg = { entry: modEntry, exit: modExit, nodes: modNodes };
|
|
919
|
+
const modQid = _qid(file, '<module>', 1, code);
|
|
920
|
+
functions.push({
|
|
921
|
+
qid: modQid,
|
|
922
|
+
name: '<module>', line: 1, params: [], file,
|
|
923
|
+
cfg: moduleCfg,
|
|
924
|
+
calls: callSitesFromCfg(moduleCfg),
|
|
925
|
+
});
|
|
926
|
+
topLevel = modQid;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return functions.length ? { file, functions, topLevel } : null;
|
|
372
930
|
}
|