@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.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 (162) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +2 -2
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/{271.index.js → 2271.index.js} +2 -2
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +5 -5
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +5 -5
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +2 -2
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +3 -3
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +560 -189
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +12 -5
  72. package/src/dataflow/CLAUDE.md +1 -1
  73. package/src/dataflow/catalog.js +42 -0
  74. package/src/dataflow/orm-write-catalog.js +175 -0
  75. package/src/engine.js +92 -1
  76. package/src/ir/CLAUDE.md +1 -0
  77. package/src/ir/chrome-probe.mjs +150 -0
  78. package/src/ir/parser-js.js +94 -7
  79. package/src/lineage/CLAUDE.md +1203 -0
  80. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  81. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  82. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  83. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  84. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  85. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  86. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  87. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  88. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  89. package/src/lineage/classification.js +56 -0
  90. package/src/lineage/coverage.js +658 -0
  91. package/src/lineage/cross-repo-link.js +107 -0
  92. package/src/lineage/dataflow-graph.schema.json +184 -0
  93. package/src/lineage/decision-story.js +206 -0
  94. package/src/lineage/drift-policy.js +279 -0
  95. package/src/lineage/driver.js +135 -0
  96. package/src/lineage/engine.js +992 -0
  97. package/src/lineage/export-briefing.js +628 -0
  98. package/src/lineage/export-csv.js +62 -0
  99. package/src/lineage/export-json.js +238 -0
  100. package/src/lineage/export-privacy.js +258 -0
  101. package/src/lineage/federation-loader.js +111 -0
  102. package/src/lineage/field-identity.js +78 -0
  103. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  104. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  105. package/src/lineage/flow-grade.js +221 -0
  106. package/src/lineage/governance-edit.js +169 -0
  107. package/src/lineage/graph-builder.js +1114 -0
  108. package/src/lineage/graph-diff.js +431 -0
  109. package/src/lineage/graph-snapshot.js +180 -0
  110. package/src/lineage/handling-analyzer.js +168 -0
  111. package/src/lineage/ids.js +349 -0
  112. package/src/lineage/impact-assessment.js +76 -0
  113. package/src/lineage/impact-engine.js +268 -0
  114. package/src/lineage/index.js +281 -0
  115. package/src/lineage/language-coverage-tiers.js +58 -0
  116. package/src/lineage/obligation-mapping.js +126 -0
  117. package/src/lineage/obligation-predicates.js +235 -0
  118. package/src/lineage/observation-adapters.js +282 -0
  119. package/src/lineage/observation-correlation.js +622 -0
  120. package/src/lineage/observation-store.js +497 -0
  121. package/src/lineage/path-query.js +410 -0
  122. package/src/lineage/path-store.js +400 -0
  123. package/src/lineage/protection.js +53 -0
  124. package/src/lineage/recipient-profile.js +192 -0
  125. package/src/lineage/recipient-registry.js +394 -0
  126. package/src/lineage/redact-graph.js +224 -0
  127. package/src/lineage/remediation.js +417 -0
  128. package/src/lineage/resolve-destination.js +91 -0
  129. package/src/lineage/runtime-observation.js +464 -0
  130. package/src/lineage/scenario-diff.js +84 -0
  131. package/src/lineage/scenario-engine.js +251 -0
  132. package/src/lineage/scenario.js +101 -0
  133. package/src/lineage/schema.js +167 -0
  134. package/src/lineage/sink-registry.js +427 -0
  135. package/src/lineage/source-registry.js +357 -0
  136. package/src/lineage/source-seeding.js +212 -0
  137. package/src/lineage/summaries.js +590 -0
  138. package/src/lineage/transform-catalog.js +397 -0
  139. package/src/lineage/transit-protection.js +150 -0
  140. package/src/lineage/validate.js +285 -0
  141. package/src/mcp/CLAUDE.md +7 -1
  142. package/src/mcp/dataflow-tools.js +160 -0
  143. package/src/mcp/server.js +1 -1
  144. package/src/mcp/tools.js +2 -1
  145. package/src/pipeline/scan-health.js +19 -1
  146. package/src/posture/CLAUDE.md +19 -0
  147. package/src/posture/artifact-registry.js +52 -0
  148. package/src/posture/auditor-walkthrough.js +76 -0
  149. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  150. package/src/posture/obligation-evidence-pack.js +202 -0
  151. package/src/posture/remediation-ledger.js +337 -0
  152. package/src/server/CLAUDE.md +47 -0
  153. package/src/server/graph-loader.js +141 -0
  154. package/src/server/http-server.js +325 -0
  155. package/src/server/routes.js +129 -0
  156. package/src/server/security.js +111 -0
  157. package/src/server/static-assets.js +139 -0
  158. package/dist/11.index.js +0 -353
  159. package/dist/259.index.js +0 -975
  160. package/dist/317.index.js +0 -300
  161. package/dist/609.index.js +0 -741
  162. package/dist/838.index.js +0 -152
