@clear-capabilities/agentic-security-scanner 0.136.2 → 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.
Files changed (117) hide show
  1. package/CHANGELOG.md +880 -0
  2. package/bin/agentic-security.js +189 -37
  3. package/dist/113.index.js +13 -4
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +5 -4
  6. package/dist/238.index.js +1 -1
  7. package/dist/317.index.js +36 -6
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +192 -15
  10. package/dist/444.index.js +20 -11
  11. package/dist/449.index.js +8 -1
  12. package/dist/526.index.js +3 -3
  13. package/dist/637.index.js +1 -1
  14. package/dist/agentic-security.mjs +15 -15
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
  17. package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
  18. package/package.json +21 -13
  19. package/src/dataflow/CLAUDE.md +12 -4
  20. package/src/dataflow/builtin-summaries.js +1 -1
  21. package/src/dataflow/catalog-expanded.js +1 -0
  22. package/src/dataflow/catalog.js +157 -31
  23. package/src/dataflow/engine.js +639 -112
  24. package/src/dataflow/implicit-flow.js +68 -36
  25. package/src/dataflow/incremental.js +18 -3
  26. package/src/dataflow/index.js +17 -1
  27. package/src/dataflow/points-to.js +19 -6
  28. package/src/dataflow/proven-clean.js +41 -0
  29. package/src/dataflow/sanitizer-gate.js +35 -9
  30. package/src/dataflow/sanitizer-proof.js +21 -3
  31. package/src/dataflow/stub-aware-filter.js +36 -13
  32. package/src/dataflow/summaries.js +21 -2
  33. package/src/engine.js +430 -196
  34. package/src/ir/CLAUDE.md +16 -2
  35. package/src/ir/balanced-call.js +55 -0
  36. package/src/ir/class-hierarchy.js +57 -11
  37. package/src/ir/index.js +14 -2
  38. package/src/ir/parser-cs.js +513 -40
  39. package/src/ir/parser-go.js +29 -11
  40. package/src/ir/parser-java.js +300 -20
  41. package/src/ir/parser-js.js +300 -22
  42. package/src/ir/parser-kt.js +436 -18
  43. package/src/ir/parser-php.js +631 -38
  44. package/src/ir/parser-py.helper.py +32 -2
  45. package/src/ir/parser-py.js +31 -4
  46. package/src/ir/parser-rb.js +161 -26
  47. package/src/ir/ssa.js +6 -1
  48. package/src/lsp/server.js +35 -3
  49. package/src/mcp/CLAUDE.md +9 -2
  50. package/src/mcp/redact.js +26 -0
  51. package/src/mcp/tools.js +164 -15
  52. package/src/posture/CLAUDE.md +19 -7
  53. package/src/posture/accuracy-scorecard.js +9 -1
  54. package/src/posture/aibom.js +12 -8
  55. package/src/posture/auditor-walkthrough.js +102 -3
  56. package/src/posture/autopilot.js +8 -1
  57. package/src/posture/calibration-drift.js +11 -5
  58. package/src/posture/calibration.js +24 -2
  59. package/src/posture/clustering.js +12 -1
  60. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
  61. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  62. package/src/posture/compliance-policy.js +33 -1
  63. package/src/posture/confidence.js +44 -10
  64. package/src/posture/corpus-enroll.js +9 -5
  65. package/src/posture/corpus-match.js +19 -0
  66. package/src/posture/csharp-analysis.js +62 -3
  67. package/src/posture/deploy-platform.js +4 -1
  68. package/src/posture/drift.js +7 -1
  69. package/src/posture/epss.js +13 -1
  70. package/src/posture/evidence-bundle.js +36 -6
  71. package/src/posture/exploitability-probability.js +13 -1
  72. package/src/posture/falsification.js +23 -2
  73. package/src/posture/fix-metrics.js +1 -1
  74. package/src/posture/fix-verify-loop.js +10 -1
  75. package/src/posture/iac-reachability.js +14 -8
  76. package/src/posture/integrity.js +25 -7
  77. package/src/posture/model-rescan.js +65 -0
  78. package/src/posture/mttr.js +5 -0
  79. package/src/posture/poc-inprocess.js +27 -8
  80. package/src/posture/regression-test-gen.js +23 -8
  81. package/src/posture/reverse-blast-radius.js +5 -1
  82. package/src/posture/risk-dollars.js +18 -1
  83. package/src/posture/sbom.js +2 -2
  84. package/src/posture/secret-history.js +20 -11
  85. package/src/posture/security-trend.js +7 -1
  86. package/src/posture/stack-playbook.js +22 -1
  87. package/src/posture/threat-model-grounding.js +2 -2
  88. package/src/posture/validator-metrics.js +10 -3
  89. package/src/posture/verifier.js +32 -57
  90. package/src/report/index.js +183 -14
  91. package/src/runScan.js +1 -1
  92. package/src/sast/_comment-strip.js +15 -4
  93. package/src/sast/_secret-entropy.js +1 -1
  94. package/src/sast/authz.js +6 -4
  95. package/src/sast/bench-shape/index.js +2 -7
  96. package/src/sast/claude-md-prompt-injection.js +14 -3
  97. package/src/sast/cloud-iam.js +60 -7
  98. package/src/sast/cpp-bench-extras.js +1 -1
  99. package/src/sast/csrf.js +7 -5
  100. package/src/sast/env-hygiene.js +5 -2
  101. package/src/sast/iac-terraform.js +25 -0
  102. package/src/sast/java-bench-extras.js +1 -1
  103. package/src/sast/java-constant-fold.js +5 -5
  104. package/src/sast/llm-owasp.js +4 -2
  105. package/src/sast/mcp-audit.js +7 -0
  106. package/src/sast/pipeline.js +8 -0
  107. package/src/sast/prompt-template.js +8 -6
  108. package/src/sast/prototype-pollution.js +6 -2
  109. package/src/sast/redos-nfa.js +6 -6
  110. package/src/sast/secret-concat.js +13 -2
  111. package/src/sast/ssrf-cloud-metadata.js +6 -3
  112. package/src/sast/xss-reflected-multilang.js +1 -1
  113. package/src/sast/xxe.js +1 -1
  114. package/src/sca/CLAUDE.md +3 -4
  115. package/src/sca/container.js +35 -3
  116. package/src/sca/dep-confusion.js +7 -0
  117. package/src/sca/sarif-ingest.js +0 -187
