@clear-capabilities/agentic-security-scanner 0.172.1 → 0.173.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 (194) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/agentic-security.js +37 -0
  3. package/dist/119.index.js +197 -0
  4. package/dist/128.index.js +129 -0
  5. package/dist/1293.index.js +5689 -0
  6. package/dist/1351.index.js +133 -0
  7. package/dist/1387.index.js +271 -0
  8. package/dist/1437.index.js +23 -0
  9. package/dist/1445.index.js +110 -0
  10. package/dist/1505.index.js +151 -0
  11. package/dist/1557.index.js +293 -0
  12. package/dist/1621.index.js +311 -0
  13. package/dist/1751.index.js +160 -0
  14. package/dist/1765.index.js +727 -0
  15. package/dist/1861.index.js +690 -0
  16. package/dist/1898.index.js +116 -0
  17. package/dist/1906.index.js +433 -0
  18. package/dist/1920.index.js +1 -291
  19. package/dist/208.index.js +1631 -0
  20. package/dist/2199.index.js +0 -239
  21. package/dist/2223.index.js +159 -0
  22. package/dist/2530.index.js +152 -0
  23. package/dist/2565.index.js +116 -0
  24. package/dist/2574.index.js +127 -0
  25. package/dist/2620.index.js +345 -0
  26. package/dist/2773.index.js +98 -0
  27. package/dist/2775.index.js +32 -0
  28. package/dist/2966.index.js +171 -0
  29. package/dist/2972.index.js +87 -0
  30. package/dist/2991.index.js +131 -0
  31. package/dist/3063.index.js +833 -0
  32. package/dist/3075.index.js +227 -0
  33. package/dist/3121.index.js +297 -0
  34. package/dist/3314.index.js +263 -0
  35. package/dist/3480.index.js +1822 -0
  36. package/dist/3481.index.js +24 -0
  37. package/dist/3506.index.js +268 -0
  38. package/dist/3533.index.js +591 -0
  39. package/dist/3653.index.js +450 -0
  40. package/dist/3822.index.js +131 -0
  41. package/dist/3868.index.js +292 -0
  42. package/dist/3917.index.js +152 -0
  43. package/dist/3918.index.js +192 -0
  44. package/dist/4090.index.js +288 -0
  45. package/dist/4121.index.js +1190 -0
  46. package/dist/4142.index.js +0 -239
  47. package/dist/4336.index.js +436 -0
  48. package/dist/4387.index.js +532 -0
  49. package/dist/4413.index.js +302 -0
  50. package/dist/4476.index.js +4510 -0
  51. package/dist/4491.index.js +133 -0
  52. package/dist/4538.index.js +185 -0
  53. package/dist/4684.index.js +319 -0
  54. package/dist/4740.index.js +165 -0
  55. package/dist/4780.index.js +209 -0
  56. package/dist/4802.index.js +225 -0
  57. package/dist/4812.index.js +379 -0
  58. package/dist/4835.index.js +126 -0
  59. package/dist/494.index.js +226 -0
  60. package/dist/4991.index.js +549 -0
  61. package/dist/5199.index.js +305 -0
  62. package/dist/5216.index.js +109 -0
  63. package/dist/5439.index.js +301 -0
  64. package/dist/5443.index.js +97 -0
  65. package/dist/5496.index.js +377 -0
  66. package/dist/550.index.js +2 -1
  67. package/dist/5801.index.js +588 -0
  68. package/dist/5858.index.js +223 -0
  69. package/dist/5933.index.js +143 -0
  70. package/dist/601.index.js +23 -0
  71. package/dist/6173.index.js +273 -0
  72. package/dist/6291.index.js +1871 -0
  73. package/dist/6312.index.js +666 -0
  74. package/dist/634.index.js +16182 -0
  75. package/dist/644.index.js +87 -0
  76. package/dist/6508.index.js +283 -0
  77. package/dist/6560.index.js +645 -0
  78. package/dist/6576.index.js +422 -0
  79. package/dist/6579.index.js +271 -0
  80. package/dist/6745.index.js +305 -0
  81. package/dist/6818.index.js +21642 -0
  82. package/dist/6972.index.js +167 -0
  83. package/dist/6995.index.js +163 -0
  84. package/dist/7249.index.js +191 -0
  85. package/dist/7372.index.js +1197 -0
  86. package/dist/7546.index.js +541 -0
  87. package/dist/76.index.js +448 -0
  88. package/dist/7879.index.js +163 -0
  89. package/dist/8025.index.js +130 -0
  90. package/dist/8054.index.js +248 -0
  91. package/dist/8097.index.js +201 -0
  92. package/dist/8126.index.js +191 -0
  93. package/dist/8178.index.js +78 -0
  94. package/dist/818.index.js +212 -0
  95. package/dist/8382.index.js +93 -0
  96. package/dist/8408.index.js +261 -0
  97. package/dist/8540.index.js +174 -0
  98. package/dist/8635.index.js +802 -0
  99. package/dist/8688.index.js +410 -0
  100. package/dist/8699.index.js +779 -0
  101. package/dist/8891.index.js +457 -0
  102. package/dist/8916.index.js +885 -0
  103. package/dist/9146.index.js +170 -0
  104. package/dist/9168.index.js +841 -0
  105. package/dist/9292.index.js +369 -0
  106. package/dist/9382.index.js +135 -11
  107. package/dist/9422.index.js +866 -0
  108. package/dist/9440.index.js +453 -0
  109. package/dist/9479.index.js +152 -0
  110. package/dist/9481.index.js +220 -0
  111. package/dist/9539.index.js +100 -0
  112. package/dist/9547.index.js +103 -0
  113. package/dist/9560.index.js +41 -61
  114. package/dist/9692.index.js +90 -0
  115. package/dist/9800.index.js +213 -1
  116. package/dist/9899.index.js +138 -0
  117. package/dist/9981.index.js +206 -0
  118. package/dist/999.index.js +117 -0
  119. package/dist/agentic-security.mjs +27 -27
  120. package/dist/agentic-security.mjs.sha256 +1 -1
  121. package/package.json +28 -3
  122. package/src/context-intelligence/candidates.js +2 -1
  123. package/src/context-intelligence/capabilities.js +2 -1
  124. package/src/context-intelligence/cli.js +2 -0
  125. package/src/context-intelligence/config.js +3 -1
  126. package/src/context-intelligence/controls.js +49 -1
  127. package/src/context-intelligence/counterexample.js +200 -0
  128. package/src/context-intelligence/cross-route.js +111 -0
  129. package/src/context-intelligence/findings.js +166 -0
  130. package/src/context-intelligence/invariants.js +197 -0
  131. package/src/context-intelligence/queue.js +6 -1
  132. package/src/context-intelligence/reachability.js +2 -2
  133. package/src/context-intelligence/review.js +68 -4
  134. package/src/dataflow/access-paths.js +11 -0
  135. package/src/dataflow/analysis-limits.js +66 -0
  136. package/src/dataflow/engine.js +272 -20
  137. package/src/dataflow/service-edges.js +174 -0
  138. package/src/dataflow/summaries.js +7 -1
  139. package/src/discovery/judge.js +2 -1
  140. package/src/engine.js +82 -14
  141. package/src/fix/apply-fix-service.js +55 -8
  142. package/src/fix/batch-journal.js +84 -0
  143. package/src/fix/exploit-replay.js +51 -0
  144. package/src/fix/footprint.js +65 -0
  145. package/src/fix/patch-policy.js +49 -0
  146. package/src/fix/secret-persistence.js +39 -0
  147. package/src/fix/test-commands.js +84 -0
  148. package/src/fix/verification-adapters.js +97 -0
  149. package/src/fix/verification-record.js +243 -0
  150. package/src/fix/verify-patch.js +89 -0
  151. package/src/ir/CLAUDE.md +5 -0
  152. package/src/ir/callgraph.js +23 -1
  153. package/src/ir/module-resolver.js +315 -0
  154. package/src/ir/parser-js.js +21 -2
  155. package/src/ir/semantic-graph.js +245 -0
  156. package/src/lineage/build-attested-correlation.js +63 -0
  157. package/src/lineage/deployment-identity.js +76 -0
  158. package/src/mcp/tools.js +13 -34
  159. package/src/pipeline/canonical-output.js +64 -0
  160. package/src/pipeline/detector-runner.js +7 -0
  161. package/src/pipeline/scan-health.js +14 -0
  162. package/src/pipeline/supervised-analyzers.js +125 -0
  163. package/src/pipeline/supervised-child.js +17 -0
  164. package/src/pipeline/telemetry.js +79 -0
  165. package/src/posture/accuracy-scorecard.js +98 -9
  166. package/src/posture/artifact-registry.js +5 -0
  167. package/src/posture/assurance/evidence-registry.js +215 -0
  168. package/src/posture/assurance/release-package-verify.js +135 -0
  169. package/src/posture/assurance/release-package.js +118 -0
  170. package/src/posture/assurance/release-verdict.js +94 -0
  171. package/src/posture/assurance/waiver.js +200 -0
  172. package/src/posture/compliance-truth.js +100 -0
  173. package/src/posture/evaluation/scan-child.js +3 -1
  174. package/src/posture/evaluation/why-missed.js +3 -1
  175. package/src/posture/exploitability-evidence.js +141 -0
  176. package/src/posture/fix-verify.js +4 -2
  177. package/src/posture/fleet/exposure-ranking.js +115 -0
  178. package/src/posture/fleet/service-chain-graph.js +150 -0
  179. package/src/posture/graph-reconciliation.js +154 -0
  180. package/src/posture/provenance/coverage.js +103 -0
  181. package/src/posture/provenance/git-evidence.js +84 -0
  182. package/src/posture/provenance/origin-resolver.js +7 -1
  183. package/src/posture/provenance/predicate-replay.js +14 -4
  184. package/src/posture/runtime-correlation.js +61 -15
  185. package/src/posture/runtime-explanation.js +116 -0
  186. package/src/posture/runtime-trace-ingest.js +194 -0
  187. package/src/posture/scan-checkpoint.js +38 -10
  188. package/src/posture/waf-ingest.js +7 -2
  189. package/src/report/index.js +4 -0
  190. package/src/report/oscal.js +38 -0
  191. package/src/report/perspectives.js +150 -0
  192. package/src/sast/_comment-strip.js +8 -1
  193. package/src/sast/ruby.js +26 -0
  194. package/src/util/string-memo.js +38 -0
