@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.
- package/CHANGELOG.md +644 -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-kt.js
CHANGED
|
@@ -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
|
-
//
|
|
16
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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),
|