@@ -22,6 +22,7 @@
22
22
 
23
23
  import * as crypto from 'node:crypto';
24
24
  import { callSitesFromCfg } from './call-sites.js';
25
+ import { matchBalancedCall } from './balanced-call.js';
25
26
 
26
27
  const FUNC_RE = new RegExp(
27
28
  '(?:^|[\\n;{}])\\s*func\\s+' +
@@ -76,7 +77,11 @@ function _lowerExpr(text) {
76
77
  const s = String(text || '').trim();
77
78
  if (!s) return { kind: 'unknown' };
78
79
  if (/^fmt\.Sprintf\s*\(/.test(s)) {
79
- const inner = s.slice(s.indexOf('(') + 1, s.lastIndexOf(')'));
80
+ // Same balanced-paren concern as the call patterns below — a chained
81
+ // call after fmt.Sprintf(...) (rare but not impossible via a wrapper)
82
+ // would otherwise have its args text swallow the chain's own parens.
83
+ const sprintf = matchBalancedCall(s, /^fmt\.Sprintf/);
84
+ const inner = sprintf ? sprintf.argsText : s.slice(s.indexOf('(') + 1, s.lastIndexOf(')'));
80
85
  const parts = _splitTopLevelCommas(inner).map(_lowerExpr);
81
86
  return { kind: 'tpl', parts };
82
87
  }
@@ -91,17 +96,30 @@ function _lowerExpr(text) {
91
96
  if (/^"/.test(s) || /^`/.test(s)) return { kind: 'literal', value: s };
92
97
  if (/^\d/.test(s)) return { kind: 'literal', value: s };
93
98
  if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
94
- // Call: foo.Bar(args) or Bar(args)
95
- const callMatch = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
99
+ // Call: foo.Bar(args) or Bar(args). matchBalancedCall finds the paren
100
+ // that actually balances the FIRST '(' — not the greedy-to-end-of-string
101
+ // match the old `/\((.*)\)\s*$/` used, which corrupted the argument text
102
+ // for a chained call (`Sanitize(x).Trim()` produced args="x).Trim(",
103
+ // which then fell through to {kind:'unknown'} and silently dropped x).
104
+ const callMatch = matchBalancedCall(s, /^([\w.]+)/);
96
105
  if (callMatch) {
97
- const callee = callMatch[1];
98
- const args = _splitTopLevelCommas(callMatch[2]).map(_lowerExpr);
99
- return { kind: 'call', callee, args };
106
+ const args = _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr);
107
+ return { kind: 'call', callee: callMatch.callee, args };
100
108
  }
101
- // String concat with +
109
+ // String concat with + — a SECOND occurrence of the same check as above
110
+ // (line ~92), reached when the expression didn't match any branch in
111
+ // between (calls, literals, etc.). It's missing the `parts.length > 1`
112
+ // guard the first occurrence has: when the `+` sits inside an extra
113
+ // pair of parens (`("SELECT " + name)` — e.g. an over-parenthesized
114
+ // argument), `_splitTopLevelPlus` returns the input unchanged as ONE
115
+ // part (depth never returns to 0 at the `+`), and `.map(_lowerExpr)`
116
+ // then recurses on the IDENTICAL string forever — a stack overflow,
117
+ // uncaught here (only caught by buildProjectIR's per-file try/catch, so
118
+ // it surfaces only as "this file has no IR"). Same bug class, same fix,
119
+ // as parser-cs.js's `_splitTopLevelPlus` guard for `new Type(concat)`.
102
120
  if (s.includes('+') && /["'`]/.test(s)) {
103
- const parts = _splitTopLevelPlus(s).map(_lowerExpr);
104
- return { kind: 'tpl', parts };
121
+ const parts = _splitTopLevelPlus(s);
122
+ if (parts.length > 1) return { kind: 'tpl', parts: parts.map(_lowerExpr) };
105
123
  }
106
124
  // Member: a.b.c
107
125
  if (/^[A-Za-z_][\w.]*$/.test(s)) {
@@ -220,9 +238,9 @@ function _lowerStmt(stmt, line) {
220
238
  return { kind: 'assign', line, target: varDecl[1], source: _lowerExpr(varDecl[2]) };
221
239
  }
222
240
  // Statement-form call: obj.Method(args) or Method(args)
223
- const cm = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
241
+ const cm = matchBalancedCall(s, /^([\w.]+)/);
224
242
  if (cm) {
225
- return { kind: 'call', line, callee: cm[1], args: _splitTopLevelCommas(cm[2]).map(_lowerExpr) };
243
+ return { kind: 'call', line, callee: cm.callee, args: _splitTopLevelCommas(cm.argsText).map(_lowerExpr) };
226
244
  }
227
245
  return null;
228
246
  }
@@ -22,6 +22,7 @@
22
22
  // when the shape is unfamiliar.
23
23
 
24
24
  import { blankComments } from '../sast/_comment-strip.js';
25
+ import { callSitesFromCfg } from './call-sites.js';
25
26
 
26
27
  let _nodeIdSeq = 0;
27
28
  function nextNodeId() { return 'jn' + (++_nodeIdSeq); }
@@ -58,6 +59,27 @@ function exprFromCst(node) {
58
59
  // CST node with named children — recurse into the most informative one.
59
60
  // Method invocation
60
61
  if (node.children.methodInvocation) return _methodInvocation(node.children.methodInvocation[0]);
62
+ // `primary` is how java-parser actually models a call: the name lives in
63
+ // primaryPrefix (an fqnOrRefType) and the invocation itself in
64
+ // primarySuffix.methodInvocationSuffix. Without this branch the fall-through
65
+ // below recursed into the prefix and returned the NAME — so
66
+ // `req.getParameter("q")` lowered to a member access, never a call, and no
67
+ // source or sink in the Java catalog could ever match.
68
+ if (node.children.primaryPrefix) {
69
+ const prefix = node.children.primaryPrefix[0];
70
+ const suffixes = node.children.primarySuffix || [];
71
+ const invocation = suffixes
72
+ .map(s => s.children?.methodInvocationSuffix?.[0])
73
+ .find(Boolean);
74
+ if (invocation) {
75
+ const fqn = prefix?.children?.fqnOrRefType?.[0];
76
+ const callee = fqn ? _flattenFqnToString(fqn) : 'unknown';
77
+ const args = (invocation.children?.argumentList?.[0]?.children?.expression || [])
78
+ .map(exprFromCst);
79
+ return { kind: 'call', callee, args };
80
+ }
81
+ return exprFromCst(prefix);
82
+ }
61
83
  // FQN ref
62
84
  if (node.children.fqnOrRefType) return _fqnExpr(node.children.fqnOrRefType[0]);
63
85
  if (node.children.unqualifiedClassInstanceCreationExpression) {
@@ -89,12 +111,37 @@ function exprFromCst(node) {
89
111
  return { kind: 'unknown' };
90
112
  }
91
113
 
114
+ /**
115
+ * The identifier images of an `fqnOrRefType`, in order.
116
+ *
117
+ * java-parser does NOT put `Identifier` directly on `fqnOrRefType`. The real
118
+ * shape is fqnOrRefTypePartFirst / fqnOrRefTypePartRest → fqnOrRefTypePartCommon
119
+ * → Identifier, so reading `children.Identifier` returned nothing for every
120
+ * real-world expression and both callers below degraded to 'unknown'. The
121
+ * direct-Identifier form is still accepted as a fallback.
122
+ */
123
+ function _fqnIdents(node) {
124
+ if (!node || !node.children) return [];
125
+ const direct = node.children.Identifier;
126
+ if (Array.isArray(direct) && direct.length) return direct.map(t => t.image);
127
+ const out = [];
128
+ const parts = [
129
+ ...(node.children.fqnOrRefTypePartFirst || []),
130
+ ...(node.children.fqnOrRefTypePartRest || []),
131
+ ];
132
+ for (const p of parts) {
133
+ const common = p.children?.fqnOrRefTypePartCommon?.[0];
134
+ const id = common?.children?.Identifier?.[0]?.image;
135
+ if (id) out.push(id);
136
+ }
137
+ return out;
138
+ }
139
+
92
140
  function _fqnExpr(node) {
93
- // java-parser fqnOrRefType has children { Identifier: [...] } sometimes
94
- // and Dot tokens between them.
95
141
  if (!node || !node.children) return { kind: 'unknown' };
96
- const ids = node.children.Identifier;
97
- if (!ids || !ids.length) return { kind: 'unknown' };
142
+ const idImages = _fqnIdents(node);
143
+ if (!idImages.length) return { kind: 'unknown' };
144
+ const ids = idImages.map(image => ({ image }));
98
145
  let cur = { kind: 'ident', name: ids[0].image };
99
146
  for (let i = 1; i < ids.length; i++) {
100
147
  cur = { kind: 'member', object: cur, prop: ids[i].image };
@@ -118,10 +165,28 @@ function _methodInvocation(node) {
118
165
  }
119
166
 
120
167
  function _flattenFqnToString(node) {
121
- if (!node || !node.children) return 'unknown';
122
- const ids = node.children.Identifier;
123
- if (!ids) return 'unknown';
124
- return ids.map(t => t.image).join('.');
168
+ const ids = _fqnIdents(node);
169
+ return ids.length ? ids.join('.') : 'unknown';
170
+ }
171
+
172
+ // Descend to the first leaf token in a CST subtree and return its
173
+ // startLine. java-parser (chevrotain) leaf tokens carry startLine directly;
174
+ // rule nodes only carry `children`. Returns 0 when nothing is found (e.g.
175
+ // an empty node), which callers already treat as "unknown".
176
+ function _lineOf(node) {
177
+ if (!node) return 0;
178
+ if (Array.isArray(node)) {
179
+ for (const n of node) { const l = _lineOf(n); if (l) return l; }
180
+ return 0;
181
+ }
182
+ if (typeof node.startLine === 'number') return node.startLine;
183
+ if (node.children) {
184
+ for (const k of Object.keys(node.children)) {
185
+ const l = _lineOf(node.children[k]);
186
+ if (l) return l;
187
+ }
188
+ }
189
+ return 0;
125
190
  }
126
191
 
127
192
  /**
@@ -132,7 +197,7 @@ function _flattenFqnToString(node) {
132
197
  * `if` / `loop-header` node and the body falls through linearly. This is
133
198
  * coarser than the JS frontend; v2 will branch the succ array.
134
199
  */
135
- function buildCfgFromBody(bodyNode, line) {
200
+ function buildCfgFromBody(bodyNode) {
136
201
  const nodes = {};
137
202
  const entry = nextNodeId();
138
203
  const exit = nextNodeId();
@@ -157,6 +222,14 @@ function buildCfgFromBody(bodyNode, line) {
157
222
  function walkStmts(stmtNode) {
158
223
  if (!stmtNode || !stmtNode.children) return;
159
224
  const kids = stmtNode.children;
225
+ // java-parser nests a `block` as block → blockStatements (PLURAL, an
226
+ // intermediate rule node) → blockStatement. Walking only `blockStatement`
227
+ // from the block node therefore descended into nothing and every method
228
+ // body lowered to an empty CFG — which is why Java had IR functions with
229
+ // entry/exit and no statements, and taint could never fire.
230
+ if (kids.blockStatements) {
231
+ for (const bss of kids.blockStatements) walkStmts(bss);
232
+ }
160
233
  // Block statement children
161
234
  if (kids.blockStatement) {
162
235
  for (const bs of kids.blockStatement) walkStmts(bs);
@@ -170,7 +243,7 @@ function buildCfgFromBody(bodyNode, line) {
170
243
  const target = d.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image;
171
244
  const initExpr = d.children?.variableInitializer?.[0]?.children?.expression?.[0];
172
245
  if (target) {
173
- emit({ kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line: line || 0, succ: [] });
246
+ emit({ kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line: _lineOf(lv), succ: [] });
174
247
  }
175
248
  }
176
249
  }
@@ -186,36 +259,190 @@ function buildCfgFromBody(bodyNode, line) {
186
259
  const e = kids.expressionStatement[0]?.children?.statementExpression?.[0]?.children?.expression?.[0];
187
260
  if (e) {
188
261
  const expr = exprFromCst(e);
189
- if (expr.kind === 'call') emit({ ...expr, line: line || 0, succ: [] });
262
+ if (expr.kind === 'call') emit({ ...expr, line: _lineOf(kids.expressionStatement[0]), succ: [] });
190
263
  else if (expr.kind === 'binary' && expr.op === '=') {
191
264
  // assignment expr `x = y;`
192
- emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line: line || 0, succ: [] });
265
+ emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line: _lineOf(kids.expressionStatement[0]), succ: [] });
193
266
  }
194
267
  }
195
268
  }
196
269
  if (kids.returnStatement) {
197
270
  const r = kids.returnStatement[0];
198
271
  const expr = r.children?.expression?.[0];
199
- emit({ kind: 'return', value: expr ? exprFromCst(expr) : null, line: line || 0, succ: [] });
272
+ emit({ kind: 'return', value: expr ? exprFromCst(expr) : null, line: _lineOf(r), succ: [] });
200
273
  }
201
274
  if (kids.throwStatement) {
202
275
  const t = kids.throwStatement[0];
203
276
  const expr = t.children?.expression?.[0];
204
- emit({ kind: 'throw', value: expr ? exprFromCst(expr) : null, line: line || 0, succ: [] });
277
+ emit({ kind: 'throw', value: expr ? exprFromCst(expr) : null, line: _lineOf(t), succ: [] });
205
278
  }
206
279
  if (kids.ifStatement) {
207
280
  const i = kids.ifStatement[0];
208
281
  const cond = i.children?.expression?.[0];
209
- emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: line || 0, succ: [] });
282
+ emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: _lineOf(i), succ: [] });
210
283
  // Then branch body falls through linearly; v1 simplification.
211
284
  for (const sub of (i.children?.statement || [])) walkStmts(sub);
212
285
  }
213
286
  if (kids.whileStatement) {
214
287
  const w = kids.whileStatement[0];
215
288
  const cond = w.children?.expression?.[0];
216
- emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: line || 0, succ: [] });
289
+ emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: _lineOf(w), succ: [] });
217
290
  for (const sub of (w.children?.statement || [])) walkStmts(sub);
218
291
  }
292
+ if (kids.forStatement) {
293
+ const f = kids.forStatement[0];
294
+ // Both basicForStatement (`for(init;test;step)`) and
295
+ // enhancedForStatement (`for(T x : xs)`) wrap a `statement` child for
296
+ // the loop body — same shape whileStatement already walks. Confirmed
297
+ // via direct java-parser CST inspection (java-parser@3.0.1): each
298
+ // forStatement's `children` has exactly one of these two keys, never
299
+ // both, and each carries `statement` directly.
300
+ const basic = f.children?.basicForStatement?.[0];
301
+ const enhanced = f.children?.enhancedForStatement?.[0];
302
+ const inner = basic || enhanced;
303
+ const cond = basic?.children?.expression?.[0];
304
+ emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: _lineOf(f), succ: [] });
305
+ // R8 Task 1 fix round 1: `for (T x : xs)` never bound `x` to `xs` —
306
+ // the loop body was reachable (the fix above) but the loop variable
307
+ // itself carried no taint provenance, so a genuinely tainted
308
+ // collection reaching a sink through the loop variable still
309
+ // couldn't fire. Confirmed via direct CST inspection:
310
+ // enhancedForStatement.children = {..., localVariableDeclaration,
311
+ // Colon, expression, ..., statement} — `localVariableDeclaration` is
312
+ // the loop var's declaration (same variableDeclaratorList shape used
313
+ // elsewhere in this file) and `expression` is the iterated
314
+ // collection. Mirrors the established pattern in parser-js.js's
315
+ // ForOfStatement handling (PRD R13(b)): synthesize
316
+ // `{kind:'assign', target: loopVar, source: iterExpr}` BEFORE
317
+ // recursing into the body, so any body statement reading the loop
318
+ // variable sees its taint provenance already established. Java's
319
+ // for-each always declares a fresh block-scoped variable (there is
320
+ // no bare-assignment form the way JS's `for (x of xs)` has), so —
321
+ // unlike parser-js.js — there is no pre-existing outer variable of
322
+ // the same name to protect with a post-loop kill; the Java CFG
323
+ // builder does not model block scoping elsewhere either, so adding
324
+ // one only for this binding would be new, inconsistent behavior
325
+ // rather than a fix for this gap.
326
+ if (enhanced) {
327
+ const lvd = enhanced.children?.localVariableDeclaration?.[0];
328
+ const declarator = lvd?.children?.variableDeclaratorList?.[0]?.children?.variableDeclarator?.[0];
329
+ const loopVar = declarator?.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image;
330
+ const iterExpr = enhanced.children?.expression?.[0];
331
+ if (loopVar) {
332
+ emit({ kind: 'assign', target: loopVar, source: iterExpr ? exprFromCst(iterExpr) : { kind: 'unknown' }, line: _lineOf(enhanced), succ: [] });
333
+ }
334
+ }
335
+ for (const sub of (inner?.children?.statement || [])) walkStmts(sub);
336
+ }
337
+ if (kids.doStatement) {
338
+ const d = kids.doStatement[0];
339
+ const cond = d.children?.expression?.[0];
340
+ emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: _lineOf(d), succ: [] });
341
+ for (const sub of (d.children?.statement || [])) walkStmts(sub);
342
+ }
343
+ if (kids.tryStatement) {
344
+ const t = kids.tryStatement[0];
345
+ emit({ kind: 'noop', line: _lineOf(t), succ: [] });
346
+ // Plain `try { ... }` has direct `block`/`catches`/`finally` children.
347
+ // Try-with-resources (`try (Resource r = ...) { ... }` — the idiomatic
348
+ // JDBC shape) wraps in a distinct `tryWithResourcesStatement`
349
+ // intermediate node, and — confirmed via direct CST inspection, this
350
+ // is the one place the plan's illustrative code was wrong — that
351
+ // intermediate node carries its OWN `block`/`catches`/`finally`
352
+ // children; a plain tryStatement's outer `children` is just
353
+ // `{ tryWithResourcesStatement: [...] }` with nothing else, so
354
+ // reading `catches`/`finally` off the outer `t` (as the plan's draft
355
+ // did) silently drops the catch/finally bodies of every
356
+ // try-with-resources block. `container` below picks the node that
357
+ // actually owns them.
358
+ const twr = t.children?.tryWithResourcesStatement?.[0];
359
+ const container = twr || t;
360
+ if (twr) {
361
+ const resSpec = twr.children?.resourceSpecification?.[0];
362
+ // resourceSpecification -> resourceList -> resource[] -> each a
363
+ // localVariableDeclaration-shaped resource; reuse the existing
364
+ // localVariableDeclarationStatement lowering shape directly rather
365
+ // than duplicating it, by walking each resource as if it were one.
366
+ const resources = resSpec?.children?.resourceList?.[0]?.children?.resource || [];
367
+ for (const r of resources) {
368
+ const vdecl = r.children?.localVariableDeclaration?.[0] || r;
369
+ const declarators = vdecl?.children?.variableDeclaratorList?.[0]?.children?.variableDeclarator || [];
370
+ for (const d of declarators) {
371
+ const target = d.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image;
372
+ const initExpr = d.children?.variableInitializer?.[0]?.children?.expression?.[0] || d.children?.expression?.[0];
373
+ if (target) emit({ kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line: _lineOf(r), succ: [] });
374
+ }
375
+ }
376
+ }
377
+ const bodyBlock = container.children?.block?.[0];
378
+ if (bodyBlock) walkStmts(bodyBlock);
379
+ const catches = container.children?.catches?.[0]?.children?.catchClause || [];
380
+ for (const cc of catches) {
381
+ const cblock = cc.children?.block?.[0];
382
+ if (cblock) walkStmts(cblock);
383
+ }
384
+ // Confirmed via direct CST inspection: the key is `finally`, no
385
+ // trailing underscore, in both the plain and try-with-resources
386
+ // shapes (java-parser@3.0.1).
387
+ const fin = container.children?.finally?.[0];
388
+ const finBlock = fin?.children?.block?.[0];
389
+ if (finBlock) walkStmts(finBlock);
390
+ }
391
+ if (kids.switchStatement) {
392
+ const sw = kids.switchStatement[0];
393
+ const cond = sw.children?.expression?.[0];
394
+ emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: _lineOf(sw), succ: [] });
395
+ const swBlock = sw.children?.switchBlock?.[0];
396
+ // Classic colon-form (`case 1: ...; break;`).
397
+ const groups = swBlock?.children?.switchBlockStatementGroup || [];
398
+ for (const g of groups) {
399
+ const bss = g.children?.blockStatements?.[0];
400
+ if (bss) walkStmts(bss);
401
+ }
402
+ // R8 Task 1 fix round 1: arrow-form (`case 1 -> ...;`, Java 14+) is a
403
+ // structurally distinct grammar rule — confirmed via direct CST
404
+ // inspection (java-parser@3.0.1) — not an alternate reading of
405
+ // switchBlockStatementGroup. A switchBlock's children carry EITHER
406
+ // switchBlockStatementGroup (colon-form) OR switchRule (arrow-form),
407
+ // never both (Java's grammar forbids mixing them in one switch
408
+ // block), so this is a separate, independent branch rather than a
409
+ // fallback path. Each switchRule carries exactly one body-shape key:
410
+ // `expression` (`case 1 -> sink(x);`), `block`
411
+ // (`case 1 -> { ...; }`), or `throwStatement`
412
+ // (`case 1 -> throw new X();`). Before this fix arrow-form switches
413
+ // fell through this whole branch producing zero walked statements —
414
+ // confirmed 0 IR-TAINT findings for the idiomatic modern-Java shape.
415
+ const rules = swBlock?.children?.switchRule || [];
416
+ for (const r of rules) {
417
+ const rblock = r.children?.block?.[0];
418
+ if (rblock) { walkStmts(rblock); continue; }
419
+ const rthrow = r.children?.throwStatement?.[0];
420
+ if (rthrow) {
421
+ const texpr = rthrow.children?.expression?.[0];
422
+ emit({ kind: 'throw', value: texpr ? exprFromCst(texpr) : null, line: _lineOf(rthrow), succ: [] });
423
+ continue;
424
+ }
425
+ const rexpr = r.children?.expression?.[0];
426
+ if (rexpr) {
427
+ const expr = exprFromCst(rexpr);
428
+ if (expr.kind === 'call') emit({ ...expr, line: _lineOf(r), succ: [] });
429
+ else if (expr.kind === 'binary' && expr.op === '=') {
430
+ emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line: _lineOf(r), succ: [] });
431
+ }
432
+ }
433
+ }
434
+ }
435
+ // Bare nested block `{ ... }` with no leading keyword. The
436
+ // `statementWithoutTrailingSubstatement` branch above already recurses
437
+ // into each such node via walkStmts; confirmed via direct CST
438
+ // inspection that a bare block lands as
439
+ // `statementWithoutTrailingSubstatement[0].children.block[0]`, so this
440
+ // branch — which fires for any node with a `block` child, not just
441
+ // this one — picks it up on the recursive call without a dedicated
442
+ // bare-block branch.
443
+ if (kids.block) {
444
+ for (const b of kids.block) walkStmts(b);
445
+ }
219
446
  }