@@ -0,0 +1,590 @@
1
+ import { hashState, emptyState, addIdentity } from './field-identity.js';
2
+ import { resolveExprIdentities, residualFlat, analyzeFunctionFieldIdentity } from './engine.js';
3
+ import { accessPathOf } from '../dataflow/access-paths.js';
4
+ import { functionRecord } from '../ir/callgraph.js';
5
+
6
+ export function emptyFieldSummary() {
7
+ return { returnFlat: new Set(), returnByPath: new Map(), mutatedParams: new Map(), widenings: [] };
8
+ }
9
+
10
+ // Increment B6: the per-function distinct-context cap's operator-facing
11
+ // knob. `AGENTIC_SECURITY_LINEAGE_MAX_CONTEXTS` is DELIBERATELY a separate
12
+ // env var from dataflow/summaries.js's own `AGENTIC_SECURITY_KCFA_MAX_CONTEXTS`
13
+ // — per the isolation principle every prior increment in this sub-project
14
+ // has verified holds, the two engines' tuning knobs must stay decoupled,
15
+ // so an operator tuning one engine's cap can never silently affect the
16
+ // other's. Mirrors dataflow's own exact validation FORMULA
17
+ // (`Number.isFinite(...) && ... >= 0`, falling back to 16 on anything
18
+ // invalid or absent) — same reasoning, independently re-derived for this
19
+ // package rather than shared config.
20
+ //
21
+ // The formula match does NOT extend to what a cap of exactly `0` means,
22
+ // though — a final whole-branch review found and corrected an earlier,
23
+ // wrong claim of full equivalence here (see the regression test's own
24
+ // comment in summaries.test.js for the full trace). dataflow's own
25
+ // compute() exempts the empty-entry context from its cap entirely ("Empty
26
+ // entry is always allowed"), so a cap of 0 there is genuinely monovariant
27
+ // — the empty-entry pass still runs, over-cap contexts reuse it. THIS
28
+ // package's compute() has no such exemption (unchanged since B1): the
29
+ // empty-entry context counts against the cap like any other, so
30
+ // AGENTIC_SECURITY_LINEAGE_MAX_CONTEXTS=0 degrades EVERY context,
31
+ // including the empty one, to an empty summary with nothing real to fall
32
+ // back to — resolution goes fully off, not merely monovariant. Verified
33
+ // empirically through a real call-graph/parser scenario. Closing this gap
34
+ // (giving the empty-entry context the same exemption dataflow's own has)
35
+ // is a candidate for a later increment; nothing in B1-B6 commits to it.
36
+ //
37
+ // Evaluated as a FUNCTION (not a module-level constant) specifically so it
38
+ // is read fresh on every `new FieldIdentitySummaryCache()` call with no
39
+ // explicit constructor argument — JS default-parameter expressions are
40
+ // evaluated at CALL time, not at module-load time, which is what makes
41
+ // this testable via `process.env` mutation without needing to re-import
42
+ // the module between test cases.
43
+ function _defaultMaxContexts() {
44
+ const envCap = Number(process.env.AGENTIC_SECURITY_LINEAGE_MAX_CONTEXTS);
45
+ return Number.isFinite(envCap) && envCap >= 0 ? envCap : 16;
46
+ }
47
+
48
+ export class FieldIdentitySummaryCache {
49
+ constructor(maxContextsPerFn = _defaultMaxContexts()) {
50
+ this._cache = new Map();
51
+ this._stack = new Set();
52
+ this._contextsByQid = new Map();
53
+ this._maxContextsPerFn = maxContextsPerFn;
54
+ }
55
+
56
+ _key(qid, entryState) {
57
+ return `${qid}::${hashState(entryState)}`;
58
+ }
59
+
60
+ has(qid, entryState) {
61
+ return this._cache.has(this._key(qid, entryState));
62
+ }
63
+
64
+ get(qid, entryState) {
65
+ return this._cache.get(this._key(qid, entryState));
66
+ }
67
+
68
+ set(qid, entryState, summary) {
69
+ this._cache.set(this._key(qid, entryState), summary);
70
+ const hash = hashState(entryState);
71
+ const seen = this._contextsByQid.get(qid) ?? new Set();
72
+ seen.add(hash);
73
+ this._contextsByQid.set(qid, seen);
74
+ }
75
+
76
+ compute(qid, entryState, analyzeFn) {
77
+ // Ordering matches dataflow/summaries.js's own compute() exactly:
78
+ // cache-hit checked FIRST, before the `_stack`-based recursion guard.
79
+ // This is load-bearing, not incidental — B5's whole point is that a
80
+ // nested self/mutual-reference call, on a LATER refinement round, must
81
+ // see the PRIOR round's real (already-cached) summary instead of the
82
+ // bottom stub, so the summary can genuinely grow round over round (e.g.
83
+ // a self-recursive `function chain() { return {base: X, nested:
84
+ // chain()}}` picks up one more `nested:` layer per round). An earlier
85
+ // version of this method checked `_stack.has(qid)` BEFORE the cache-hit
86
+ // check specifically to keep the pre-existing B1 recursion test passing
87
+ // unmodified — but that ordering makes every nested self-call within
88
+ // the SAME compute() invocation hit the bottom-stub branch on every
89
+ // round (since `qid` never leaves `_stack` until the whole call
90
+ // returns), so round 2's self-call is byte-identical to round 1's, and
91
+ // `fieldSummaryEq` converges immediately without ever changing anything
92
+ // — refinement runs but is a structural no-op for genuine
93
+ // self-recursion, defeating the actual purpose of this increment.
94
+ // Verified empirically: with `_stack` checked first, a self-referencing
95
+ // `chain()`-shaped scenario stays stuck at `{'data:base'}` forever;
96
+ // with the cache-hit checked first (this ordering), it genuinely grows
97
+ // (`{'data:base', 'nested:data:base', 'nested:nested:data:base', ...}`)
98
+ // across rounds, bounded by FP_MAX. The pre-existing B1 test's own
99
+ // safety property (a nested self-call is NEVER unboundedly recursive,
100
+ // and — checked once, on its FIRST occurrence — gets a genuine bottom
101
+ // stub) still holds under this ordering; only the test's assertion
102
+ // needed updating to check the FIRST occurrence rather than every
103
+ // occurrence, since round 2+'s nested self-call now legitimately
104
+ // resolves to the real (still `_stack`-guarded from ever calling
105
+ // `innerFn` a second time — see below) prior-round summary. This is
106
+ // this increment's own coordinator-reviewed correction — see the
107
+ // Task 1 report's "Fix round" section for the full trace.
108
+ if (this.has(qid, entryState)) {
109
+ const cached = this.get(qid, entryState);
110
+ if (!cached._recursive) return cached;
111
+ }
112
+
113
+ const hash = hashState(entryState);
114
+ const seen = this._contextsByQid.get(qid) ?? new Set();
115
+ if (!seen.has(hash) && seen.size >= this._maxContextsPerFn) {
116
+ // Past this function's distinct-context cap: degrade to the
117
+ // empty-entry summary (if one exists) rather than computing an
118
+ // unbounded number of contexts. Mirrors
119
+ // dataflow/summaries.js's own graceful degradation past its own cap.
120
+ //
121
+ // Path provenance (Sub-project C, increment 3, §13.6/§13.7 item 11):
122
+ // mark the degradation with a PERMANENT, externally-visible
123
+ // `degradedReason` — unlike `_recursive` (a transient
124
+ // recursion-in-progress marker stripped before external use), a
125
+ // degraded summary stays degraded for the life of this cache entry,
126
+ // so this field is never stripped. Marked on a SHALLOW COPY, never
127
+ // on `base` in place: `base` is the exact object already cached for
128
+ // this qid's empty-entry context (Finding 2) — mutating it here
129
+ // would retroactively mark that PRECISE summary as degraded for
130
+ // every later reader of the empty-entry context too.
131
+ const base = this._cache.get(this._key(qid, emptyState())) ?? emptyFieldSummary();
132
+ const fallback = { ...base, degradedReason: 'context-cap' };
133
+ this.set(qid, entryState, fallback);
134
+ return fallback;
135
+ }
136
+
137
+ if (this._stack.has(qid)) {
138
+ // Recursion guard: return a bottom stub immediately to the NESTED
139
+ // caller, never recurse further — this part is unchanged from B1 and
140
+ // stays the safety mechanism (never infinite-loop on a recursive call
141
+ // graph). What B5 adds is downstream of this: this ALSO flags
142
+ // `_hitRecursion` on the cache instance (shared across the whole
143
+ // nested call chain, mirroring dataflow/summaries.js's own exact
144
+ // design) so that whichever OUTER compute() call is currently
145
+ // mid-analyzeFn() knows, once its own analyzeFn() returns, to
146
+ // refine its result via the bounded fixed-point loop below. This
147
+ // branch is only ever reached when the cache-hit check above found
148
+ // NOTHING cached yet for this exact qid+entryState — i.e. the FIRST
149
+ // time this qid is encountered while still on `_stack` (every LATER
150
+ // encounter, within the same or a later refinement round, hits the
151
+ // cache-hit branch above instead, once the first round has cached a
152
+ // real summary).
153
+ this._hitRecursion = true;
154
+ return { ...emptyFieldSummary(), _recursive: true };
155
+ }
156
+
157
+ this._stack.add(qid);
158
+ // Mirrors dataflow/summaries.js's own exact placement: reset AFTER
159
+ // pushing onto `_stack`, immediately before the analyzeFn() call this
160
+ // flag is scoped to. Nothing reads `_hitRecursion` between the push and
161
+ // this reset, so the two orderings are behaviorally identical here —
162
+ // reset-after-push is what the reference precedent actually does.
163
+ //
164
+ // Known, disclosed imprecision (a final whole-branch review found this
165
+ // via a real 2-function, non-recursive-then-recursive scenario through
166
+ // the real driver — see CLAUDE.md's B5 section): this flag is
167
+ // LAST-WRITER-WINS across the whole nested call chain, not scoped to
168
+ // "did MY OWN analyzeFn() hit recursion." If `analyzeFn`'s body first
169
+ // makes a self/mutual-recursive call (setting the flag true) and THEN
170
+ // makes an unrelated, cache-missing call to some other function, that
171
+ // second call's own `this._hitRecursion = false` (right here, for ITS
172
+ // OWN frame) does not touch the OUTER flag — but if that inner call
173
+ // itself hits a recursion or cache-miss chain, the flag can end up
174
+ // reset by the time control returns to this frame's own post-analyze
175
+ // check, silently skipping refinement this frame otherwise deserved.
176
+ // Sound either way (skipping refinement only under-approximates,
177
+ // never fabricates), but means refinement reliably fires only when
178
+ // the recursive self-call is the LAST uncached compute() an
179
+ // analyzeFn() body makes — not merely "somewhere in the body."
180
+ // Mirrors dataflow/summaries.js's own precedent exactly, so not a new
181
+ // risk this package introduces; inherit this knowledge if B6 touches
182
+ // this same flag.
183
+ this._hitRecursion = false;
184
+ try {
185
+ // A final whole-branch review found this method previously had no
186
+ // try/finally around analyzeFn — if it threw, qid stayed on _stack
187
+ // forever, and every LATER compute() call for that qid would fall
188
+ // into the recursion guard above and silently return a bottom stub,
189
+ // permanently. Not reachable at this increment's own hand-built-test
190
+ // scope, but live the moment a real driver (B4) runs
191
+ // analyzeFunctionFieldIdentity over real parsed IR — that function
192
+ // throws a plain TypeError on a malformed `fn`, and a sensible
193
+ // driver catching-and-continuing would otherwise silently spread
194
+ // "one function failed" into "this function under-reports for the
195
+ // rest of the run," the exact silent-under-approximation class the
196
+ // intraprocedural engine spent six rounds closing. Mirrors
197
+ // dataflow/summaries.js's own try/finally around the identical
198
+ // stack-push/pop pattern.
199
+ let summary = analyzeFn(entryState);
200
+ this.set(qid, entryState, summary);
201
+ // Increment B5: bounded fixed-point refinement. If a NESTED call for
202
+ // THIS SAME qid (still on `_stack` for the duration of this
203
+ // analyzeFn() call) hit the recursion guard above, the summary just
204
+ // computed treated that self/mutual reference as carrying zero
205
+ // identity (the bottom stub) — an honest but possibly permanent
206
+ // under-approximation if nothing ever revisits it. Re-invoking
207
+ // analyzeFn() now, AFTER this qid is already cached with a real
208
+ // (non-bottom-stub) summary, lets a nested self-call resolve against
209
+ // THAT cached value instead of the guard on the next round, so each
210
+ // round can only get more complete, never regress. Bounded by
211
+ // FP_MAX (mirrors dataflow's own `FP_MAX = 3`) as a hard safety cap,
212
+ // not a precision target — a function that never converges within
213
+ // the cap is left at whatever the last round produced, an honest,
214
+ // sound under-approximation. `fieldSummaryEq` (membership-based, see
215
+ // below) decides convergence: a round judged equal to the previous
216
+ // one is NOT cached (the cache still holds the prior — equal — round,
217
+ // which is correct), and the loop stops. Ordering (cache the round
218
+ // BEFORE the loop starts; compare BEFORE caching each subsequent
219
+ // round) mirrors dataflow/summaries.js's own compute() exactly.
220
+ if (this._hitRecursion) {
221
+ const FP_MAX = 3;
222
+ for (let fp = 0; fp < FP_MAX; fp++) {
223
+ const prev = summary;
224
+ summary = analyzeFn(entryState);
225
+ if (fieldSummaryEq(prev, summary)) break;
226
+ this.set(qid, entryState, summary);
227
+ }
228
+ }
229
+ // Defensive strip, mirroring dataflow's own equivalent: analyzeFn's
230
+ // real return shape here never actually carries `_recursive` under
231
+ // normal operation, but this guards against a future change to
232
+ // analyzeFn's callers accidentally leaking it through a cached,
233
+ // externally-visible summary.
234
+ if (summary._recursive) delete summary._recursive;
235
+ return summary;
236
+ } finally {
237
+ this._stack.delete(qid);
238
+ }
239
+ }
240
+
241
+ size() {
242
+ return this._cache.size;
243
+ }
244
+
245
+ clear() {
246
+ this._cache.clear();
247
+ this._stack.clear();
248
+ this._contextsByQid.clear();
249
+ }
250
+ }
251
+
252
+ // Compares two FieldSummary objects for VALUE equality — by membership,
253
+ // never by size alone. dataflow/summaries.js's own equivalent (_summaryEq)
254
+ // carries a documented, previously-shipped bug ("Stage 3 correctness
255
+ // audit," see that file's comment directly above _summaryEq): an earlier
256
+ // version compared mutatedParams by SIZE only, so two summaries with the
257
+ // same cardinality but different actual members were wrongly judged
258
+ // equal — the fixed-point loop then broke early WITHOUT caching the
259
+ // fresher, more-correct summary, silently serving a stale one to any
260
+ // LATER cache read. This function is written correctly from the start,
261
+ // citing that precedent as the reason, not discovered the same way twice.
262
+ //
263
+ // Deliberately does NOT compare `widenings` — mirrors dataflow's own
264
+ // `_summaryEq`, which also excludes its diagnostic-list equivalent
265
+ // (`findings`) from the equality check. Two summaries that agree on their
266
+ // actual FACTS (returnFlat, returnByPath, mutatedParams) but happen to
267
+ // carry a differently-ordered or differently-worded widening-reason list
268
+ // should still be treated as converged — the facts are what a caller
269
+ // actually consumes; the widening list is diagnostic.
270
+ //
271
+ // `degradedReason` (Sub-project C, increment 3, §13.6/§13.7 item 13) is
272
+ // excluded from this comparison for the SAME reason as `widenings` — it is
273
+ // diagnostic (why a summary is honestly incomplete), never a fact the
274
+ // analysis result itself depends on. This is stated here deliberately, not
275
+ // left as an accidental omission: `fieldSummaryEq`'s field-by-field
276
+ // comparison below never touched `degradedReason` to begin with (it isn't
277
+ // one of `returnFlat`/`mutatedParams`), so nothing had to change to keep
278
+ // this true — the comment exists so a future reader doesn't "fix" the
279
+ // omission by adding it.
280
+ //
281
+ // `returnByPath` is deliberately NOT compared either — it is currently
282
+ // always `new Map()` for every summary this cache ever stores, per B1's
283
+ // own disclosed, still-open limitation; comparing two always-empty Maps
284
+ // would be a no-op check, not a meaningful omission. If a future
285
+ // increment populates `returnByPath`, this function will need extending.
286
+ export function fieldSummaryEq(a, b) {
287
+ if (!a || !b) return a === b;
288
+ if (a.returnFlat.size !== b.returnFlat.size) return false;
289
+ for (const id of a.returnFlat) if (!b.returnFlat.has(id)) return false;
290
+ if (a.mutatedParams.size !== b.mutatedParams.size) return false;
291
+ for (const [path, ids] of a.mutatedParams) {
292
+ const bIds = b.mutatedParams.get(path);
293
+ if (!bIds || bIds.size !== ids.size) return false;
294
+ for (const id of ids) if (!bIds.has(id)) return false;
295
+ }
296
+ return true;
297
+ }
298
+
299
+ // Maps a call site's argument expressions onto a fresh entry state for the
300
+ // callee, keyed by the callee's own parameter names — the interprocedural
301
+ // analog of `engine.js`'s `assign` transfer function: each argument is
302
+ // resolved against the CALLER's current state via `resolveExprIdentities`,
303
+ // and its residual (root-level) identities plus its byPath (field-level)
304
+ // structure are both written into the callee's entry state at the
305
+ // corresponding parameter name, using the exact same residual+byPath split
306
+ // `assign` already uses — this is a direct, deliberate reuse of Sub-project
307
+ // A's already-hardened write pattern, not a new mechanism.
308
+ export function entryStateFromCall(paramNames, callArgs, callerState, ctx) {
309
+ // Path provenance (Sub-project C, increment 3, §13.2a). `ctx` is an
310
+ // OPTIONAL 4th parameter. THE SINGLE MOST IMPORTANT THING HERE: derive a
311
+ // RECORDER-ONLY ctx — never forward `ctx` itself to resolveExprIdentities.
312
+ // Forwarding the full ctx hands resolveExprIdentities a live
313
+ // `resolveCallSummary`, so a call argument that is itself a resolvable
314
+ // call (e.g. `sink(scrub(user))`) starts resolving interprocedurally
315
+ // where the shipped engine takes the unresolved fallback — changing the
316
+ // ANALYSIS RESULT with no recorder attached anywhere, in the unsound
317
+ // direction under a tight B6 context cap (the extra nested resolve
318
+ // consumes the callee's only context slot, so a later, unrelated call
319
+ // degrades to an empty summary and loses an identity the shipped engine
320
+ // keeps). This is exactly the hazard found and closed in
321
+ // DESIGN_PATH_PROVENANCE.md §13.2a's fix round; see
322
+ // engine-provenance-interprocedural.test.js's golden-baseline regression
323
+ // tests for the guard that pins this closed.
324
+ //
325
+ // Deriving this ONCE, here, inside entryStateFromCall itself (not at a
326
+ // call site) is load-bearing, not stylistic — it means no future second
327
+ // caller of entryStateFromCall can reintroduce the hazard by passing the
328
+ // full ctx through a different path.
329
+ const argCtx = ctx?.recordHop ? { recordHop: ctx.recordHop } : undefined;
330
+ let entryState = emptyState();
331
+ const n = Math.min(paramNames.length, callArgs.length);
332
+ for (let i = 0; i < n; i++) {
333
+ const paramName = paramNames[i];
334
+ const resolved = resolveExprIdentities(callerState, callArgs[i], argCtx);
335
+ const residual = residualFlat(resolved.flat, resolved.byPath);
336
+ for (const id of residual) entryState = addIdentity(entryState, paramName, id);
337
+ for (const [subPath, ids] of resolved.byPath) {
338
+ for (const id of ids) entryState = addIdentity(entryState, `${paramName}.${subPath}`, id);
339
+ }
340
+ }
341
+ return entryState;
342
+ }
343
+
344
+ // Maps a callee's summary back onto the CALLER's own state at the call
345
+ // site — the interprocedural analog of reading a function's return value
346
+ // and observing its side effects. Unlike dataflow/summaries.js's
347
+ // applyAtCallSite (which only propagates a mutation back for a bare
348
+ // `ident` argument, silently dropping a `member`-expression argument like
349
+ // `f(obj.field)`), this version also resolves a member-expression argument
350
+ // via `accessPathOf` — a deliberate, scoped improvement: field mutations
351
+ // plausibly target `obj.field`-shaped arguments often enough that
352
+ // dropping them silently would be a real, avoidable under-approximation.
353
+ export function applyAtCallSite(summary, paramNames, callArgs) {
354
+ const mutations = [];
355
+ for (const [paramPath, ids] of summary.mutatedParams) {
356
+ const [rootParamName, ...rest] = paramPath.split('.');
357
+ const idx = paramNames.indexOf(rootParamName);
358
+ if (idx === -1) continue;
359
+ const arg = callArgs[idx];
360
+ const argPath = accessPathOf(arg);
361
+ if (!argPath) continue;
362
+ const fullPath = rest.length > 0 ? `${argPath}.${rest.join('.')}` : argPath;
363
+ mutations.push({ path: fullPath, dataElementIds: [...ids] });
364
+ }
365
+ return { returnFlat: summary.returnFlat, returnByPath: summary.returnByPath, mutations };
366
+ }
367
+
368
+ // Builds a `resolveCallSummary` closure — the shape `resolveExprIdentities`'s
369
+ // `call` case now consults (see engine.js) — wired to a real
370
+ // FieldIdentitySummaryCache. `lookupCallee` is itself injected and
371
+ // deliberately opaque to this function: this increment's own tests pass a
372
+ // simple hand-built name-to-function map; increment B3's real call-graph
373
+ // integration will pass a resolver backed by `scanner/src/ir/callgraph.js`
374
+ // instead, without this function (or `resolveExprIdentities`) needing to
375
+ // change at all.
376
+ export function createCallSummaryResolver(cache, lookupCallee) {
377
+ return function resolveCallSummary(calleeExpr, callArgs, callerState, ctx) {
378
+ const resolved = lookupCallee(calleeExpr);
379
+ if (!resolved) return null;
380
+ const { qid, fn } = resolved;
381
+ // Path provenance (Sub-project C, increment 3, §13.7 item 7):
382
+ // `entryStateFromCall` does the recorder-only stripping itself (see its
383
+ // own header comment) — this call site forwards the caller's `ctx`
384
+ // unmodified; the hazard cannot reappear here because the strip
385
+ // happens one level down, not at each call site.
386
+ const entryState = entryStateFromCall(fn.params, callArgs, callerState, ctx);
387
+ // §13.2's first half: the callee's own entry context, computed once so
388
+ // both the bind hop below and the return-direction wrapper at the
389
+ // bottom of this function can reference the exact same value.
390
+ const calleeContext = hashState(entryState);
391
+
392
+ // Path provenance (§13.2b): the argument -> parameter binding out-half,
393
+ // emitted once per (path, id) entry of the freshly built entryState —
394
+ // entryState IS the complete record of every (toPath, id) the binding
395
+ // wrote, so nothing has to be re-resolved to enumerate them. fromPath
396
+ // stays null: the argument expression's own in-halves (emitted inside
397
+ // entryStateFromCall, above) already carry the real contributing keys
398
+ // at the join key (callerScope, callerNodeId, id, callerContext); a
399
+ // non-null fromPath here would double-emit the same information in a
400
+ // differently-shaped record. peerScope/peerContext are mandatory, not
401
+ // decorative: toPath lives in the CALLEE's namespace, so without them
402
+ // C4 would collide this binding's target with any caller-local
403
+ // variable of the same name (Decision 5's bug class).
404
+ if (ctx?.recordHop) {
405
+ for (const [path, ids] of entryState) {
406
+ for (const id of ids) {
407
+ ctx.recordHop({
408
+ kind: 'write-out', subKind: 'call-arg-bind',
409
+ fromPath: null, toPath: path, dataElementId: id,
410
+ syntacticPath: null, widenReason: null, lossReason: null,
411
+ peerScope: qid, peerContext: calleeContext,
412
+ });
413
+ }
414
+ }
415
+ }
416
+
417
+ const summary = cache.compute(qid, entryState, (es) => {
418
+ // Pass THIS SAME resolver down as the callee's own ctx — without
419
+ // this, a chain of resolved calls (outer resolves to middle, middle
420
+ // itself calls inner) would silently stop resolving after one hop:
421
+ // middle's own analysis would run with no ctx, so its call to inner
422
+ // would take the unresolved fallback, and outer would receive a
423
+ // coarsely-widened summary reported as `widened: false` (since
424
+ // resolveExprIdentities's call case only reads summary.returnFlat/
425
+ // returnByPath, never summary.widenings) — a confident-looking
426
+ // answer that's silently wrong one level down. A final whole-branch
427
+ // review found and proved this exact gap via a real three-function
428
+ // chain. Passing the resolver down makes resolution recurse through
429
+ // as many resolved hops as `lookupCallee` can cover, with the
430
+ // existing recursion guard (field-identity summary cache's `_stack`
431
+ // bottom-stub) already sufficient to keep a self- or mutually-
432
+ // recursive chain safe (verified: both terminate immediately,
433
+ // returning an empty, honestly-unrefined result — precision there
434
+ // is increment B5's job, not this fix's).
435
+ //
436
+ // Path provenance (§13.7 item 9, hole 3): keep the caller's recorder
437
+ // alive on the callee's own ctx instead of discarding it (the
438
+ // pre-C3 `{ resolveCallSummary }`-only object was hole 3). Do NOT
439
+ // re-stamp `context` here — the callee's own analyzeFunctionFieldIdentity
440
+ // call (engine.js's stepCtx wrapper) computes and stamps its own
441
+ // `context` from ITS OWN entry state (`es`, not the caller's), and
442
+ // its stamps win over anything this object would set, by spread
443
+ // order (`{ ...ctx, ..., ...h }` — the innermost `h` from the
444
+ // deepest call always wins). Passing a `context` field here would be
445
+ // silently overwritten and is dead code.
446
+ const calleeCtx = ctx?.recordHop
447
+ ? { resolveCallSummary, recordHop: ctx.recordHop }
448
+ : { resolveCallSummary };
449
+ const result = analyzeFunctionFieldIdentity(fn, es, calleeCtx);
450
+ return summaryFromAnalysisResult(result);
451
+ });
452
+
453
+ // Path provenance (§13.6/§13.7 item 12): a summary the cache honestly
454
+ // degraded (B6 context-cap) has an empty `returnFlat`, so
455
+ // engine.js's `case 'call'` `for (const id of flat)` loop can never
456
+ // fire — there is no hop at all to carry a marker (Finding 1: the
457
+ // degradation is otherwise completely silent). Emitted HERE, at the
458
+ // resolver, one loss hop per id that entered the callee (the entry
459
+ // state's own ids — the identities whose downstream fate is now
460
+ // unrepresented), `fromPath`/`toPath` both null so it reads as an
461
+ // ANNOTATION on the argument's own real in-half at the same join key
462
+ // under §2.2's rule (or, when there is no path-shaped argument, as
463
+ // edge-forming in its own right) — never dropped either way.
464
+ if (summary?.degradedReason && ctx?.recordHop) {
465
+ for (const [, ids] of entryState) {
466
+ for (const id of ids) {
467
+ ctx.recordHop({
468
+ kind: 'production', subKind: 'call-resolved',
469
+ fromPath: null, toPath: null, dataElementId: id,
470
+ syntacticPath: null, widenReason: null,
471
+ lossReason: 'context-cap-degraded',
472
+ peerScope: qid, peerContext: calleeContext,
473
+ });
474
+ }
475
+ }
476
+ }
477
+
478
+ // §13.2(c), the return direction: a FRESH wrapper every call — the
479
+ // cached summary is never mutated, so fieldSummaryEq and the B5
480
+ // refinement loop are untouched. engine.js reads only
481
+ // returnFlat/returnByPath, so this augmentation is inert to it; it
482
+ // exists purely so `case 'call'`'s own production/call-resolved hop can
483
+ // carry peerScope/peerContext naming the callee it resolved to.
484
+ return summary ? { ...summary, resolvedQid: qid, resolvedContext: calleeContext } : summary;
485
+ };
486
+ }
487
+
488
+ // Converts analyzeFunctionFieldIdentity's raw per-function result
489
+ // (`{exitState, returnFacts, mutatedParams, widenings}`) into the
490
+ // FieldSummary shape (`{returnFlat, returnByPath, mutatedParams,
491
+ // widenings}`) that FieldIdentitySummaryCache stores and every consumer of
492
+ // a resolved summary reads. Extracted (increment B4) from what was
493
+ // previously inline-only logic inside createCallSummaryResolver's own
494
+ // cache.compute callback, so increment B4's project-wide driver can seed
495
+ // the cache with the exact SAME conversion for a function's own top-level
496
+ // analysis, rather than reimplementing it a second time and risking the
497
+ // two copies drifting apart.
498
+ //
499
+ // Unions identities across EVERY return site, not just the first — a
500
+ // function with multiple return statements (e.g. an early-return branch)
501
+ // must have all of them reflected, not just whichever happened to be
502
+ // recorded first. This was a genuine correctness improvement over
503
+ // increment B1's own round-trip test's `returnFacts[0]` shortcut (that
504
+ // test only ever exercised a single-return-site function, so the shortcut
505
+ // was harmless there).
506
+ export function summaryFromAnalysisResult(result) {
507
+ const returnFlat = new Set();
508
+ for (const rf of result.returnFacts) {
509
+ for (const id of rf.identities) returnFlat.add(id);
510
+ }
511
+ return {
512
+ returnFlat,
513
+ returnByPath: new Map(), // still flat-only — see B1's disclosed limitation in CLAUDE.md; not closed by this increment either.
514
+ // If this ever stops being an unconditional empty Map, fieldSummaryEq
515
+ // (above) MUST be extended to compare it too — that function currently
516
+ // omits it, reasoned as safe only because every value here is always
517
+ // empty. Skipping this would silently reintroduce the exact
518
+ // "Stage 3 correctness audit" bug class dataflow/summaries.js
519
+ // documents: two summaries wrongly judged equal, the fresher one
520
+ // never cached, a later reader silently served the stale value.
521
+ mutatedParams: result.mutatedParams,
522
+ widenings: result.widenings,
523
+ };
524
+ }
525
+
526
+ // Resolves a call expression's callee to a bare, resolvable name — the
527
+ // lineage-engine analog of dataflow/engine.js's own `_resolvableCalleeName`
528
+ // BASE CASE (before that file's later, CHA-gated member-expression
529
+ // extension) for the `calleeExpr.kind === 'ident'` branch specifically.
530
+ // Deliberately narrow: only a bare identifier callee (`helper(x)`) resolves
531
+ // to a name at all. A member-expression callee (`obj.helper(x)`) returns
532
+ // null here, on purpose — resolving THAT safely needs class-hierarchy
533
+ // analysis (which method does the object concretely carry), a separate,
534
+ // much larger mechanism dataflow built specifically for its own R11
535
+ // requirement (`_resolveMemberCalleeViaCHA`, gated on a `_cha` object this
536
+ // package has no equivalent of and is not in scope to build here).
537
+ // Guessing from the property name alone would fabricate a call edge that
538
+ // may not exist — worse than leaving the call unresolved, matching this
539
+ // whole codebase's own stated doctrine (see callgraph.js's comments on
540
+ // `resolveKnownCallee` vs. the guessing `resolve()`).
541
+ //
542
+ // UNLIKE dataflow's version, this one does NOT also accept a plain string
543
+ // callee (`typeof calleeExpr === 'string'`). Per the IR shape contract
544
+ // (scanner/src/ir/CLAUDE.md), `callee` is `string|expr` — the seven
545
+ // hand-rolled parsers (Python/Ruby/PHP/Go/Java/C#/Kotlin) emit a flat,
546
+ // dot-joined STRING callee, never a structured `{kind:'ident'}` node. This
547
+ // means `createCallGraphLookup` below resolves NOTHING for those
548
+ // languages' IR today — every call in a non-JS/TS file silently takes the
549
+ // unresolved-call fallback (flat + widened:true), the same fail-safe
550
+ // direction as an unresolvable JS/TS call, just unconditionally so. This
551
+ // is not an oversight: naively accepting a dotted string (e.g. Java's
552
+ // class-qualified `"App.getUser"`) and handing it to `resolveKnownCallee`
553
+ // would resolve it via that function's project-wide bare-name index —
554
+ // which is member-call resolution in disguise, exactly what this
555
+ // increment's CHA-free scope forbids. Whether/how to extend real
556
+ // interprocedural resolution to the hand-rolled-parser languages is
557
+ // undecided and out of scope for Sub-project B's current B1-B6 breakdown.
558
+ function _resolvableCalleeName(calleeExpr) {
559
+ if (!calleeExpr) return null;
560
+ if (calleeExpr.kind === 'ident') return calleeExpr.name || null;
561
+ return null;
562
+ }
563
+
564
+ // Builds a real `lookupCallee` closure — the shape `createCallSummaryResolver`
565
+ // expects as its second argument — backed by a real call graph from
566
+ // `scanner/src/ir/callgraph.js#buildCallGraph`. `callerFile` is fixed at
567
+ // construction time: one `lookupCallee` closure is built per analyzed
568
+ // function/file (mirroring how `dataflow/engine.js`'s own
569
+ // `_resolveCalleeForSummary` derives `_callerFile` fresh per call context),
570
+ // so `createCallSummaryResolver`'s existing single-argument `lookupCallee`
571
+ // shape (no caller-file parameter) does not need to change.
572
+ //
573
+ // Uses `resolveKnownCallee` — never `resolve()` — matching `callgraph.js`'s
574
+ // own documented distinction: `resolveKnownCallee` is "safe-by-default,"
575
+ // refusing the bare-name-tail guess `resolve()` is willing to make. This
576
+ // package's own doctrine (see FR-301, never silently merge/drop distinct
577
+ // identities) treats a fabricated call edge as strictly worse than a missed
578
+ // one, same as dataflow's own precedent.
579
+ export function createCallGraphLookup(callGraph, callerFile) {
580
+ return function lookupCallee(calleeExpr) {
581
+ if (!callGraph || typeof callGraph.resolveKnownCallee !== 'function') return null;
582
+ const name = _resolvableCalleeName(calleeExpr);
583
+ if (!name) return null;
584
+ const resolved = callGraph.resolveKnownCallee(name, callerFile);
585
+ if (!resolved) return null;
586
+ const fn = functionRecord(callGraph, resolved);
587
+ if (!fn) return null;
588
+ return { qid: resolved, fn };
589
+ };
590
+ }