@clear-capabilities/agentic-security-scanner 0.136.9 → 0.137.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.
@@ -9,15 +9,60 @@
9
9
  // - assignments: `val x = …` `var x = …` `x = …`
10
10
  // - calls (statement-form): `obj.method(args)` / `method(args)`
11
11
  // - return: `return expr`
12
+ // - control flow (R8): `if`/`else`/`else if`/`while`/`for`/`when`/`do`/
13
+ // `try`/`catch`/`finally` bodies are recursed into by `_buildCfg`
14
+ // (ported from parser-cs.js's proven keyword+balanced-scan+recurse
15
+ // pattern), so a sink several levels deep inside a braced body is
16
+ // reachable. A `for (x in xs)` header binds the loop variable to the
17
+ // iterated collection before the body is recursed into, so the loop
18
+ // variable itself carries taint provenance (same lesson as C#'s
19
+ // `foreach` and Java's for-each). A `when (subject) { pattern -> rhs
20
+ // … }` block is NOT recursed into via the generic keyword scan — its
21
+ // arms are pattern-matched separately (see `_buildWhenArms`) because a
22
+ // `when` arm's default label is the bare word `else`, which collides
23
+ // with `if`/`else` chaining if run through the same generic matcher.
24
+ // Line numbers through this recursion are computed via exact
25
+ // character-offset lookup (`_lineStarts`/`_lineForOffset`), not
26
+ // approximated.
12
27
  //
13
28
  // What we do NOT model:
14
- // - lambdas (collapsed to opaque expression)
15
- // - destructuring `val (a, b) = pair`
16
- // - `if`/`when`/`for`/`while` control flow (body treated as straight-line)
29
+ // - lambdas (collapsed to opaque expression) — this now explicitly
30
+ // includes Kotlin's idiomatic trailing-lambda call syntax
31
+ // (`xs.forEach { x -> … }`, `xs.reduce(0) { acc, x -> … }`): the call
32
+ // itself is captured as a `call` node (so the call SITE is visible),
33
+ // but the lambda body is not parsed, matching this file's pre-existing
34
+ // scope limit for parenthesized lambda arguments elsewhere. This is a
35
+ // deliberate scope boundary, not an oversight: `.use { }`,
36
+ // `synchronized(lock) { }`, `run { }`, `apply { }`, `let { }` and
37
+ // dozens of other Kotlin stdlib scope functions are ALL syntactically
38
+ // identical — a plain function call with a trailing-lambda argument,
39
+ // not language keyword syntax — so there is no reliable way for a
40
+ // keyword-headed regex matcher (which is what `_buildCfg` is) to single
41
+ // out `synchronized` from `run`/`apply`/`let`/`.use` without arbitrarily
42
+ // privileging one function name over dozens of equally common ones.
43
+ // This differs from C#'s `using`/`lock`, which the C# parser DOES
44
+ // recurse into, because those are real C# keyword-headed statement
45
+ // grammar, syntactically distinguishable from an ordinary method call.
46
+ // - destructuring `val (a, b) = pair`, including a destructured `for
47
+ // ((k, v) in map)` loop-variable binding (the loop body is still
48
+ // recursed into; only the per-variable taint binding is skipped)
49
+ // - `when` branch/pattern semantics: an arm's PATTERN (`1`, `is Foo`,
50
+ // `in 1..5`) is not lowered or evaluated — only its right-hand-side
51
+ // statement is. Matches this task's "recurse into bodies, don't model
52
+ // branching semantics" scope, same as `if`/`try`/`catch` elsewhere in
53
+ // this file.
17
54
  // - infix functions (the call shape isn't recognized)
18
55
  // - operator overloading
56
+ // - control-flow BRANCHING semantics generally: `if`/`else`, `when` arms,
57
+ // and `try`/`catch`/`finally` clauses are each recursed into and linked
58
+ // SEQUENTIALLY (the same "linear but complete" approximation
59
+ // parser-cs.js and parser-cpp.js use) rather than as alternative paths —
60
+ // every branch's body is reachable in the CFG, which is what taint
61
+ // analysis needs, but the graph does not model that only one branch
62
+ // executes per run.
19
63
  //