220
447
 
221
448
  if (nodes[prev]) {
@@ -263,16 +490,69 @@ export async function parseJavaFile(file, raw) {
263
490
  const name = md.children?.methodHeader?.[0]?.children?.methodDeclarator?.[0]?.children?.Identifier?.[0]?.image
264
491
  || md.children?.methodDeclarator?.[0]?.children?.Identifier?.[0]?.image
265
492
  || 'anonymous';
266
- const params = []; // params extraction deferred
493
+ // Parameter list + annotations live in the same CST subtree, so
494
+ // both are extracted in one walk (PRD R14(a) Task 5). Mirrors the
495
+ // dual-path fallback pattern used above for `name`: depending on
496
+ // which `k` matched in the outer loop, the params live nested
497
+ // under a `methodHeader` node OR directly under `methodDeclarator`.
498
+ const fpl = md.children?.methodHeader?.[0]?.children?.methodDeclarator?.[0]?.children?.formalParameterList?.[0]
499
+ || md.children?.methodDeclarator?.[0]?.children?.formalParameterList?.[0];
500
+ const paramAnnotations = [];
501
+ const params = (fpl?.children?.formalParameter || []).map((fp, idx) => {
502
+ // Regular parameters nest under `variableParaRegularParameter`.
503
+ // Varargs (`String... args`) instead use a distinct
504
+ // `variableArityParameter` node that this walk doesn't extract
505
+ // from — `vp` is undefined, so this degrades gracefully to a
506
+ // dropped (not corrupted/crashed) parameter, matching the
507
+ // codebase's existing "degrade gracefully rather than throw"
508
+ // convention for shapes that aren't cleanly extractable. Array
509
+ // types (`String[] args`) are unaffected — the `[]` lives in
510
+ // `unannType`, not the identifier, so `variableDeclaratorId`
511
+ // still yields a clean name.
512
+ const vp = fp.children?.variableParaRegularParameter?.[0];
513
+ const paramName = vp?.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image || null;
514
+ // Java allows multiple stacked annotations on one parameter
515
+ // (`@NotNull @RequestParam String q`), each its own
516
+ // `variableModifier` entry — loop over all of them, not just
517
+ // the first, or a stacked annotation is silently dropped (the
518
+ // same class of bug Task 3's C# regex needed a fix round for).
519
+ const variableModifiers = vp?.children?.variableModifier || [];
520
+ for (const vm of variableModifiers) {
521
+ const ann = vm.children?.annotation?.[0];
522
+ // `typeName.children.Identifier` is an array of ALL
523
+ // dot-separated segments for a fully-qualified annotation
524
+ // (`@org.springframework.web.bind.annotation.RequestParam`
525
+ // yields ['org','springframework','web','bind','annotation',
526
+ // 'RequestParam']) — the simple annotation name is the LAST
527
+ // segment, not the first. Taking [0] previously recorded the
528
+ // package root ("org") as the decorator name, a silent wrong
529
+ // value rather than the honest drop the C#/JS sibling parsers
530
+ // produce for the same shape (fix round 1, R14(a) Task 5).
531
+ const identifiers = ann?.children?.typeName?.[0]?.children?.Identifier;
532
+ const decoratorName = identifiers?.length ? identifiers[identifiers.length - 1]?.image : undefined;
533
+ if (decoratorName && paramName) {
534
+ paramAnnotations.push({ index: idx, name: paramName, decorator: decoratorName });
535
+ }
536
+ }
537
+ return paramName;
538
+ }).filter(Boolean);
267
539
  const body = md.children?.methodBody?.[0]?.children?.block?.[0];
268
540
  if (body) {
541
+ const methodLine = _lineOf(md);
542
+ const cfg = buildCfgFromBody(body);
269
543
  functions.push({
270
- qid: `${file}::${className || 'class'}::${name}`,
544
+ // Sibling frontends (parser-js.js) suffix the qid with `@line`
545
+ // so overloaded/same-named methods don't collide — without it,
546
+ // callgraph.js's `functions.set(fn.qid, fn)` silently drops
547
+ // every overload but the last one declared.
548
+ qid: `${file}::${className || 'class'}::${name}@${methodLine}`,
271
549
  name: className ? `${className}.${name}` : name,
272
- line: 0,
550
+ line: methodLine,
273
551
  params,
274
- cfg: buildCfgFromBody(body, 0),
552
+ cfg,
275
553
  file,
554
+ calls: callSitesFromCfg(cfg),
555
+ ...(paramAnnotations.length ? { paramAnnotations } : {}),
276
556
  });
277
557
  }
278
558
  }