@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.
@@ -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
- // - control flow (if/for/while/switch)body is straight-line
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) { buf += c; escape = false; continue; }
143
+ if (escape) { push(c); escape = false; if (c === '\n') curLine++; continue; }
42
144
  if (inStr) {
43
- buf += c;
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; buf += c; continue; }
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 === ']') depth--;
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
- buf += c;
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
- if (buf.trim()) out.push(buf.trim());
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 parts = _splitTopLevelDot(s).map(_lowerExpr);
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
- function _buildCfg(bodyText, nodes, prevId, startLine) {
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.trim();
272
- if (!s || s.startsWith('//') || s.startsWith('#')) { line++; continue; }
703
+ const s = stmt.text;
704
+ const line = startLine + stmt.line - 1;
705
+ if (!s || s.startsWith('//') || s.startsWith('#')) continue;
273
706
 
274
- const ifMatch = s.match(/^if\s*\((.+?)\)\s*\{([\s\S]*)\}(?:\s*else\s*\{([\s\S]*)\})?\s*$/s);
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 thenTail = _buildCfg(ifMatch[2], nodes, ifNode, line + 1);
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 elseTail = _buildCfg(ifMatch[3], nodes, ifNode, line + 1);
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*$/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 bodyTail = _buildCfg(whileMatch[2], nodes, header, line + 1);
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*$/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 bodyTail = _buildCfg(foreachMatch[3], nodes, assignId, line + 1);
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
- line += (s.match(/\n/g) || []).length + 1;
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) { line++; continue; }
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
- const tail = _buildCfg(extracted.body, nodes, entry, startLine + 1);
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
- return functions.length ? { file, functions, topLevel: null } : null;
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
  }