@@ -0,0 +1,89 @@
1
+ // The one verifier every patch route calls (E3-03).
2
+ //
3
+ // Routes used to call verifyFix themselves, with different inputs (only verify_fix looked up the exploit PoC; one branch swapped the
4
+ // verifier when an env flag was set), and each shaped the result its own way. verifyPatch runs verifyFix ONCE with one set of inputs and
5
+ // returns the raw verdict beside the canonical patch-level record, so identical patch and settings give the same record id on every route.
6
+ //
7
+ // Route and mode are NOT part of the record id (see FIX_RECORD_ID_FIELDS): they say who asked, not what was established.
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import { verifyFix } from '../posture/fix-verify.js';
11
+ import { verifyLastScan } from '../posture/integrity.js';
12
+ import { stateDir } from '../posture/state-dir.js';
13
+ import { recordFromVerifyFix } from './verification-adapters.js';
14
+ import { replayExploit } from './exploit-replay.js';
15
+ import { checkFootprint } from './footprint.js';
16
+ import { checkSecretPersistence } from './secret-persistence.js';
17
+ import { runTestsOnPatchedCopy } from './test-commands.js';
18
+ import { legacyViewOf } from './verification-record.js';
19
+
20
+ const REQUIRED_LEGS = Object.freeze(['securityAfter', 'newFindingDelta', 'behaviorPreservation', 'behaviorTests']);
21
+
22
+ function readOriginal(scanRoot) {
23
+ return (rel) => { try { return fs.readFileSync(path.join(scanRoot, rel), 'utf8'); } catch { return null; } };
24
+ }
25
+
26
+ /** The scan, only when its signature verifies: a stored PoC is code that will be run, so an unsigned scan supplies none. */
27
+ function readSignedScan(scanRoot) {
28
+ const file = path.join(stateDir(scanRoot), 'last-scan.json');
29
+ const body = fs.readFileSync(file, 'utf8');
30
+ return verifyLastScan(body, `${file}.sig`) === true ? JSON.parse(body) : null;
31
+ }
32
+
33
+ function lookupFinding(scanRoot, stableId, readScan) {
34
+ try {
35
+ const scan = (readScan || readSignedScan)(scanRoot);
36
+ return (scan?.findings || []).find((f) => f.stableId === stableId) || null;
37
+ } catch { return null; }
38
+ }
39
+
40
+ /**
41
+ * @param {{scanRoot:string, finding:{id?:string,stableId:string}, files:Record<string,string>, route:string, mode?:'preview'|'verify'|'apply',
42
+ * fixMeta?:object, poc?:object|null, readScan?:(root:string)=>object|null, verify?:Function, extra?:object}} o
43
+ * @returns {Promise<{verdict:object, fixVerification:object, canonical:object}>}
44
+ */
45
+ export async function verifyPatch(o) {
46
+ const { scanRoot, finding, files, route, mode = 'verify', fixMeta, readScan, extra } = o;
47
+ const run = o.verify || verifyFix;
48
+ const stored = lookupFinding(scanRoot, finding.stableId, readScan);
49
+ // One PoC rule for every route: the stored one for this finding, unless the caller supplied its own.
50
+ const poc = o.poc !== undefined ? o.poc : (stored?.poc?.code ? { ...stored.poc, finding: stored } : null);
51
+ // An explicit local reproducer turns the replay on for every route alike; without one the leg is not_run and says so.
52
+ const replay = o.reproducer ? await replayExploit({ scanRoot, reproducer: o.reproducer, files, attempts: o.replayAttempts, timeoutMs: o.replayTimeoutMs, attemptFn: o.attemptFn }) : null;
53
+ // The tests run on a copy WITH the patch applied. A suite that was already red says nothing about the patch, so it is reported as such.
54
+ const runTests = () => {
55
+ const t = runTestsOnPatchedCopy({ scanRoot, files, command: o.testCommand || fixMeta?.testCommand, timeoutMs: o.testTimeoutMs });
56
+ return t.preExistingFailure ? { ...t, status: 'skipped', passed: null, skipped: true, reason: 'suite-already-failing-before-the-patch' } : t;
57
+ };
58
+ const read = readOriginal(scanRoot);
59
+ const footprint = checkFootprint({ before: Object.fromEntries(Object.keys(files).map((r) => [r, read(r)])), after: files, cwe: stored?.cwe || o.cwe || null, intentionalRemoval: fixMeta?.intentionalRemoval || null });
60
+ // A secret finding is only fixed when the secret is gone from the source, not when it was re-encoded.
61
+ const isSecret = /CWE-(798|259|321|312)/i.test(String(stored?.cwe || o.cwe || ''));
62
+ const persistence = isSecret && stored?.file && files[stored.file] !== undefined ? checkSecretPersistence({ before: read(stored.file), after: files[stored.file], line: stored.line }) : null;
63
+ let verdict = await run({ scanRoot, originalFindingStableId: finding.stableId, files, fixMeta, runTests, ...(poc ? { poc } : {}), ...(extra || {}) });
64
+ // The finding disappearing is not enough: a patch that removed the feature is rejected here, for every route at once.
65
+ if (persistence?.status === 'failed' && verdict.ok) verdict = { ...verdict, ok: false, rejectedBy: 'secret-persists', summary: `${persistence.reason}\n${verdict.summary || ''}`.trim() };
66
+ if (footprint.status === 'failed' && verdict.ok) verdict = { ...verdict, ok: false, rejectedBy: 'behavior-preservation', summary: `${footprint.reason}\n${verdict.summary || ''}`.trim() };
67
+ const fixVerification = recordFromVerifyFix(verdict, {
68
+ finding: { id: finding.id ?? stored?.id ?? finding.stableId, stableId: finding.stableId },
69
+ files, route, mode, findingPresent: !!(stored || finding.present), preFiles: readOriginal(scanRoot),
70
+ requiredLegs: replay ? [...REQUIRED_LEGS, 'exploitProofAfter'] : REQUIRED_LEGS, replay, footprint, persistence,
71
+ });
72
+ const canonical = legacyViewOf(fixVerification);
73
+ // The record is the authority: a failed leg (a surviving exploit, tests that fail on the patched tree) rejects the patch on every route.
74
+ if (!canonical.ok && verdict.ok) {
75
+ const failed = ['securityAfter', 'exploitProofAfter', 'newFindingDelta', 'behaviorTests', 'behaviorPreservation', 'lint'].filter((n) => fixVerification[n].status === 'failed');
76
+ verdict = { ...verdict, ok: false, rejectedBy: verdict.rejectedBy || `leg:${failed[0] || 'failed'}`, summary: `${failed.map((n) => `${n}: ${fixVerification[n].reason}`).join('; ') || 'a verification leg failed'}\n${verdict.summary || ''}`.trim() };
77
+ }
78
+ return { verdict, fixVerification, canonical, replay, footprint, persistence };
79
+ }
80
+
81
+ /** The additive fields every route puts on its result. Existing fields are untouched. */
82
+ export function additiveFields(fixVerification) {
83
+ return {
84
+ fixVerification,
85
+ fixCompleteness: fixVerification.fixCompleteness,
86
+ verifiedFull: fixVerification.verifiedFull,
87
+ degradedLegs: fixVerification.degradedLegs,
88
+ };
89
+ }
package/src/ir/CLAUDE.md CHANGED
@@ -266,3 +266,8 @@ down enterprise runners sometimes don't. Targets for retirement:
266
266
  every fallback to the regex parser is now also a silent loss of Python