20
- // Single-pass v1. Roslyn-equivalent for Kotlin (kotlinc -p ir or PSI via
64
+ // Single-pass v1 lowering of leaf statements; the CFG shape above it is now
65
+ // recursive (R8). Roslyn/PSI-equivalent for Kotlin (kotlinc -p ir or PSI via
21
66
  // gradle helper) is the upgrade path.
22
67
 
23
68
  import * as crypto from 'node:crypto';
@@ -31,6 +76,18 @@ const FUN_RE = new RegExp(
31
76
  '\\s*\\(([^)]*)\\)' + // params (group 2)
32
77
  '\\s*(?::\\s*[A-Za-z_][\\w<>?,\\s.]*)?\\s*\\{', 'g'); // optional return type then '{'
33
78
 
79
+ // R8: reused UNCHANGED by the new recursive `_buildCfg` below — this
80
+ // splitter already flushes on `\n` OR `;` at depth 0 (Kotlin has no
81
+ // C#-style `;`-only statement terminator), which is exactly the
82
+ // granularity `_buildCfg` needs. Because it does NOT also flush on a `}`
83
+ // returning to depth 0 (unlike parser-cs.js's R8-updated splitter), a
84
+ // single returned statement can itself be a CHAIN of glued keyword-headed
85
+ // constructs when they share a line in the common formatting style (`if
86
+ // (x) { … } else { … }`, `try { … } catch (…) { … } finally { … }`) — the
87
+ // `}` and the continuation keyword never straddle a depth-0 newline, so no
88
+ // flush point falls between them. `_buildCfg`'s inner chain loop
89
+ // (`_consumeChunk`) is what walks forward through such a glued statement,
90
+ // consuming one keyword-headed construct at a time.
34
91
  function _splitStatements(body) {
35
92
  const out = [];
36
93
  let buf = '';
@@ -173,9 +230,366 @@ function _lowerStmt(stmt, line) {
173
230
  // Statement-form call
174
231
  const cm = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
175
232
  if (cm) return { kind: 'call', line, callee: cm[1], args: _splitTopLevelCommas(cm[2]).map(_lowerExpr) };
233
+ // R8: trailing-lambda call — `recv.method(args)? { lambda }`, Kotlin's
234
+ // idiomatic collection-operator / scope-function syntax
235
+ // (`xs.forEach { x -> … }`, `xs.reduce(0) { acc, x -> … }`). Without this
236
+ // branch the statement fell all the way through to `unknown` (verified:
237
+ // this is NOT the same shape the ordinary call regex above already
238
+ // handles — that regex requires the statement to END in `)`, but a
239
+ // trailing-lambda statement ends in `}` with no enclosing parens around
240
+ // the lambda at all, unlike C#'s `xs.ForEach(x => { … })` where the
241
+ // lambda sits INSIDE a real paren argument list). The lambda body is
242
+ // collapsed to an opaque expression — see this module's header comment
243
+ // for why `.use{}`/`synchronized(){}`/etc are deliberately not recursed
244
+ // into — so only the call's own identity (callee + any non-lambda args)
245
+ // is modeled; that is enough to keep the call SITE visible instead of
246
+ // silently vanishing.
247
+ //
248
+ // R8 fix round (found by this task's own bench:self-scan:check, the exact
249
+ // same defect class as R14(a)'s C# `attrRegex` ReDoS): the original single
250
+ // pattern here had an optional paren group sandwiched between two `\s*`
251
+ // quantifiers (`\s*(\([^()]*\))?\s*`) — on a failing match, the engine can
252
+ // partition a run of whitespace between the two `\s*`s in exponentially
253
+ // many ways, confirmed genuinely quadratic (200,000-char adversarial input:
254
+ // ~1.7s at 20,000 chars, extrapolating far higher at realistic file sizes).
255
+ // Restructured into two mutually-exclusive alternatives — no-parens and
256
+ // with-parens — exactly as `class-hierarchy.js` (commit `6bd394c`) and the
257
+ // C# fix (PRD R14(a)) both did for the identical shape: a REQUIRED group
258
+ // between two `\s*`s has no such ambiguity, because the parser is not
259
+ // choosing whether to consume the group, only where the group starts.
260
+ // Re-verified linear (200,000 chars: ~3ms) and byte-identical to the old
261
+ // pattern's output across a 15-shape sweep (calls with/without args, no
262
+ // parens at all, multi-segment callee, non-matching text, unbalanced
263
+ // parens, empty body).
264
+ const trailingNoParens = s.match(/^([\w.]+)\s*\{[\s\S]*\}\s*$/);
265
+ const trailingWithParens = trailingNoParens ? null : s.match(/^([\w.]+)\s*(\([^()]*\))\s*\{[\s\S]*\}\s*$/);
266
+ const trailing = trailingNoParens || trailingWithParens;
267
+ if (trailing) {
268
+ const parenGroup = trailingWithParens ? trailingWithParens[2] : null;
269
+ const argsText = parenGroup ? parenGroup.slice(1, -1) : '';
270
+ return { kind: 'call', line, callee: trailing[1], args: argsText ? _splitTopLevelCommas(argsText).map(_lowerExpr) : [] };
271
+ }
176
272
  return { kind: 'unknown', line, text: s };
177
273
  }
178
274
 
275
+ // Build a sorted array of line-start offsets for `text` (index 0 holds the
276
+ // start of line 1, i.e. always 0). Paired with `_lineForOffset` to turn a
277
+ // character offset into an exact 1-based line number in O(log n) — ported
278
+ // verbatim from parser-cs.js / parser-cpp.js, the proven reference for this
279
+ // exact-offset-based line computation pattern.
280
+ function _lineStarts(text) {
281
+ const starts = [0];
282
+ for (let i = 0; i < text.length; i++) {
283
+ if (text[i] === '\n') starts.push(i + 1);
284
+ }
285
+ return starts;
286
+ }
287
+
288
+ function _lineForOffset(lineStarts, idx) {
289
+ let lo = 0, hi = lineStarts.length - 1;
290
+ while (lo < hi) {
291
+ const mid = (lo + hi + 1) >> 1;
292
+ if (lineStarts[mid] <= idx) lo = mid; else hi = mid - 1;
293
+ }
294
+ return lo + 1;
295
+ }
296
+
297
+ function _lineForAbs(lineStarts, funcStartLine, abs) {
298
+ return funcStartLine + _lineForOffset(lineStarts, abs) - 1;
299
+ }
300
+
301
+ // Find the index of the delimiter in `openCh`/`closeCh` that matches the
302
+ // one at `openIdx`, respecting nesting and skipping string-literal content.
303
+ // Returns -1 if unmatched. Ported verbatim from parser-cs.js.
304
+ function _matchDelim(text, openIdx, openCh, closeCh) {
305
+ let depth = 0;
306
+ let inStr = null;
307
+ let escape = false;
308
+ for (let i = openIdx; i < text.length; i++) {
309
+ const c = text[i];
310
+ if (escape) { escape = false; continue; }
311
+ if (inStr) {
312
+ if (c === '\\') { escape = true; continue; }
313
+ if (c === inStr) inStr = null;
314
+ continue;
315
+ }
316
+ if (c === '"' || c === "'") { inStr = c; continue; }
317
+ if (c === openCh) depth++;
318
+ else if (c === closeCh) { depth--; if (depth === 0) return i; }
319
+ }
320
+ return -1;
321
+ }
322
+
323
+ // R8 lesson from the PHP port of this task (fix round covering a comment
324
+ // between `}` and a continuation keyword defeating a whitespace-only
325
+ // lookahead): skip BOTH whitespace and `//`/`/* */` comments, not just
326
+ // whitespace, wherever `_consumeChunk` needs to look past a `}` for a
327
+ // possible continuation keyword (`else`, `catch`, `finally`) or past a
328
+ // header's closing `)` for its opening `{`. Kotlin's shared statement
329
+ // splitter (`_splitStatements`) does not strip comments the way
330
+ // parser-cs.js's does, so a comment sitting between a control-flow body's
331
+ // close and its continuation is genuinely reachable here, not merely
332
+ // hypothetical.
333
+ function _skipWsComments(s, i) {
334
+ for (;;) {
335
+ while (i < s.length && /\s/.test(s[i])) i++;
336
+ if (s[i] === '/' && s[i + 1] === '/') {
337
+ while (i < s.length && s[i] !== '\n') i++;
338
+ continue;
339
+ }
340
+ if (s[i] === '/' && s[i + 1] === '*') {
341
+ i += 2;
342
+ while (i < s.length && !(s[i] === '*' && s[i + 1] === '/')) i++;
343
+ if (i < s.length) i += 2;
344
+ continue;
345
+ }
346
+ break;
347
+ }
348
+ return i;
349
+ }
350
+
351
+ // Find the first top-level `->` in a `when` arm's text (its arm separator,
352
+ // `pattern -> statement`), respecting string literals and nested
353
+ // parens/brackets/braces (a pattern can be `is Foo(1, 2)` or a range
354
+ // `in 1..5`, and an arm's RHS can itself be a `{ … }` block). Returns -1 if
355
+ // none is found (a malformed/unsupported arm shape — the caller skips it;
356
+ // this parser does not model `when` pattern semantics, only the RHS body).
357
+ function _findTopLevelArrow(s) {
358
+ let depth = 0, inStr = null, escape = false;
359
+ for (let i = 0; i < s.length - 1; i++) {
360
+ const c = s[i];
361
+ if (escape) { escape = false; continue; }
362
+ if (inStr) {
363
+ if (inStr === '"' && c === '\\') { escape = true; continue; }
364
+ if (c === inStr) inStr = null;
365
+ continue;
366
+ }
367
+ if (c === '"' || c === "'") { inStr = c; continue; }
368
+ if (c === '(' || c === '{' || c === '[') depth++;
369
+ else if (c === ')' || c === '}' || c === ']') depth--;
370
+ else if (depth === 0 && c === '-' && s[i + 1] === '>') return i;
371
+ }
372
+ return -1;
373
+ }
374
+
375
+ // Node-id counter for `_buildCfg`. Reset to 0 per function (see
376
+ // `parseKotlinFile`) so ids stay `n0`, `n1`, ... within a single function's
377
+ // `cfg.nodes` — matching the pre-R8 flat loop's `n${idx}` naming
378
+ // convention, just keyed off a running node COUNT now rather than the
379
+ // original statement array's index (a single top-level statement, an `if`
380
+ // block, can now expand into many CFG nodes, so id generation can no
381
+ // longer be tied to statement position).
382
+ let _ktNid = 0;
383
+ function _nextNodeId() { return `n${_ktNid++}`; }
384
+
385
+ function _addNode(nodes, node) {
386
+ const id = _nextNodeId();
387
+ node.succ = node.succ || [];
388
+ node.pred = node.pred || [];
389
+ nodes[id] = node;
390
+ return id;
391
+ }
392
+
393
+ function _linkNodes(nodes, src, dst) {
394
+ if (!nodes[src] || !nodes[dst]) return;
395
+ if (!nodes[src].succ.includes(dst)) nodes[src].succ.push(dst);
396
+ if (!nodes[dst].pred.includes(src)) nodes[dst].pred.push(src);
397
+ }
398
+
399
+ const HEADER_RE = /^(if|while|for|when|else\s+if|else|do|try|catch|finally)\b/;
400
+ const NEEDS_COND_RE = /^(?:if|while|when|else if|catch)$/;
401
+
402
+ // R8: recursive statement handler. `s` is ONE element returned by
403
+ // `_splitStatements` — which, because Kotlin's splitter (unlike C#'s) does
404
+ // not flush on a `}` reaching depth 0, may itself be a CHAIN of glued
405
+ // keyword-headed constructs (`if (x) { … } else { … }`,
406
+ // `try { … } catch (…) { … } finally { … }`) when they share a source line
407
+ // in the common formatting style. This walks forward through `s`,
408
+ // consuming one keyword-headed construct at a time and recursing into its
409
+ // body, until no further continuation keyword is found immediately after
410
+ // the previous construct's closing brace; genuinely leftover non-keyword
411
+ // text (rare) is lowered as its own statement rather than silently
412
+ // dropped. `abs0` is the absolute offset — within the function's whole,
413
+ // untouched body text — of `s[0]`; every position derived below is
414
+ // `abs0 + <offset within s>`, never re-approximated.
415
+ function _consumeChunk(s, abs0, nodes, prevId, funcStartLine, lineStarts, depth) {
416
+ if (depth > 12) return prevId;
417
+ let prev = prevId;
418
+ let pos = 0;
419
+ let first = true;
420
+ for (;;) {
421
+ const skipTo = _skipWsComments(s, pos);
422
+ const rest = s.slice(skipTo);
423
+ const hm = rest.match(HEADER_RE);
424
+ if (!hm) {
425
+ if (first) {
426
+ return _lowerLeafOrBlock(s, abs0, nodes, prev, funcStartLine, lineStarts, depth);
427
+ }
428
+ if (rest.trim()) {
429
+ prev = _lowerLeafOrBlock(rest, abs0 + skipTo, nodes, prev, funcStartLine, lineStarts, depth);
430
+ }
431
+ return prev;
432
+ }
433
+ first = false;
434
+ const kwNorm = hm[1].replace(/\s+/g, ' ').trim();
435
+ const line = _lineForAbs(lineStarts, funcStartLine, abs0 + skipTo);
436
+
437
+ let p = skipTo + hm[0].length;
438
+ p = _skipWsComments(s, p);
439
+ let condRaw = null, afterHeader = p;
440
+ if (s[p] === '(') {
441
+ const closeIdx = _matchDelim(s, p, '(', ')');
442
+ if (closeIdx !== -1) {
443
+ condRaw = s.slice(p + 1, closeIdx);
444
+ afterHeader = closeIdx + 1;
445
+ }
446
+ }
447
+
448
+ if (kwNorm === 'for' && condRaw !== null) {
449
+ // `for (x in xs)` — Kotlin's only `for` shape is a for-each; there is
450
+ // no C-style `for (init; test; step)`. The parenthesised clause is a
451
+ // declaration, not an expression, so it gets its own loop-header node
452
+ // (no `cond`) plus — R8 lesson from Java's for-each gap — a
453
+ // synthesized assign binding the loop variable to the iterated
454
+ // collection BEFORE the body is recursed into, so the loop variable
455
+ // itself carries taint provenance.
456
+ const headerId = _addNode(nodes, { kind: 'loop-header', line });
457
+ _linkNodes(nodes, prev, headerId);
458
+ prev = headerId;
459
+ const fm = condRaw.match(/^([\s\S]+?)\s+in\s+([\s\S]+)$/);
460
+ if (fm) {
461
+ const declPart = fm[1].trim();
462
+ // Destructured loop var (`for ((k, v) in map)`) is out of scope —
463
+ // same "destructuring not modeled" limit this file's header
464
+ // documents for `val (a, b) = pair`; skip the binding, still
465
+ // recurse into the body below.
466
+ if (!/^\(/.test(declPart)) {
467
+ const loopVar = declPart.replace(/:\s*[\w<>?,\s.]+$/, '').trim();
468
+ const iterExpr = fm[2].trim();
469
+ if (loopVar && /^[A-Za-z_]\w*$/.test(loopVar)) {
470
+ const assignId = _addNode(nodes, { kind: 'assign', line, target: loopVar, source: _lowerExpr(iterExpr) });
471
+ _linkNodes(nodes, prev, assignId);
472
+ prev = assignId;
473
+ }
474
+ }
475
+ }
476
+ } else if (NEEDS_COND_RE.test(kwNorm) && condRaw !== null) {
477
+ const ifId = _addNode(nodes, { kind: 'if', line, cond: _lowerExpr(condRaw) });
478
+ _linkNodes(nodes, prev, ifId);
479
+ prev = ifId;
480
+ }
481
+
482
+ const restIdx = _skipWsComments(s, afterHeader);
483
+ if (s[restIdx] === '{') {
484
+ const closeRel = _matchDelim(s, restIdx, '{', '}');
485
+ if (closeRel !== -1) {
486
+ const innerAbs0 = abs0 + restIdx + 1;
487
+ if (kwNorm === 'when') {
488
+ prev = _buildWhenArms(s.slice(restIdx + 1, closeRel), nodes, prev, funcStartLine, lineStarts, innerAbs0, depth + 1);
489
+ } else {
490
+ prev = _buildCfg(s.slice(restIdx + 1, closeRel), nodes, prev, funcStartLine, lineStarts, innerAbs0, depth + 1);
491
+ }
492
+ pos = closeRel + 1;
493
+ } else {
494
+ // Unbalanced braces — bail rather than loop forever.
495
+ pos = s.length;
496
+ }
497
+ } else {
498
+ // Braceless single-statement body (`if (x) doSomething()`), or a
499
+ // continuation with no body at all (a do-while's trailing
500
+ // `while (cond)`, which has no block of its own). Take whatever
501
+ // remains of this chunk as the body and stop chaining further within
502
+ // this call — matching parser-cs.js's own simplification for this
503
+ // shape.
504
+ const bodyText = s.slice(afterHeader);
505
+ if (bodyText.trim()) {
506
+ prev = _consumeChunk(bodyText, abs0 + afterHeader, nodes, prev, funcStartLine, lineStarts, depth + 1);
507
+ }
508
+ pos = s.length;
509
+ }
510
+
511
+ if (pos >= s.length || depth > 12) return prev;
512
+ }
513
+ }
514
+
515
+ // A single leaf statement or a bare (keyword-less) `{ … }` block. Bare
516
+ // blocks are rare in idiomatic Kotlin but are still valid syntax.
517
+ function _lowerLeafOrBlock(s, abs0, nodes, prevId, funcStartLine, lineStarts, depth) {
518
+ const leadWs = s.match(/^\s*/)[0].length;
519
+ const trimmed = s.trim();
520
+ if (!trimmed) return prevId;
521
+ const bare = trimmed.match(/^\{([\s\S]*)\}$/);
522
+ if (bare) {
523
+ const innerAbs0 = abs0 + leadWs + 1;
524
+ return _buildCfg(bare[1], nodes, prevId, funcStartLine, lineStarts, innerAbs0, depth + 1);
525
+ }
526
+ const line = _lineForAbs(lineStarts, funcStartLine, abs0 + leadWs);
527
+ const node = _lowerStmt(trimmed, line);
528
+ if (!node) return prevId;
529
+ const id = _addNode(nodes, node);
530
+ _linkNodes(nodes, prevId, id);
531
+ return id;
532
+ }
533
+
534
+ // A `when (subject) { … }` block's body is NOT statements in the ordinary
535
+ // sense — each top-level unit is an ARM, `pattern -> rhs`, where `rhs` can
536
+ // be a single statement or a `{ … }` block. Handled separately from the
537
+ // generic `_buildCfg` recursion for one specific reason: a `when` arm's
538
+ // default label is the bare word `else` (`else -> …`), which is also
539
+ // `_consumeChunk`'s continuation keyword for `if`/`else` chaining — running
540
+ // the generic header matcher directly over arm text would misinterpret
541
+ // `else -> cleanup(id)` as an if-else continuation expecting a `{ … }`
542
+ // body, not a `when` arm. Splitting off the pattern (via
543
+ // `_findTopLevelArrow`, ignoring its text entirely — pattern semantics are
544
+ // out of scope) before handing the RHS to `_consumeChunk` sidesteps the
545
+ // collision: the generic matcher never sees the word `else` in the
546
+ // pattern position.
547
+ function _buildWhenArms(bodyInner, nodes, prevId, funcStartLine, lineStarts, abs0, depth) {
548
+ let prev = prevId;
549
+ let cursor = 0;
550
+ for (const armText of _splitStatements(bodyInner)) {
551
+ if (!armText) continue;
552
+ let idx = bodyInner.indexOf(armText, cursor);
553
+ if (idx === -1) idx = cursor;
554
+ cursor = idx + armText.length;
555
+ const armAbs0 = abs0 + idx;
556
+ const arrowIdx = _findTopLevelArrow(armText);
557
+ if (arrowIdx === -1) continue; // unsupported arm shape — skip, don't drop the whole when.
558
+ const rhs = armText.slice(arrowIdx + 2);
559
+ const rhsAbs0 = armAbs0 + arrowIdx + 2;
560
+ if (!rhs.trim()) continue;
561
+ prev = _consumeChunk(rhs, rhsAbs0, nodes, prev, funcStartLine, lineStarts, depth);
562
+ }
563
+ return prev;
564
+ }
565
+
566
+ // Top-level entry point: split `bodyText` into statements (Kotlin's own
567
+ // splitter — reused unchanged, see its header comment) and process each
568
+ // through `_consumeChunk`. `abs0` is the absolute offset — within the
569
+ // function's whole, untouched body text — of `bodyText[0]`; threaded
570
+ // through every recursive call so line numbers are always computed via a
571
+ // direct offset lookup, never approximated. Because `_splitStatements`
572
+ // itself does not report offsets (deliberately reused unmodified — see its
573
+ // header comment), each returned statement's offset is instead recovered
574
+ // by searching forward from a monotonically-advancing cursor: every
575
+ // statement is guaranteed (by that function's own accumulation logic — no
576
+ // character is ever rewritten, only leading/trailing whitespace trimmed)
577
+ // to be a literal, contiguous substring of `bodyText`, so `indexOf` from
578
+ // the previous statement's end can never mis-locate it.
579
+ function _buildCfg(bodyText, nodes, prevId, funcStartLine, lineStarts, abs0, depth = 0) {
580
+ if (depth > 12) return prevId;
581
+ let prev = prevId;
582
+ let cursor = 0;
583
+ for (const stmtText of _splitStatements(bodyText)) {
584
+ if (!stmtText) continue;
585
+ let idx = bodyText.indexOf(stmtText, cursor);
586
+ if (idx === -1) idx = cursor;
587
+ cursor = idx + stmtText.length;
588
+ prev = _consumeChunk(stmtText, abs0 + idx, nodes, prev, funcStartLine, lineStarts, depth);
589
+ }
590
+ return prev;
591
+ }
592
+
179
593
  function _extractBody(src, openBrace) {
180
594
  let depth = 1;
181
595
  let i = openBrace + 1;
@@ -231,23 +645,27 @@ export function parseKotlinFile(file, code) {
231
645
  const extracted = _extractBody(code, braceIdx);
232
646
  if (!extracted) continue;
233
647
  const startLine = _lineAt(code, m.index);
234
- const stmts = _splitStatements(extracted.body);
648
+ // R8 (lesson learned from the PHP port of this task, and this
649
+ // function's OWN pre-R8 bug): the body's own start line must be
650
+ // derived from `braceIdx` — the function's ACTUAL opening `{` — not
651
+ // approximated as `startLine` (the declaration line). Before this fix,
652
+ // every statement's line was computed by starting at `startLine` and
653
+ // accumulating `\n` counts across already-flattened statement text —
654
+ // both wrong in the same direction (off by however many lines the
655
+ // signature's own line differs from the body's first content line, at
656
+ // minimum 1 for the extremely common same-line-brace style this file's
657
+ // own tests use).
658
+ const bodyStartLine = _lineAt(code, braceIdx + 1);
659
+ // Built once per function body; `_buildCfg` looks up every node's line
660
+ // in O(log n) via `_lineForOffset` against this SAME array.
661
+ const lineStarts = _lineStarts(extracted.body);
235
662
  const nodes = {};
236
663
  nodes.entry = { kind: 'entry', line: startLine, succ: [], pred: [] };
237
664
  nodes.exit = { kind: 'exit', line: startLine, succ: [], pred: [] };
238
- let prev = 'entry';
239
- let stmtLine = startLine;
240
- for (let idx = 0; idx < stmts.length; idx++) {
241
- const node = _lowerStmt(stmts[idx], stmtLine);
242
- if (!node) continue;
243
- const id = `n${idx}`;
244
- nodes[id] = { ...node, succ: [], pred: [prev] };
245
- nodes[prev].succ.push(id);
246
- prev = id;
247
- stmtLine += (stmts[idx].match(/\n/g) || []).length + 1;
248
- }
249
- nodes[prev].succ.push('exit');
250
- nodes.exit.pred.push(prev);
665
+ _ktNid = 0;
666
+ const tail = _buildCfg(extracted.body, nodes, 'entry', bodyStartLine, lineStarts, 0, 0);
667
+ nodes[tail].succ.push('exit');
668
+ nodes.exit.pred.push(tail);
251
669
  const cfg = { entry: 'entry', exit: 'exit', nodes };
252
670
  functions.push({
253
671
  qid: _qid(file, name, startLine, extracted.body),