267
267
  interprocedural analysis, not just the older, vaguer "some constructs get
268
268
  dropped" cost.
269
+
270
+ ## Semantic graph (`semantic-graph.js`, engineering-excellence E2-01)
271
+
272
+ An additive, typed, versioned view over this IR: `Symbol`, `CallSite`, `Allocation`, `FieldAccess`, `ValueOrigin`, plus the schema-only lists `ServiceBoundary`, `StorageEdge`, `Sanitizer`, `Guard`. Ids are content-derived and never include a line, so a blank line above a function moves no id. It reads the CFG (the one structure every language's IR shares; `writes` exists for JavaScript only). Nothing on the scan path imports it, so findings and `chain[]` cannot change; a test pins that. Columns are `null` (the IR has lines only) and say `locationPrecision: "line"`. Contract and the measured per-language construct matrix: `docs/engineering-excellence/IR_CONTRACT.md` (regenerate with `node scripts/engineering-excellence/ir-matrix.mjs`). Do not add a `site` field: IR site ids come from a process-global counter and are not stable across builds.
273
+
@@ -28,6 +28,8 @@ export function functionRecord(callGraph, resolved) {
28
28
  return fns.get(resolved) || null;
29
29
  }
30
30
 
31
+ import { buildModuleIndex } from './module-resolver.js';
32
+
31
33
  export function buildCallGraph(perFileIR, fileContents) {
32
34
  const functions = new Map();
33
35
  const byNameInFile = new Map();
@@ -186,11 +188,26 @@ export function buildCallGraph(perFileIR, fileContents) {
186
188
  return null;
187
189
  }
188
190
 
191
+ // E2-02: import-aware resolution. A call whose head identifier is an import binding is resolved by following the program's own
192
+ // import/export statements (exact, bounded, cycle-safe). It never throws: a failure to build the index leaves the older name-based
193
+ // resolution exactly as it was.
194
+ let moduleIndex = null;
195
+ if (fileContents) { try { moduleIndex = buildModuleIndex(fileContents, perFileIR); } catch { moduleIndex = null; } }
196
+ const viaImport = (callerFile, name) => {
197
+ if (!moduleIndex || !callerFile) return null;
198
+ try { return moduleIndex.resolveCall(callerFile, name); } catch { return null; }
199
+ };
200
+
189
201
  // Resolve each call site.
190
202
  const edges = []; // { caller, site, callee, ambiguous? }
191
203
  for (const fn of functions.values()) {
192
204
  for (const c of (fn.calls || [])) {
193
205
  if (!c.callee) { edges.push({ caller: fn.qid, site: c.site, callee: null, line: c.line }); continue; }
206
+ // 0. Import binding (E2-02): explicit evidence beats every name-based rule below. A binding to an external package or builtin resolves
207
+ // to nothing, so a same-named function elsewhere in the project can no longer be taken for it.
208
+ const imp = viaImport(fn.file, c.callee);
209
+ if (imp && imp.status === 'resolved') { edges.push({ caller: fn.qid, site: c.site, callee: imp.qid, calleeName: c.callee, line: c.line, via: 'import', hops: imp.hops }); continue; }
210
+ if (imp && imp.status === 'external') { edges.push({ caller: fn.qid, site: c.site, callee: null, calleeName: c.callee, line: c.line, external: true }); continue; }
194
211
  // 1. Direct name in same file
195
212
  const sameFileMap = byNameInFile.get(fn.file);
196
213
  const resolved = sameFileMap?.get(c.callee) ||
@@ -284,6 +301,11 @@ export function buildCallGraph(perFileIR, fileContents) {
284
301
  // because the guard lived only in that one file's helper instead of here).
285
302
  function _resolveImpl(name, callerFile, allowTailGuess) {
286
303
  if (!name || typeof name !== 'string') return null;
304
+ {
305
+ const imp = viaImport(callerFile, name);
306
+ if (imp && imp.status === 'resolved') return imp.qid;
307
+ if (imp && imp.status === 'external') return null;
308
+ }
287
309
  // Roadmap #3: same-file preference. A bare name (`handler`, `save`,
288
310
  // `query`) defined in several files would otherwise resolve to whichever
289
311
  // file Map is iterated first, mis-targeting interprocedural taint to a
@@ -363,5 +385,5 @@ export function buildCallGraph(perFileIR, fileContents) {
363
385
  function resolveKnownCallee(name, callerFile) {
364
386
  return _resolveImpl(name, callerFile, false);
365
387
  }
366
- return { functions, edges, callersOf, resolve, resolveKnownCallee };
388
+ return { functions, edges, callersOf, resolve, resolveKnownCallee, moduleIndex };
367
389
  }
@@ -0,0 +1,315 @@
1
+ // Import-aware module resolution (E2-02): which function does `svc.run(x)` in routes.js actually call?
2
+ //
3
+ // The call graph used to resolve a cross-file call by NAME alone: any function called `run` anywhere in the project could be the callee,
4
+ // and a member call through an imported module (`const svc = require('./service'); svc.run(x)`) was not resolved at all because `svc.run`
5
+ // is not a function name. That was a false negative for the second shape and a false-edge risk for the first. This resolver follows the
6
+ // program's own import/export statements instead.
7
+ //
8
+ // WHAT IT UNDERSTANDS (JavaScript and TypeScript, parsed with Babel; Python by line patterns)
9
+ // imports `import {a as b} from`, default, namespace, `import x = require()`, `const x = require()`, `const {a: b} = require()`,
10
+ // `const f = require('m').f`; Python `from m import a as b`, `import m as n`, `import a.b`
11
+ // exports `export function/const/class`, `export {a as b}`, `export {a} from`, `export * from`, `export default`, `exports.a =`,
12
+ // `module.exports.a =`, `module.exports = {a, b: fn}`, `module.exports = fn`, `module.exports = require('./x')`
13
+ // paths relative specifiers with extension and index-file probing, TypeScript's `./x.js` -> `./x.ts`, Python dotted modules and
14
+ // relative `from . import`
15
+ //
16
+ // WHAT IT DOES NOT, SAID OUT LOUD (each shows up as a status, never as a guess)
17
+ // dynamic `require(expr)` / `import()`, path aliases (`@/x`, `~/x`, tsconfig `paths`), conditional exports, `export * as ns`, call-through
18
+ // property chains deeper than one level (`a.b.c()`), an export that is not a function, shadowing of an imported name by a local.
19
+ //
20
+ // TERMINATION. Re-export chains are followed to a depth of MAX_HOPS with a visited set, so a cycle (a re-exports b re-exports a) ends as
21
+ // `cycle` and a long chain ends as `depth-limit`. The result always carries the hops it took.
22
+ import { parseSync } from '@babel/core';
23
+ import presetReact from '@babel/preset-react';
24
+ import presetTypescript from '@babel/preset-typescript';
25
+
26
+ export const MAX_HOPS = 8;
27
+ const MAX_FILE_BYTES = 1_500_000;
28
+ const BUILTINS = new Set(['assert', 'buffer', 'child_process', 'cluster', 'crypto', 'dgram', 'dns', 'events', 'fs', 'http', 'http2', 'https', 'net', 'os', 'path', 'perf_hooks', 'process', 'querystring', 'readline', 'stream', 'string_decoder', 'timers', 'tls', 'url', 'util', 'vm', 'worker_threads', 'zlib']);
29
+ const JS_EXT = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts'];
30
+
31
+ const posix = (p) => String(p).replace(/\\/g, '/');
32
+ function normalize(p) {
33
+ const out = [];
34
+ for (const seg of posix(p).split('/')) { if (seg === '' || seg === '.') continue; if (seg === '..') { if (!out.length) return null; out.pop(); } else out.push(seg); }
35
+ return out.join('/');
36
+ }
37
+ const dirOf = (f) => { const i = posix(f).lastIndexOf('/'); return i < 0 ? '' : posix(f).slice(0, i); };
38
+
39
+ // ---- JavaScript / TypeScript extraction -------------------------------------------------------------------------------------
40
+
41
+ function parseJs(file, code) {
42
+ try {
43
+ return parseSync(code, { filename: file, presets: [presetReact, [presetTypescript, { ignoreExtensions: true }]], parserOpts: { plugins: ['decorators-legacy', 'decoratorAutoAccessors'] }, babelrc: false, configFile: false });
44
+ } catch { return null; }
45
+ }
46
+ const litString = (n) => (n && (n.type === 'StringLiteral' || (n.type === 'Literal' && typeof n.value === 'string')) ? n.value : (n && n.type === 'TemplateLiteral' && !n.expressions.length && n.quasis.length === 1 ? n.quasis[0].value.cooked : null));
47
+ const keyName = (k) => (k.type === 'Identifier' ? k.name : litString(k));
48
+ const isRequire = (n) => n && n.type === 'CallExpression' && n.callee.type === 'Identifier' && n.callee.name === 'require' && n.arguments.length === 1;
49
+ const isFnNode = (n) => n && (n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression' || n.type === 'FunctionDeclaration');
50
+
51
+ export function extractJsFacts(file, code) {
52
+ const facts = { language: 'js', imports: [], exports: [], unsupported: [] };
53
+ const ast = parseJs(file, code);
54
+ if (!ast) { facts.unsupported.push('parse-failed'); return facts; }
55
+ const addImport = (local, kind, source, imported = null) => facts.imports.push({ local, kind, source, imported });
56
+ const addExport = (e) => facts.exports.push(e);
57
+ for (const st of ast.program.body) {
58
+ switch (st.type) {
59
+ case 'ImportDeclaration':
60
+ for (const sp of st.specifiers) {
61
+ if (sp.type === 'ImportSpecifier') addImport(sp.local.name, 'named', st.source.value, sp.imported.type === 'Identifier' ? sp.imported.name : sp.imported.value);
62
+ else if (sp.type === 'ImportDefaultSpecifier') addImport(sp.local.name, 'default', st.source.value);
63
+ else if (sp.type === 'ImportNamespaceSpecifier') addImport(sp.local.name, 'namespace', st.source.value);
64
+ }
65
+ break;
66
+ case 'TSImportEqualsDeclaration':
67
+ if (st.moduleReference?.type === 'TSExternalModuleReference') addImport(st.id.name, 'namespace', litString(st.moduleReference.expression));
68
+ break;
69
+ case 'VariableDeclaration':
70
+ for (const d of st.declarations) {
71
+ const init = d.init;
72
+ if (isRequire(init)) {
73
+ const src = litString(init.arguments[0]);
74
+ if (src === null) { facts.unsupported.push('dynamic-require'); continue; }
75
+ if (d.id.type === 'Identifier') addImport(d.id.name, 'namespace', src);
76
+ else if (d.id.type === 'ObjectPattern') for (const p of d.id.properties) {
77
+ if (p.type !== 'ObjectProperty') { facts.unsupported.push('rest-in-require-pattern'); continue; }
78
+ const v = p.value.type === 'AssignmentPattern' ? p.value.left : p.value;
79
+ if (v.type === 'Identifier') addImport(v.name, 'named', src, keyName(p.key));
80
+ }
81
+ } else if (init && init.type === 'MemberExpression' && isRequire(init.object) && d.id.type === 'Identifier' && !init.computed) {
82
+ const src = litString(init.object.arguments[0]);
83
+ if (src !== null) addImport(d.id.name, 'named', src, init.property.name);
84
+ } else if (init && init.type === 'CallExpression' && init.callee.type === 'CallExpression' && isRequire(init.callee)) facts.unsupported.push('call-of-require-result');
85
+ if (isFnNode(init) && st.parent === undefined && d.id.type === 'Identifier') { /* local function value: found through the IR by name */ }
86
+ }
87
+ break;
88
+ case 'ExportNamedDeclaration':
89
+ if (st.declaration) {
90
+ const dec = st.declaration;
91
+ if (dec.type === 'FunctionDeclaration' || dec.type === 'ClassDeclaration') addExport({ name: dec.id.name, local: dec.id.name, kind: dec.type === 'FunctionDeclaration' ? 'function' : 'value' });
92
+ else if (dec.type === 'VariableDeclaration') for (const d of dec.declarations) if (d.id.type === 'Identifier') addExport({ name: d.id.name, local: d.id.name, kind: isFnNode(d.init) ? 'function' : 'value' });
93
+ }
94
+ for (const sp of st.specifiers || []) {
95
+ if (sp.type !== 'ExportSpecifier') { facts.unsupported.push('export-namespace-specifier'); continue; }
96
+ const exported = sp.exported.type === 'Identifier' ? sp.exported.name : sp.exported.value;
97
+ if (st.source) addExport({ name: exported, kind: 'reexport', from: st.source.value, imported: sp.local.name });
98
+ else addExport({ name: exported, local: sp.local.name, kind: 'local' });
99
+ }
100
+ break;
101
+ case 'ExportAllDeclaration':
102
+ addExport({ name: '*', kind: 'star', from: st.source.value });
103
+ break;
104
+ case 'ExportDefaultDeclaration': {
105
+ const d = st.declaration;
106
+ if ((d.type === 'FunctionDeclaration' || d.type === 'ClassDeclaration') && d.id) addExport({ name: 'default', local: d.id.name, kind: 'local' });
107
+ else if (d.type === 'Identifier') addExport({ name: 'default', local: d.name, kind: 'local' });
108
+ else if (isFnNode(d)) addExport({ name: 'default', local: null, kind: 'anonymous-function' });
109
+ else addExport({ name: 'default', local: null, kind: 'value' });
110
+ break;
111
+ }
112
+ case 'ExpressionStatement': {
113
+ const e = st.expression;
114
+ if (e.type === 'AssignmentExpression' && e.operator === '=' && e.left.type === 'MemberExpression') {
115
+ const L = e.left;
116
+ const objIsExports = L.object.type === 'Identifier' && L.object.name === 'exports';
117
+ const objIsModuleExports = L.object.type === 'MemberExpression' && L.object.object.type === 'Identifier' && L.object.object.name === 'module' && L.object.property.name === 'exports';
118
+ const isModuleExportsItself = L.object.type === 'Identifier' && L.object.name === 'module' && !L.computed && L.property.name === 'exports';
119
+ if ((objIsExports || objIsModuleExports) && !L.computed) {
120
+ const r = e.right;
121
+ addExport({ name: L.property.name, local: r.type === 'Identifier' ? r.name : null, kind: isFnNode(r) ? 'function' : r.type === 'Identifier' ? 'local' : 'value' });
122
+ } else if (isModuleExportsItself) {
123
+ const r = e.right;
124
+ if (r.type === 'ObjectExpression') for (const p of r.properties) {
125
+ if (p.type === 'ObjectProperty' || p.type === 'ObjectMethod') { const n = keyName(p.key); if (n) addExport({ name: n, local: p.type === 'ObjectProperty' && p.value.type === 'Identifier' ? p.value.name : null, kind: p.type === 'ObjectMethod' || (p.type === 'ObjectProperty' && isFnNode(p.value)) ? 'function' : p.type === 'ObjectProperty' && p.value.type === 'Identifier' ? 'local' : 'value' }); }
126
+ else facts.unsupported.push('spread-in-module-exports');
127
+ }
128
+ else if (isRequire(r)) { const src = litString(r.arguments[0]); if (src !== null) addExport({ name: '*', kind: 'star', from: src, whole: true }); else facts.unsupported.push('dynamic-require'); }
129
+ else if (r.type === 'Identifier') addExport({ name: 'default', local: r.name, kind: 'local' });
130
+ else if (isFnNode(r)) addExport({ name: 'default', local: r.id?.name ?? null, kind: r.id ? 'local' : 'anonymous-function' });
131
+ else addExport({ name: 'default', local: null, kind: 'value' });
132
+ }
133
+ } else if (e.type === 'CallExpression' && e.callee.type === 'Import') facts.unsupported.push('dynamic-import');
134
+ break;
135
+ }
136
+ default: break;
137
+ }
138
+ }
139
+ return facts;
140
+ }
141
+
142
+ // ---- Python extraction ------------------------------------------------------------------------------------------------------
143
+
144
+ export function extractPyFacts(file, code) {
145
+ const facts = { language: 'py', imports: [], exports: [], unsupported: [] };
146
+ for (const raw of String(code).split('\n')) {
147
+ const line = raw.replace(/#.*$/, '');
148
+ let m;
149
+ if ((m = /^from\s+(\.+)?([\w.]*)\s+import\s+(.+)$/.exec(line))) {
150
+ const source = `${m[1] || ''}${m[2] || ''}`;
151
+ const list = m[3].replace(/[()\\]/g, '').split(',').map((s) => s.trim()).filter(Boolean);
152
+ for (const item of list) { const mm = /^(\w+|\*)(?:\s+as\s+(\w+))?$/.exec(item); if (!mm) continue; if (mm[1] === '*') facts.unsupported.push('star-import'); else facts.imports.push({ local: mm[2] || mm[1], kind: 'py-from', source, imported: mm[1] }); }
153
+ } else if ((m = /^import\s+(.+)$/.exec(line))) {
154
+ for (const item of m[1].split(',').map((s) => s.trim()).filter(Boolean)) {
155
+ const mm = /^([\w.]+)(?:\s+as\s+(\w+))?$/.exec(item); if (!mm) continue;
156
+ facts.imports.push(mm[2] ? { local: mm[2], kind: 'py-module', source: mm[1] } : { local: mm[1].split('.')[0], kind: 'py-module-root', source: mm[1] });
157
+ }
158
+ } else if ((m = /^(?:async\s+)?def\s+(\w+)\s*\(/.exec(line))) facts.exports.push({ name: m[1], local: m[1], kind: 'function' });
159
+ }
160
+ return facts;
161
+ }
162
+
163
+ // ---- index ------------------------------------------------------------------------------------------------------------------
164
+
165
+ /**
166
+ * @param {Record<string,string>} fileContents repository-relative path -> source
167
+ * @param {Record<string,{functions:object[]}>} perFile the IR's perFile map
168
+ */
169
+ export function buildModuleIndex(fileContents, perFile) {
170
+ const files = new Set(Object.keys(fileContents || {}).map(posix));
171
+ const facts = new Map();
172
+ for (const [f, code] of Object.entries(fileContents || {})) {
173
+ if (typeof code !== 'string' || code.length > MAX_FILE_BYTES) continue;
174
+ const file = posix(f);
175
+ if (/\.(js|jsx|ts|tsx|mjs|cjs|mts|cts)$/.test(file)) { if (/\b(import|export|require|module\.exports|exports\.)/.test(code)) facts.set(file, extractJsFacts(file, code)); }
176
+ else if (/\.py$/.test(file)) facts.set(file, extractPyFacts(file, code));
177
+ }
178
+ const fnsByFile = new Map();
179
+ for (const [f, ir] of Object.entries(perFile || {})) fnsByFile.set(posix(f), (ir?.functions || []).filter((fn) => fn.name !== '<module>'));
180
+
181
+ function resolveSpecifier(from, spec, language) {
182
+ if (typeof spec !== 'string' || !spec) return { kind: 'unresolved', reason: 'empty specifier' };
183
+ if (language === 'py') {
184
+ const rel = /^(\.+)(.*)$/.exec(spec);
185
+ let baseDir; let dotted;
186
+ if (rel) { baseDir = dirOf(from); for (let i = 1; i < rel[1].length; i++) baseDir = dirOf(baseDir); dotted = rel[2]; } else { baseDir = ''; dotted = spec; }
187
+ const rest = dotted ? dotted.split('.').join('/') : '';
188
+ const stem = normalize(baseDir ? (rest ? `${baseDir}/${rest}` : baseDir) : rest);
189
+ if (stem === null) return { kind: 'unresolved', reason: 'path leaves the repository' };
190
+ for (const c of [`${stem}.py`, `${stem}/__init__.py`]) if (stem !== '' && files.has(c)) return { kind: 'file', file: c };
191
+ if (stem === '' && files.has('__init__.py')) return { kind: 'file', file: '__init__.py' };
192
+ return rel ? { kind: 'unresolved', reason: `relative module ${spec} not found` } : { kind: 'external', reason: 'not a module of this repository' };
193
+ }
194
+ if (spec.startsWith('node:') || BUILTINS.has(spec.split('/')[0])) return { kind: 'external', reason: 'node builtin' };
195
+ if (spec.startsWith('.') || spec.startsWith('/')) {
196
+ const stem = normalize(spec.startsWith('/') ? spec.slice(1) : `${dirOf(from)}/${spec}`);
197
+ if (stem === null) return { kind: 'unresolved', reason: 'path leaves the repository' };
198
+ const noExt = stem.replace(/\.(m|c)?js$/, '');
199
+ const cands = [stem, ...JS_EXT.map((e) => stem + e), noExt + '.ts', noExt + '.tsx', noExt + '.mts', noExt + '.cts', ...JS_EXT.map((e) => `${stem}/index${e}`)];
200
+ for (const c of cands) if (files.has(c)) return { kind: 'file', file: c };
201
+ return { kind: 'unresolved', reason: `${spec} not found in the repository` };
202
+ }
203
+ if (/^(@\/|~\/|#|\$)/.test(spec)) return { kind: 'unresolved', reason: 'path alias: tsconfig paths and bundler aliases are not read' };
204
+ return { kind: 'external', reason: 'package import' };
205
+ }
206
+
207
+ function functionIn(file, local) {
208
+ const hits = (fnsByFile.get(file) || []).filter((fn) => fn.name === local || String(fn.name).endsWith(`.${local}`));
209
+ if (hits.length === 1) return { qid: hits[0].qid };
210
+ return hits.length ? { ambiguous: hits.map((h) => h.qid) } : null;
211
+ }
212
+
213
+ // follow an export of `file` named `name` to the function that implements it
214
+ function resolveExport(file, name, hops, visited) {
215
+ const key = `${file}#${name}`;
216
+ if (visited.has(key)) return { status: 'cycle', hops, reason: `re-export cycle at ${key}` };
217
+ if (hops.length >= MAX_HOPS) return { status: 'depth-limit', hops, reason: `more than ${MAX_HOPS} re-export hops` };
218
+ visited.add(key);
219
+ const f = facts.get(file);
220
+ if (!f) return { status: 'unresolved-export', hops, reason: `${file} has no import/export statements` };
221
+ const hop = (extra) => [...hops, { file, name, ...extra }];
222
+ const direct = f.exports.filter((e) => e.name === name && e.kind !== 'star');
223
+ if (direct.length) {
224
+ const e = direct[direct.length - 1];
225
+ if (e.kind === 'reexport') {
226
+ const spec = resolveSpecifier(file, e.from, f.language);
227
+ if (spec.kind === 'file') return resolveExport(spec.file, e.imported, hop({ via: 're-export', from: e.from }), visited);
228
+ return { status: spec.kind === 'external' ? 'external' : 'unresolved-import', hops: hop({ via: 're-export', from: e.from }), reason: spec.reason };
229
+ }
230
+ if (e.kind === 'anonymous-function') {
231
+ const cands = (fnsByFile.get(file) || []).filter((fn) => /^(default|anonymous|<anonymous>)/i.test(fn.name));
232
+ return cands.length === 1 ? { status: 'resolved', qid: cands[0].qid, file, exportName: name, hops: hop({}) } : { status: 'unresolved-export', hops: hop({}), reason: 'anonymous default export function not identifiable in the IR' };
233
+ }
234
+ if (e.kind === 'value') {
235
+ const byName = functionIn(file, name);
236
+ if (byName?.qid) return { status: 'resolved', qid: byName.qid, file, exportName: name, hops: hop({}) };
237
+ return { status: 'unresolved-export', hops: hop({}), reason: `${name} is exported but is not a function the IR knows` };
238
+ }
239
+ const local = e.local || name;
240
+ const fn = functionIn(file, local) || (local !== name ? functionIn(file, name) : null);
241
+ if (fn?.qid) return { status: 'resolved', qid: fn.qid, file, exportName: name, hops: hop({}) };
242
+ if (fn?.ambiguous) return { status: 'ambiguous', hops: hop({}), reason: `several functions named ${local} in ${file}`, candidates: fn.ambiguous };
243
+ // an imported binding re-exported by name (`import {a} from './x'; export {a}`)
244
+ const imp = f.imports.find((i) => i.local === local);
245
+ if (imp) return resolveBinding(file, imp, hop({ via: 'import-then-export' }), visited);
246
+ return { status: 'unresolved-export', hops: hop({}), reason: `${local} is exported by ${file} but no function of that name was found` };
247
+ }
248
+ // `export * from` / `module.exports = require()`: search each star target; more than one hit is ambiguous
249
+ const stars = f.exports.filter((e) => e.kind === 'star');
250
+ const found = [];
251
+ for (const s of stars) {
252
+ const spec = resolveSpecifier(file, s.from, f.language);
253
+ if (spec.kind !== 'file') continue;
254
+ const r = resolveExport(spec.file, name, hop({ via: s.whole ? 'module.exports = require' : 'export *', from: s.from }), new Set(visited));
255
+ if (r.status === 'resolved') found.push(r); else if (r.status === 'cycle' || r.status === 'depth-limit') return r;
256
+ }
257
+ if (found.length === 1) return found[0];
258
+ if (found.length > 1) return { status: 'ambiguous', hops: hop({}), reason: `${name} is provided by ${found.length} star re-exports`, candidates: found.map((r) => r.qid) };
259
+ return { status: 'unresolved-export', hops: hop({}), reason: `${file} does not export ${name}` };
260
+ }
261
+
262
+ function resolveBinding(callerFile, imp, hops, visited) {
263
+ const f = facts.get(callerFile);
264
+ const spec = resolveSpecifier(callerFile, imp.source, f?.language);
265
+ if (spec.kind === 'external') return { status: 'external', hops, reason: spec.reason, source: imp.source };
266
+ if (spec.kind === 'unresolved') return { status: 'unresolved-import', hops, reason: spec.reason, source: imp.source };
267
+ const name = imp.kind === 'default' ? 'default' : imp.kind === 'py-from' ? imp.imported : imp.imported;
268
+ if (name == null) return { status: 'module', file: spec.file, hops, source: imp.source };
269
+ return resolveExport(spec.file, name, [...hops, { file: callerFile, via: `import ${imp.source}` }], visited);
270
+ }
271
+
272
+ /** Resolve the callee text of a call made in `callerFile`. Never throws; always terminates. */
273
+ function resolveCall(callerFile, calleeText) {
274
+ const caller = posix(callerFile);
275
+ const f = facts.get(caller);
276
+ if (!f || typeof calleeText !== 'string' || !calleeText) return { status: 'no-facts', hops: [] };
277
+ const parts = calleeText.split('.');
278
+ const head = parts[0];
279
+ const imp = f.imports.find((i) => i.local === head);
280
+ if (!imp) return { status: 'not-imported', hops: [] };
281
+ const visited = new Set();
282
+ if (parts.length === 1) {
283
+ if (imp.kind === 'namespace' || imp.kind === 'py-module' || imp.kind === 'py-module-root') {
284
+ // calling the module object itself: `const f = require('./f'); f(x)` -> the module's default / module.exports function
285
+ const spec = resolveSpecifier(caller, imp.source, f.language);
286
+ if (spec.kind !== 'file') return { status: spec.kind === 'external' ? 'external' : 'unresolved-import', hops: [], reason: spec.reason, source: imp.source };
287
+ return resolveExport(spec.file, 'default', [{ file: caller, via: `import ${imp.source}` }], visited);
288
+ }
289
+ return resolveBinding(caller, imp, [], visited);
290
+ }
291
+ if (parts.length > 2 && !(imp.kind === 'py-module-root')) return { status: 'unsupported', hops: [], reason: `${calleeText}: property chains deeper than one level are not resolved`, source: imp.source };
292
+ if (imp.kind === 'namespace' || imp.kind === 'py-module') {
293
+ const spec = resolveSpecifier(caller, imp.source, f.language);
294
+ if (spec.kind === 'external') return { status: 'external', hops: [], reason: spec.reason, source: imp.source };
295
+ if (spec.kind !== 'file') return { status: 'unresolved-import', hops: [], reason: spec.reason, source: imp.source };
296
+ return resolveExport(spec.file, parts[1], [{ file: caller, via: `import ${imp.source}` }], visited);
297
+ }
298
+ if (imp.kind === 'py-module-root') {
299
+ // `import a.b` binds `a`; `a.b.fn()` names the module a/b and the function fn
300
+ const dotted = parts.slice(0, -1).join('.');
301
+ if (!imp.source.startsWith(dotted)) return { status: 'unsupported', hops: [], reason: `${calleeText} does not match import ${imp.source}`, source: imp.source };
302
+ const spec = resolveSpecifier(caller, dotted, 'py');
303
+ if (spec.kind === 'external') return { status: 'external', hops: [], reason: spec.reason, source: imp.source };
304
+ if (spec.kind !== 'file') return { status: 'unresolved-import', hops: [], reason: spec.reason, source: imp.source };
305
+ return resolveExport(spec.file, parts[parts.length - 1], [{ file: caller, via: `import ${imp.source}` }], visited);
306
+ }
307
+ // `x.y()` where x is a named/default import: a method on an imported value, not a module member. If the import is an external
308
+ // package that is still the answer that matters (`axios.get` is the package's, not ours).
309
+ const specOther = resolveSpecifier(caller, imp.source, f.language);
310
+ if (specOther.kind === 'external') return { status: 'external', hops: [], reason: specOther.reason, source: imp.source };
311
+ return { status: 'unsupported', hops: [], reason: `${calleeText}: member of an imported value, not of a module`, source: imp.source };
312
+ }
313
+
314
+ return { facts, resolveSpecifier, resolveExport: (file, name) => resolveExport(posix(file), name, [], new Set()), resolveCall };
315
+ }
@@ -182,7 +182,13 @@ function exprOf(n) {
182
182
  return { key: resolveObjectKey(p), value: exprOf(p.value) };
183
183
  }).filter(Boolean),
184
184
  };
185
- case 'ArrayExpression': return { kind: 'array', elements: (n.elements || []).map(exprOf) };
185
+ case 'ArrayExpression': {
186
+ // `spreads` is additive and present only when an element is `...x`: the position of every later element is then unknown, which the
187
+ // engine needs in order to treat the literal index by index (E2-03). Consumers that ignore it see the same shape as before.
188
+ const spreads = [];
189
+ (n.elements || []).forEach((el, i) => { if (el && el.type === 'SpreadElement') spreads.push(i); });
190
+ return spreads.length ? { kind: 'array', elements: (n.elements || []).map(exprOf), spreads } : { kind: 'array', elements: (n.elements || []).map(exprOf) };
191
+ }
186
192
  case 'SpreadElement': return exprOf(n.argument);
187
193
  case 'ThisExpression': return { kind: 'ident', name: '_this_' };
188
194
  // Taint-recall PRD (80%) Tier 3: JSX had ZERO IR modeling at all — every
@@ -486,7 +492,20 @@ export function parseJsFile(file, code) {
486
492
  const parentName = stack[stack.length - 1]?.name || '';
487
493
  enterFn(name || 'anon', parentName, path.node, path.node.params || []);
488
494
  },
489
- exit() { exitFn(); },
495
+ exit(path) {
496
+ // An arrow with an EXPRESSION body (`(req) => req.query.c`) returns that expression. It has no ReturnStatement, so the function used to
497
+ // record no return at all and every helper written this way was invisible to return-taint summaries. The return node is added on exit so
498
+ // it follows any call nodes the expression contained.
499
+ const fn = currentFn();
500
+ const body = path.node.body;
501
+ if (fn && body && body.type !== 'BlockStatement') {
502
+ const nodeId = nextNodeId();
503
+ addNode(fn, { id: nodeId, kind: 'return', value: exprOf(body), line: body.loc?.start?.line || path.node.loc?.start?.line || 0, succ: [], pred: [] });
504
+ fn.returns.push(nodeId);
505
+ linkCfg(fn, nodeId, fn.cfg.exit);
506
+ }
507
+ exitFn();
508
+ },
490
509
  },
491
510
  ClassMethod: {
492
511
  enter(path) {