@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,992 @@
1
+ import { accessPathOf, pathIsCoveredByPrefix } from '../dataflow/access-paths.js';
2
+ import { identitiesAt, emptyState, removeIdentitiesAt, addIdentity, joinStates, statesEqual, hashState } from './field-identity.js';
3
+
4
+ function noIdentity() {
5
+ return { flat: new Set(), byPath: new Map(), widened: false };
6
+ }
7
+
8
+ function unionOfByPath(byPath) {
9
+ const union = new Set();
10
+ for (const ids of byPath.values()) for (const id of ids) union.add(id);
11
+ return union;
12
+ }
13
+
14
+ // Whatever in `flat` is NOT already captured by `byPath` — this is the part
15
+ // of a value's identity set that has no more specific field-level home, and
16
+ // is safe to write coarsely (it was never distinguished field-by-field to
17
+ // begin with, so writing it coarsely does not merge two ALREADY-DISTINGUISHED
18
+ // facts the way writing the full `flat` at a root would). See
19
+ // DESIGN_INTRAPROCEDURAL.md §3 for the full reasoning and the bug this
20
+ // closes (a plain-variable alias, e.g. `const copy = user;`, surviving one
21
+ // level of aliasing past round 1's object-literal-only fix).
22
+ export function residualFlat(flat, byPath) {
23
+ const covered = unionOfByPath(byPath);
24
+ const residual = new Set();
25
+ for (const id of flat) if (!covered.has(id)) residual.add(id);
26
+ return residual;
27
+ }
28
+
29
+ // Round 6 finding: round 5's wildcard guards (`path === '*' ||
30
+ // path.endsWith('.*')`) only recognized a TRAILING wildcard segment. A
31
+ // wildcard segment can also appear in the MIDDLE of a path — `store[k].name`
32
+ // lowers to the access path `store.*.name` (accessPathOf's own, pre-existing
33
+ // convention for a statically-unknown computed key), which is neither
34
+ // exactly '*' nor ending in '.*', so it fell through to the OLD, unfixed
35
+ // strong-update/silent-drop behavior — round 5's own bug recurring one path
36
+ // segment deeper. These two helpers generalize both the selection-side
37
+ // (`member`) and write-out-side (`assign`) guards to be position-
38
+ // independent: any '*' segment anywhere in the path, not just a trailing
39
+ // one.
40
+ //
41
+ // Finds the longest DEFINITE (wildcard-free) prefix of `path` before its
42
+ // first '*' segment — e.g. 'store.*.name' -> 'store', 'bag.*' -> 'bag',
43
+ // '*' -> null (no definite prefix at all), 'a.b.c' -> null (no wildcard
44
+ // present, caller should not treat this as a wildcard path in the first
45
+ // place). This subsumes round 5's trailing-only `endsWith('.*')` handling
46
+ // as a special case ('bag.*' through this function gives the same 'bag'
47
+ // round 5's `slice(0, -2)` gave) while also correctly handling an INTERIOR
48
+ // wildcard, which round 5 missed.
49
+ function definitePrefixBeforeWildcard(path) {
50
+ const segments = path.split('.');
51
+ const idx = segments.indexOf('*');
52
+ if (idx <= 0) return null; // no wildcard present, or '*' is the very first segment (no definite prefix)
53
+ return segments.slice(0, idx).join('.');
54
+ }
55
+
56
+ function pathHasWildcard(path) {
57
+ return path.split('.').includes('*');
58
+ }
59
+
60
+ // --- Path provenance hop recording (Sub-project C, increment 1) ---------
61
+ // See DESIGN_PATH_PROVENANCE.md for the full design. Only two helpers below
62
+ // are new; everything else in this file is unchanged in shape, and the
63
+ // accumulator (`ctx.recordHop`) is threaded exactly the way `ctx` already
64
+ // flows everywhere (Decision 7) — no new plumbing, no new parameters.
65
+
66
+ // Which state keys jointly contributed each id to identitiesAt(state, path)?
67
+ // Mirrors identitiesAt's own bidirectional prefix-coverage test (field-
68
+ // identity.js) EXACTLY — if that test ever changes, this must change with
69
+ // it or the provenance DAG silently disconnects (Decision 6). Single pass
70
+ // over `state`: O(|state|) total for every id at `path`, not O(|state| x
71
+ // |ids|) — the naive per-id-loop cost the design doc's initial draft
72
+ // understated and a review corrected (Decision 6, "Cost, corrected"). Used
73
+ // internally by the engine's own hop-recording sites; never called on the
74
+ // hot path when no recorder is present (guarded by `ctx?.recordHop` at each
75
+ // call site, matching Decision 1's "extra, discarded computation" allowance).
76
+ function contributingKeysAllIds(state, path) {
77
+ const byId = new Map();
78
+ for (const [candidatePath, ids] of state) {
79
+ if (!(pathIsCoveredByPrefix(path, candidatePath) || pathIsCoveredByPrefix(candidatePath, path))) continue;
80
+ for (const id of ids) {
81
+ const set = byId.get(id) ?? new Set();
82
+ set.add(candidatePath);
83
+ byId.set(id, set);
84
+ }
85
+ }
86
+ return byId;
87
+ }
88
+
89
+ // Per-id form of the above, exported for DESIGN_PATH_PROVENANCE.md's
90
+ // structural test guard (a design review recommendation, folded into Task
91
+ // 2 of the Sub-project C increment-1 plan): a test asserts that
92
+ // unioning `contributingKeys(state, path, id)` over every id in
93
+ // `identitiesAt(state, path)` reconstructs that same set exactly, so a
94
+ // future change to identitiesAt's coverage test that isn't mirrored here
95
+ // fails loudly instead of silently disconnecting the DAG. Not on the
96
+ // engine's own hot path (see contributingKeysAllIds above) — this is a
97
+ // thin per-id wrapper for test/debugging use, where re-scanning `state`
98
+ // once per call is fine.
99
+ export function contributingKeys(state, path, id) {
100
+ return contributingKeysAllIds(state, path).get(id) ?? new Set();
101
+ }
102
+
103
+ export function resolveExprIdentities(state, expr, ctx) {
104
+ if (!expr) return noIdentity();
105
+
106
+ switch (expr.kind) {
107
+ case 'ident': {
108
+ const path = accessPathOf(expr);
109
+ if (!path) return noIdentity();
110
+ const flat = identitiesAt(state, path);
111
+ const byPath = new Map();
112
+ for (const [candidatePath, ids] of state) {
113
+ if (candidatePath !== path && pathIsCoveredByPrefix(candidatePath, path)) {
114
+ const subPath = candidatePath.slice(path.length + 1);
115
+ const existing = byPath.get(subPath) ?? new Set();
116
+ byPath.set(subPath, new Set([...existing, ...ids]));
117
+ }
118
+ }
119
+ // Path provenance (Sub-project C, increment 1, Task 2 instrumented
120
+ // site 1 of 4): one production/ident in-half per (contributing state
121
+ // key, dataElementId) pair — Decision 6, NOT one per queried `path`.
122
+ // Extra computation whose result is entirely discarded when no
123
+ // recorder is present (Decision 1).
124
+ if (ctx?.recordHop) {
125
+ const contrib = contributingKeysAllIds(state, path);
126
+ for (const id of flat) {
127
+ const keys = contrib.get(id);
128
+ if (!keys) continue;
129
+ for (const key of keys) {
130
+ ctx.recordHop({
131
+ kind: 'production', subKind: 'ident',
132
+ fromPath: key, toPath: null, dataElementId: id,
133
+ syntacticPath: key === path ? null : path,
134
+ widenReason: null, lossReason: null,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ return { flat, byPath, widened: false };
140
+ }
141
+
142
+ case 'member': {
143
+ const path = accessPathOf(expr);
144
+ if (path) {
145
+ if (pathHasWildcard(path)) {
146
+ // A computed member access with a statically-unknown key (`obj[k]`) —
147
+ // we don't know WHICH field is being read, so conservatively resolve
148
+ // the definite prefix's aggregate identity (identitiesAt's existing
149
+ // descendant aggregation, from round 2, already does exactly this
150
+ // when queried at that prefix path) and flag it widened, per
151
+ // DESIGN_INTRAPROCEDURAL.md §4's dynamic-property-key example. Never
152
+ // silently drop it (FR-306's "never launder identity into a clean
153
+ // value" principle) — see the round-5 re-review that found this gap,
154
+ // and round 6's generalization to an INTERIOR wildcard (e.g.
155
+ // 'store.*.name' from `store[k].name`), which round 5's
156
+ // trailing-only check missed.
157
+ const basePath = definitePrefixBeforeWildcard(path);
158
+ const flat = basePath ? identitiesAt(state, basePath) : new Set();
159
+ // Path provenance (Sub-project C, increment 2, Task 1, §10.1
160
+ // `member` path-branch/wildcard row): fromPath is the DEFINITE
161
+ // PREFIX before the wildcard, never the raw '*'-containing path
162
+ // (Decision 5) — recording the wildcard form would create a DAG
163
+ // node no read hop could ever reach.
164
+ if (ctx?.recordHop) {
165
+ for (const id of flat) {
166
+ ctx.recordHop({
167
+ kind: 'selection', subKind: 'member',
168
+ fromPath: basePath, toPath: null, dataElementId: id,
169
+ syntacticPath: path, widenReason: 'dynamic-property-key', lossReason: null,
170
+ });
171
+ }
172
+ }
173
+ return { flat, byPath: new Map(), widened: flat.size > 0 };
174
+ }
175
+ // Pure ident/member chain — resolve directly against state, same
176
+ // logic as the `ident` case above.
177
+ const flat = identitiesAt(state, path);
178
+ const byPath = new Map();
179
+ for (const [candidatePath, ids] of state) {
180
+ if (candidatePath !== path && pathIsCoveredByPrefix(candidatePath, path)) {
181
+ const subPath = candidatePath.slice(path.length + 1);
182
+ const existing = byPath.get(subPath) ?? new Set();
183
+ byPath.set(subPath, new Set([...existing, ...ids]));
184
+ }
185
+ }
186
+ // Path provenance (§10.1 `member` path-branch/no-wildcard row): one
187
+ // selection in-half per (contributing state key, dataElementId)
188
+ // pair — Decision 6, exactly the same pattern as `ident` above
189
+ // (this branch IS structurally identical to ident's own
190
+ // resolution, just against a dotted path instead of a bare name).
191
+ if (ctx?.recordHop) {
192
+ const contrib = contributingKeysAllIds(state, path);
193
+ for (const id of flat) {
194
+ const keys = contrib.get(id);
195
+ if (!keys) continue;
196
+ for (const key of keys) {
197
+ ctx.recordHop({
198
+ kind: 'selection', subKind: 'member',
199
+ fromPath: key, toPath: null, dataElementId: id,
200
+ syntacticPath: key === path ? null : path,
201
+ widenReason: null, lossReason: null,
202
+ });
203
+ }
204
+ }
205
+ }
206
+ return { flat, byPath, widened: false };
207
+ }
208
+
209
+ // The base isn't a pure path (e.g. `(user ?? other).email`,
210
+ // `(flag ? a : b).email`, `({a: user}).a.email`) — resolve the base
211
+ // recursively and SELECT `prop` out of its `byPath`, mirroring how the
212
+ // `object` case's construction attributes a property to its own key.
213
+ // This is the read-side mirror of that write-side logic — without it,
214
+ // a value round 3 correctly taught to carry structure via `byPath`
215
+ // silently loses that structure the moment a field is read off it
216
+ // directly, rather than through an intermediate variable. Also inherit
217
+ // the base's RESIDUAL (via the existing `residualFlat` helper, same one
218
+ // `assign`/`object` already use) — a coarse/ancestor-level fact on the
219
+ // base conservatively applies to every field read off it, same
220
+ // reasoning as `identitiesAt`'s ancestor coverage for state-backed reads.
221
+ const base = resolveExprIdentities(state, expr.object, ctx);
222
+ if (expr.prop === '*') {
223
+ // Same reasoning as the path-succeeds branch above: unknown key on a
224
+ // non-path base — conservatively use everything the base carries
225
+ // (base.flat is already that full aggregate), flagged widened.
226
+ const flat = new Set(base.flat);
227
+ // Path provenance (§10.1 `member` non-path-base/`prop==='*'` row):
228
+ // fromPath null — the base is an in-flight value, not itself a
229
+ // state key; the base's own recursion already emitted whatever
230
+ // state-backed in-halves it carries. This hop only annotates the
231
+ // (widened) selection.
232
+ if (ctx?.recordHop) {
233
+ for (const id of flat) {
234
+ ctx.recordHop({
235
+ kind: 'selection', subKind: 'member',
236
+ fromPath: null, toPath: null, dataElementId: id,
237
+ syntacticPath: null, widenReason: 'dynamic-property-key', lossReason: null,
238
+ });
239
+ }
240
+ }
241
+ return { flat, byPath: new Map(), widened: base.flat.size > 0 };
242
+ }
243
+ const baseResidual = residualFlat(base.flat, base.byPath);
244
+ const flat = new Set(baseResidual);
245
+ const byPath = new Map();
246
+ for (const [subPath, ids] of base.byPath) {
247
+ if (subPath === expr.prop) {
248
+ for (const id of ids) flat.add(id);
249
+ } else if (subPath.startsWith(`${expr.prop}.`)) {
250
+ const rebased = subPath.slice(expr.prop.length + 1);
251
+ const existing = byPath.get(rebased) ?? new Set();
252
+ byPath.set(rebased, new Set([...existing, ...ids]));
253
+ for (const id of ids) flat.add(id);
254
+ }
255
+ }
256
+ // Path provenance (§10.1 `member` non-path-base/`prop !== '*'` row):
257
+ // fromPath null, same reasoning as the `prop === '*'` branch above —
258
+ // this hop only annotates the selection, per id in the resulting
259
+ // (post-selection) flat set.
260
+ if (ctx?.recordHop) {
261
+ for (const id of flat) {
262
+ ctx.recordHop({
263
+ kind: 'selection', subKind: 'member',
264
+ fromPath: null, toPath: null, dataElementId: id,
265
+ syntacticPath: null, widenReason: null, lossReason: null,
266
+ });
267
+ }
268
+ }
269
+ return { flat, byPath, widened: base.widened };
270
+ }
271
+
272
+ // Path provenance (§10.1): `literal` emits nothing, because no identity
273
+ // exists to have provenance. `unknown` also emits nothing — nothing was
274
+ // resolved, so nothing is being dropped; a `lossReason` here would be
275
+ // speculative, and §10.1 is explicit that this is a decide-with-
276
+ // evidence call, not something to add on spec alone.
277
+ case 'literal':
278
+ case 'unknown':
279
+ return noIdentity();
280
+
281
+ case 'object': {
282
+ const flat = new Set();
283
+ const byPath = new Map();
284
+ for (const prop of expr.props) {
285
+ const r = resolveExprIdentities(state, prop.value, ctx);
286
+ for (const id of r.flat) flat.add(id);
287
+ // Path provenance (Sub-project C, increment 1, Task 2 instrumented
288
+ // site 2 of 4): one production/object in-half per id this property
289
+ // contributes, fromPath null — a fresh structural annotation, not a
290
+ // prior aliasing source (Decision 5/§10.1). All three rows of
291
+ // §10.1's `object` table (plain, spread, `*`-keyed) agree on
292
+ // kind/fromPath/toPath, so this is emitted once here rather than
293
+ // duplicated in each of the three branches below — but they do NOT
294
+ // agree on widenReason: the `*`-keyed row is explicitly
295
+ // `'dynamic-property-key'` (a computed key we cannot statically
296
+ // resolve), while a plain or spread property is an explicit,
297
+ // non-widened flow. A fix-round review caught this file's own
298
+ // earlier comment overstating the agreement to cover widenReason
299
+ // too, which had silently left `{[k]: v}` graded as an explicit
300
+ // flow.
301
+ if (ctx?.recordHop) {
302
+ const objWidenReason = prop.key === '*' ? 'dynamic-property-key' : null;
303
+ for (const id of r.flat) {
304
+ ctx.recordHop({
305
+ kind: 'production', subKind: 'object',
306
+ fromPath: null, toPath: null, dataElementId: id,
307
+ syntacticPath: null, widenReason: objWidenReason, lossReason: null,
308
+ });
309
+ }
310
+ }
311
+ if (prop.spread) {
312
+ // Object spread ({...src}) copies ALL of src's own properties onto
313
+ // this object as TOP-LEVEL siblings — merge the spread source's
314
+ // byPath structure directly into this object's own byPath,
315
+ // preserving field-level distinctness (a spread's contents are
316
+ // fully known, unlike a computed-unknown-key property, which is
317
+ // why this is a different branch from the `prop.key === '*'` case
318
+ // below, not the same one).
319
+ for (const [subPath, ids] of r.byPath) {
320
+ const existing = byPath.get(subPath) ?? new Set();
321
+ byPath.set(subPath, new Set([...existing, ...ids]));
322
+ }
323
+ continue;
324
+ }
325
+ if (prop.key === '*') {
326
+ // Unknown computed key (`{[k]: v}`, round 5 — see
327
+ // scanner/src/ir/parser-js.js's ObjectExpression case, which now
328
+ // emits the literal key '*' for a non-literal computed key,
329
+ // mirroring computed-member-access's existing convention) —
330
+ // fold into the coarse residual for this object rather than a
331
+ // specific byPath entry, which would just be a differently-
332
+ // shaped version of the same fabricated-key collision bug (only
333
+ // with '*' as the fabricated key instead of the key expression's
334
+ // own variable name). Nothing to do here beyond adding to `flat`
335
+ // above — leaving it OUT of `byPath` is exactly what makes it
336
+ // residual when this object is later written via `assign`.
337
+ continue;
338
+ }
339
+ // Use the RESIDUAL, not the full r.flat: if prop.value is itself an
340
+ // aliased/structured reference (now possible via the ident/member
341
+ // case above returning a populated byPath), writing the full flat
342
+ // here would duplicate what the nested subPath entries below already
343
+ // separate — the same coarse-merge bug one level deeper. See
344
+ // DESIGN_INTRAPROCEDURAL.md §3.
345
+ const propResidual = residualFlat(r.flat, r.byPath);
346
+ if (propResidual.size > 0) {
347
+ const existing = byPath.get(prop.key) ?? new Set();
348
+ byPath.set(prop.key, new Set([...existing, ...propResidual]));
349
+ }
350
+ for (const [subPath, ids] of r.byPath) {
351
+ const fullPath = `${prop.key}.${subPath}`;
352
+ const existing = byPath.get(fullPath) ?? new Set();
353
+ byPath.set(fullPath, new Set([...existing, ...ids]));
354
+ }
355
+ }
356
+ return { flat, byPath, widened: false };
357
+ }
358
+
359
+ case 'array': {
360
+ const flat = new Set();
361
+ for (const el of expr.elements) {
362
+ const r = resolveExprIdentities(state, el, ctx);
363
+ for (const id of r.flat) flat.add(id);
364
+ }
365
+ // Path provenance (§10.1 `array` row): structure-flattening by
366
+ // design (spread ambiguity, ADR §4) — identity propagates FULLY;
367
+ // only per-index distinction is lost, which is a precision fact, not
368
+ // an identity loss, so no widenReason/lossReason here.
369
+ if (ctx?.recordHop) {
370
+ for (const id of flat) {
371
+ ctx.recordHop({
372
+ kind: 'production', subKind: 'array',
373
+ fromPath: null, toPath: null, dataElementId: id,
374
+ syntacticPath: null, widenReason: null, lossReason: null,
375
+ });
376
+ }
377
+ }
378
+ return { flat, byPath: new Map(), widened: false };
379
+ }
380
+
381
+ case 'tpl': {
382
+ const flat = new Set();
383
+ for (const part of expr.parts) {
384
+ const r = resolveExprIdentities(state, part, ctx);
385
+ for (const id of r.flat) flat.add(id);
386
+ }
387
+ // Path provenance (§10.1 `tpl` row): transformation-bearing — the
388
+ // identity is embedded in a new string. This is an EXPLICIT flow, not
389
+ // a widened one (ADR §4), so no widenReason.
390
+ if (ctx?.recordHop) {
391
+ for (const id of flat) {
392
+ ctx.recordHop({
393
+ kind: 'production', subKind: 'tpl',
394
+ fromPath: null, toPath: null, dataElementId: id,
395
+ syntacticPath: null, widenReason: null, lossReason: null,
396
+ });
397
+ }
398
+ }
399
+ return { flat, byPath: new Map(), widened: false };
400
+ }
401
+
402
+ case 'binary': {
403
+ // Arithmetic/comparison operators always PRODUCE A NEW PRIMITIVE —
404
+ // structure-flattening by design, not a gap. See
405
+ // DESIGN_INTRAPROCEDURAL.md §4's "structure-preserving vs.
406
+ // structure-flattening" invariant.
407
+ const left = resolveExprIdentities(state, expr.left, ctx);
408
+ const right = resolveExprIdentities(state, expr.right, ctx);
409
+ const flat = new Set([...left.flat, ...right.flat]);
410
+ // Path provenance (§10.1 `binary` row): same as `tpl` — deliberately
411
+ // a separate case (not shared) per ADR §4, but the hop shape agrees.
412
+ if (ctx?.recordHop) {
413
+ for (const id of flat) {
414
+ ctx.recordHop({
415
+ kind: 'production', subKind: 'binary',
416
+ fromPath: null, toPath: null, dataElementId: id,
417
+ syntacticPath: null, widenReason: null, lossReason: null,
418
+ });
419
+ }
420
+ }
421
+ return { flat, byPath: new Map(), widened: false };
422
+ }
423
+
424
+ case 'logical': {
425
+ // Unlike `binary`, `||`/`&&`/`??` can return one operand VERBATIM, BY
426
+ // REFERENCE, via short-circuit evaluation — structurally identical to
427
+ // `union` below (select/pass through an existing value). Must forward
428
+ // byPath, merged per sub-path across both operands, the same way
429
+ // `union` merges per sub-path across branches. See
430
+ // DESIGN_INTRAPROCEDURAL.md §4.
431
+ const left = resolveExprIdentities(state, expr.left, ctx);
432
+ const right = resolveExprIdentities(state, expr.right, ctx);
433
+ const flat = new Set([...left.flat, ...right.flat]);
434
+ const byPath = new Map();
435
+ for (const r of [left, right]) {
436
+ for (const [subPath, ids] of r.byPath) {
437
+ const existing = byPath.get(subPath) ?? new Set();
438
+ byPath.set(subPath, new Set([...existing, ...ids]));
439
+ }
440
+ }
441
+ // Path provenance (§10.1 `logical` row): structure-preserving
442
+ // (short-circuit evaluation can return an operand verbatim) — no
443
+ // widenReason.
444
+ if (ctx?.recordHop) {
445
+ for (const id of flat) {
446
+ ctx.recordHop({
447
+ kind: 'production', subKind: 'logical',
448
+ fromPath: null, toPath: null, dataElementId: id,
449
+ syntacticPath: null, widenReason: null, lossReason: null,
450
+ });
451
+ }
452
+ }
453
+ return { flat, byPath, widened: false };
454
+ }
455
+
456
+ case 'union': {
457
+ // A ternary selects one branch's value VERBATIM at runtime — this is
458
+ // the expression-level equivalent of the CFG's own branch join
459
+ // (joinStates), which unions PER PATH rather than flattening. Must do
460
+ // the same here: merge each branch's byPath per sub-path, never
461
+ // collapse into one coarse flat blob. See DESIGN_INTRAPROCEDURAL.md §4.
462
+ const flat = new Set();
463
+ const byPath = new Map();
464
+ for (const branch of expr.branches) {
465
+ const r = resolveExprIdentities(state, branch, ctx);
466
+ for (const id of r.flat) flat.add(id);
467
+ for (const [subPath, ids] of r.byPath) {
468
+ const existing = byPath.get(subPath) ?? new Set();
469
+ byPath.set(subPath, new Set([...existing, ...ids]));
470
+ }
471
+ }
472
+ // Path provenance (§10.1 `union` row): structure-preserving — both
473
+ // branches emitted their own in-halves during recursion above; this
474
+ // hop annotates the resulting selection. Both branches' ids landing
475
+ // here is FR-305's genuine multiple-path case, not §9.1's phantom
476
+ // cross-join.
477
+ if (ctx?.recordHop) {
478
+ for (const id of flat) {
479
+ ctx.recordHop({
480
+ kind: 'production', subKind: 'union',
481
+ fromPath: null, toPath: null, dataElementId: id,
482
+ syntacticPath: null, widenReason: null, lossReason: null,
483
+ });
484
+ }
485
+ }
486
+ return { flat, byPath, widened: false };
487
+ }
488
+
489
+ case 'call': {
490
+ // NEW (Sub-project B, increment 2): if the caller supplied a resolver
491
+ // and it recognizes this specific call, use the resolved callee's REAL
492
+ // return facts (both flat and byPath, so a caller selecting one field
493
+ // off a resolved call's structured return value gets the same
494
+ // field-level precision as any other structure-preserving construct)
495
+ // instead of the generic unresolved-call fallback below. This is what
496
+ // makes the structure-preserving/structure-flattening invariant (see
497
+ // DESIGN_INTRAPROCEDURAL.md §3) genuinely true for a call now: a
498
+ // RESOLVED call is structure-preserving (forwards byPath); an
499
+ // UNRESOLVED one remains structure-flattening (flat + widened),
500
+ // exactly as before this increment. `ctx` is optional and
501
+ // backward-compatible — no `ctx` (or no `ctx.resolveCallSummary`)
502
+ // falls straight through to the pre-existing behavior below,
503
+ // unchanged.
504
+ if (ctx?.resolveCallSummary) {
505
+ // Path provenance (Sub-project C, increment 3, §13.1): pass the
506
+ // whole stamped ctx as a 4th argument — this is the ONLY place the
507
+ // full ctx crosses from engine.js into summaries.js. `ctx` here is
508
+ // already the stamped `stepCtx` (§7.2), so this hands
509
+ // resolveCallSummary both the caller's recorder AND the caller's
510
+ // scope/nodeId/line/context stamping in one object.
511
+ const summary = ctx.resolveCallSummary(expr.callee, expr.args ?? [], state, ctx);
512
+ if (summary) {
513
+ const flat = new Set(summary.returnFlat);
514
+ const byPath = new Map(summary.returnByPath);
515
+ // Path provenance (§10.1 `call` resolved row): records only that
516
+ // a RESOLVED call contributed — the actual cross-function stitch
517
+ // is C3's job, not C2's.
518
+ //
519
+ // §13.2(c): peerScope/peerContext name the callee this call
520
+ // resolved to, so C4 can connect this hop to the callee's own
521
+ // write-out/return hops. `?? null`, never a bare reference — a
522
+ // 3-argument resolveCallSummary stub (an older/hand-built test
523
+ // fixture that doesn't return resolvedQid/resolvedContext) must
524
+ // not throw or stamp `undefined`.
525
+ if (ctx?.recordHop) {
526
+ for (const id of flat) {
527
+ ctx.recordHop({
528
+ kind: 'production', subKind: 'call-resolved',
529
+ fromPath: null, toPath: null, dataElementId: id,
530
+ syntacticPath: null, widenReason: null, lossReason: null,
531
+ peerScope: summary.resolvedQid ?? null, peerContext: summary.resolvedContext ?? null,
532
+ });
533
+ }
534
+ }
535
+ return { flat, byPath, widened: false };
536
+ }
537
+ }
538
+ const flat = new Set();
539
+ // Hotfix (found during Sub-project E1's design spike; see
540
+ // DESIGN_GRAPH_BUILDER.md §11): an unresolved method call's RECEIVER
541
+ // (`expr.callee.object` when `expr.callee.kind === 'member'`) can
542
+ // itself carry identity that survives into the call's own unknown
543
+ // return value — e.g. `pan.slice(0, 4)` still carries `pan`'s
544
+ // identity, the same way `pan + 'x'`/`String(pan)` already correctly
545
+ // do via `binary`/argument resolution. Before this fix, only
546
+ // `expr.args` was ever unioned here, so a receiver-only flow (no
547
+ // arguments carrying identity) silently dropped it entirely. Unioned
548
+ // the SAME way arguments already are — flat, into this unresolved
549
+ // call's own flat result — per DESIGN_INTRAPROCEDURAL.md's
550
+ // structure-flattening rule for `call` (an unresolved call's return
551
+ // is genuinely unknown structure; only `flat` participates, never
552
+ // `byPath`). Mirrors `scanner/src/dataflow/engine.js`'s
553
+ // `_calleeReceiverTainted` precedent for the sibling taint engine,
554
+ // which this package had not yet inherited.
555
+ // task review MF-1 (engine-receiver-identity-hotfix): the initial fix
556
+ // only handled `parser-js.js`'s structured `{kind:'member', object,
557
+ // prop}` callee shape. Every OTHER language parser
558
+ // (parser-py.js/parser-java.js/parser-go.js/parser-php.js/
559
+ // parser-rb.js/parser-cs.js/parser-kt.js/parser-cpp.js) instead emits
560
+ // a flat, dot-joined STRING callee ("pan.slice", not an exprDesc) —
561
+ // there is no sub-expression to recurse into, so the receiver is
562
+ // recovered by slicing off the string after its LAST '.' and looking
563
+ // up that prefix directly, mirroring `dataflow/engine.js`'s
564
+ // `_calleeReceiverTainted` string branch exactly (that function
565
+ // returns a boolean via `isCoveredBy`; this one needs the actual
566
+ // identity SET, so it queries `identitiesAt` instead). Not reachable
567
+ // from any shipped caller today (this package is wired only against
568
+ // `parser-js.js`'s JS/TS output — see `DESIGN_GRAPH_BUILDER.md`'s own
569
+ // scope note), but `resolveExprIdentities` is a generic function with
570
+ // no JS-only gate of its own, and leaving this branch half-ported
571
+ // would silently reproduce the exact "sibling engine already solved
572
+ // this and we didn't inherit it" bug class one language over.
573
+ if (typeof expr.callee === 'string') {
574
+ const idx = expr.callee.lastIndexOf('.');
575
+ if (idx > 0) {
576
+ const receiverPath = expr.callee.slice(0, idx);
577
+ for (const id of identitiesAt(state, receiverPath)) flat.add(id);
578
+ }
579
+ } else if (expr.callee?.kind === 'member' && expr.callee.object) {
580
+ const receiver = resolveExprIdentities(state, expr.callee.object, ctx);
581
+ for (const id of receiver.flat) flat.add(id);
582
+ }
583
+ for (const arg of expr.args ?? []) {
584
+ const r = resolveExprIdentities(state, arg, ctx);
585
+ for (const id of r.flat) flat.add(id);
586
+ }
587
+ // Path provenance (§10.1 `call` unresolved row): an unresolved call's
588
+ // return is genuinely unknown structure — flat + widened, never
589
+ // laundered into a clean value.
590
+ if (ctx?.recordHop) {
591
+ for (const id of flat) {
592
+ ctx.recordHop({
593
+ kind: 'production', subKind: 'call',
594
+ fromPath: null, toPath: null, dataElementId: id,
595
+ syntacticPath: null, widenReason: 'unresolved-call', lossReason: null,
596
+ });
597
+ }
598
+ }
599
+ return { flat, byPath: new Map(), widened: flat.size > 0 };
600
+ }
601
+
602
+ case 'assign-expr': {
603
+ // Nested assignment-as-expression (e.g. `if ((x = getUser()).isAdmin)`)
604
+ // is read-only here: resolves what the expression VALUE carries but
605
+ // does NOT write into `x` in `state` — see
606
+ // scanner/src/lineage/DESIGN_INTRAPROCEDURAL.md §4 for why this is a
607
+ // deliberate, documented limitation, not an oversight.
608
+ // A simple pass-through of whatever the assignment's source resolves
609
+ // to (structure-preserving), so its byPath is forwarded directly, not
610
+ // dropped. See DESIGN_INTRAPROCEDURAL.md §4.
611
+ const r = resolveExprIdentities(state, expr.source, ctx);
612
+ // Path provenance (§10.1 `assign-expr` row): pure pass-through — the
613
+ // source's own recursion already emitted its own in-halves; this hop
614
+ // forwards the source's `widened` flag, so it forwards an approximate
615
+ // widenReason too, matching the SAME documented-approximate
616
+ // 'unresolved-call' convention step()'s `assign`/`return` hops
617
+ // already use (resolveExprIdentities's return shape has no real
618
+ // reason string to forward — see Decision 3's deviation note; a full
619
+ // fix needs a broader threading change, out of this task's scope).
620
+ // Note the known limitation this case already documents: it does not
621
+ // write to state, so there is no write-out hop here — an in-half with
622
+ // no out-half that is NOT a loss.
623
+ if (ctx?.recordHop) {
624
+ const assignExprWidenReason = r.widened && r.flat.size > 0 ? 'unresolved-call' : null;
625
+ for (const id of r.flat) {
626
+ ctx.recordHop({
627
+ kind: 'production', subKind: 'assign-expr',
628
+ fromPath: null, toPath: null, dataElementId: id,
629
+ syntacticPath: null, widenReason: assignExprWidenReason, lossReason: null,
630
+ });
631
+ }
632
+ }
633
+ return { flat: r.flat, byPath: r.byPath, widened: r.widened };
634
+ }
635
+
636
+ // Path provenance (§10.1): the switch's own `default` — no known case
637
+ // matched, nothing was resolved, so (same reasoning as `unknown` above)
638
+ // nothing is being dropped and there is nothing to record.
639
+ default:
640
+ return noIdentity();
641
+ }
642
+ }
643
+
644
+ function step(node, stateIn, widenings, ctx) {
645
+ switch (node.kind) {
646
+ case 'assign': {
647
+ if (typeof node.target !== 'string') {
648
+ // Assignment-expression-form destructuring (`({a} = obj)`, as opposed
649
+ // to `const {a} = obj`) is lowered by the real parser into a single
650
+ // `assign` node whose `target` is the raw pattern object, not a
651
+ // string path — scanner/src/dataflow/engine.js (the sibling taint
652
+ // engine) already guards this exact shape, a bug it once hit; this
653
+ // package inherited the same gap until a final review found it.
654
+ // Writing to a stringified pattern object as a fabricated path key
655
+ // would silently collide across every unrelated destructuring
656
+ // assignment in the function, merging their fields together.
657
+ // Correctly tracking this form would require walking the pattern the
658
+ // same way the parser already does for declaration-form destructuring
659
+ // — deferred (matching the sibling engine's own precedent of skipping
660
+ // rather than guessing) rather than attempted here.
661
+ //
662
+ // Path provenance (Sub-project C, increment 2, Task 2, §10.2
663
+ // `assign`/target-not-a-string row): a genuine LOSS site —
664
+ // `lossReason: 'unsupported-target'`. The design doc's own dated
665
+ // correction to this row is explicit that resolving `node.source`
666
+ // is NOT merely discarded computation once a recorder IS present:
667
+ // running the full resolveExprIdentities tree on node.source
668
+ // genuinely EMITS real production/selection in-half hops for
669
+ // whatever it reads — these correctly join with this row's own
670
+ // loss marker to show "this data was read here, then lost, because
671
+ // the target couldn't be represented" (arguably necessary for
672
+ // §18.4's transparency requirement, not incidental). If
673
+ // node.source resolves no identity at all, none of that fires and
674
+ // there is nothing to lose.
675
+ //
676
+ // CORRECTION (final whole-branch review): this resolve must run
677
+ // UNCONDITIONALLY, not gated on `ctx?.recordHop` — an earlier
678
+ // version gated it, reasoning it was "extra, discarded computation"
679
+ // per Decision 1. That reasoning is wrong for THIS call specifically:
680
+ // resolveExprIdentities is not side-effect-free when
681
+ // ctx.resolveCallSummary is present (interprocedural mode) — its
682
+ // `call` case can trigger FieldIdentitySummaryCache.compute() for a
683
+ // callee, which registers a context against that function's
684
+ // distinct-context cap (summaries.js). Gating the resolve on
685
+ // ctx?.recordHop therefore meant a recorder's mere PRESENCE could
686
+ // consume cap budget a non-recorder run never would, silently
687
+ // changing a LATER, unrelated call site's own resolution once the
688
+ // cap is hit — a real, reproduced violation of this whole
689
+ // increment's own "byte-identical with no recorder" acceptance bar,
690
+ // in the unsound direction (attaching a recorder made the analysis
691
+ // LOSE an identity a no-recorder run kept). The fix: resolve
692
+ // unconditionally (matching the sibling `assign` branches below,
693
+ // which already do this), and gate ONLY the hop emission on
694
+ // `ctx?.recordHop` — mirroring Decision 1's own "extra computation
695
+ // is fine to skip, but only when it is genuinely side-effect-free"
696
+ // intent, which this call never actually satisfied.
697
+ const resolved = resolveExprIdentities(stateIn, node.source, ctx);
698
+ if (ctx?.recordHop) {
699
+ const allIds = new Set([...residualFlat(resolved.flat, resolved.byPath), ...[...resolved.byPath.values()].flatMap((s) => [...s])]);
700
+ for (const id of allIds) {
701
+ ctx.recordHop({
702
+ kind: 'write-out', subKind: 'assign',
703
+ fromPath: null, toPath: null, dataElementId: id,
704
+ syntacticPath: null, widenReason: null, lossReason: 'unsupported-target',
705
+ });
706
+ }
707
+ }
708
+ return { state: stateIn, returnFact: null };
709
+ }
710
+ const resolved = resolveExprIdentities(stateIn, node.source, ctx);
711
+ if (pathHasWildcard(node.target)) {
712
+ // A computed-key write (`obj[k] = ...`) must be a WEAK update (add to
713
+ // whatever the container already carries, never clear it first) — a
714
+ // strong update here would treat two genuinely different (but
715
+ // statically indistinguishable) write locations as the same location,
716
+ // silently deleting an earlier write. scanner/src/dataflow/engine.js's
717
+ // `_addPathAliasAware` already established this exact precedent for
718
+ // the sibling taint engine; this mirrors it. Round 6: generalized to
719
+ // an INTERIOR wildcard too (e.g. 'store.*.name' from
720
+ // `store[k].name = ...`) — round 5's trailing-only check
721
+ // (`endsWith('.*')`) let this fall through to a strong update one
722
+ // path segment deeper, recreating round 5's own bug.
723
+ const containerPath = definitePrefixBeforeWildcard(node.target);
724
+ let wState = stateIn;
725
+ if (containerPath) {
726
+ const allIds = new Set([...residualFlat(resolved.flat, resolved.byPath), ...[...resolved.byPath.values()].flatMap((s) => [...s])]);
727
+ // Path provenance (§10.2 `assign`/wildcard-target row): one
728
+ // write-out/assign-weak record per (containerPath, id) — toPath
729
+ // is the DEFINITE PREFIX before the wildcard (Decision 5, same
730
+ // discipline as `member`'s wildcard-read branch), never the raw
731
+ // '*'-containing `node.target`, which is carried instead on
732
+ // `syntacticPath`. This is a WEAK update (no kill), so
733
+ // `widenReason: 'dynamic-property-key'` — mirrors the widenings
734
+ // ledger push below, not a new/better reason.
735
+ for (const id of allIds) {
736
+ wState = addIdentity(wState, containerPath, id);
737
+ if (ctx?.recordHop) {
738
+ ctx.recordHop({
739
+ kind: 'write-out', subKind: 'assign-weak',
740
+ fromPath: null, toPath: containerPath, dataElementId: id,
741
+ syntacticPath: node.target, widenReason: 'dynamic-property-key', lossReason: null,
742
+ });
743
+ }
744
+ }
745
+ if (allIds.size > 0) {
746
+ widenings.push({ atPath: containerPath, dataElementIds: [...allIds], reason: 'dynamic-property-key', line: node.line });
747
+ }
748
+ }
749
+ return { state: wState, returnFact: null };
750
+ }
751
+ let state = removeIdentitiesAt(stateIn, node.target);
752
+ // Write every byPath entry at its own sub-path, and write only the
753
+ // RESIDUAL (whatever in `flat` isn't already captured by byPath's
754
+ // union) coarsely at the target's own root — never the full `flat`
755
+ // when byPath has structure, since that recreates the coarse-merge
756
+ // bug (FR-301). This single rule subsumes round 1's explicit
757
+ // byPath-empty-vs-nonempty branching: an empty residual is a no-op, so
758
+ // a plain value (byPath empty, e.g. `user.email`) still writes its
759
+ // full `flat` at `target` exactly as before, and an object-literal or
760
+ // aliased-reference RHS (byPath populated) writes only its per-field
761
+ // entries. See DESIGN_INTRAPROCEDURAL.md §3 for the full reasoning,
762
+ // including the aliasing gap (`const copy = user;`) this closes.
763
+ //
764
+ // Path provenance (Sub-project C, increment 1, Task 2 instrumented
765
+ // site 3 of 4): one write-out/assign out-half per `addIdentity` call,
766
+ // at the EXACT path just written (never `node.target` alone for a
767
+ // byPath entry — DESIGN_PATH_PROVENANCE.md §10.1 flags recording the
768
+ // coarser `node.target` here as "the most likely C2 mistake", since it
769
+ // would mismatch the granularity every read hop uses and disconnect
770
+ // the DAG). `widenReason` mirrors the SAME (documented-approximate)
771
+ // 'unresolved-call' label the widenings ledger below already uses in
772
+ // this exact branch — not a new/better reason, just the one this
773
+ // task's scope has evidence for; see the CLAUDE.md note on that
774
+ // ledger's own known mislabeling, which this hop record inherits
775
+ // rather than fixes (fixing it needs resolveExprIdentities's return
776
+ // shape to thread a real reason string, out of Task 2's scope).
777
+ const residual = residualFlat(resolved.flat, resolved.byPath);
778
+ const assignWidenReason = resolved.widened && resolved.flat.size > 0 ? 'unresolved-call' : null;
779
+ for (const id of residual) {
780
+ state = addIdentity(state, node.target, id);
781
+ if (ctx?.recordHop) {
782
+ ctx.recordHop({
783
+ kind: 'write-out', subKind: 'assign',
784
+ fromPath: null, toPath: node.target, dataElementId: id,
785
+ syntacticPath: null, widenReason: assignWidenReason, lossReason: null,
786
+ });
787
+ }
788
+ }
789
+ for (const [subPath, ids] of resolved.byPath) {
790
+ for (const id of ids) {
791
+ const toPath = `${node.target}.${subPath}`;
792
+ state = addIdentity(state, toPath, id);
793
+ if (ctx?.recordHop) {
794
+ ctx.recordHop({
795
+ kind: 'write-out', subKind: 'assign',
796
+ fromPath: null, toPath, dataElementId: id,
797
+ syntacticPath: null, widenReason: assignWidenReason, lossReason: null,
798
+ });
799
+ }
800
+ }
801
+ }
802
+ if (resolved.widened && resolved.flat.size > 0) {
803
+ widenings.push({ atPath: node.target, dataElementIds: [...resolved.flat], reason: 'unresolved-call', line: node.line });
804
+ }
805
+ return { state, returnFact: null };
806
+ }
807
+
808
+ case 'call': {
809
+ // This CFG node kind is a bare call-statement (its return value is
810
+ // discarded, e.g. `logEvent(user);`), evaluated here purely to flag
811
+ // widening on its ARGUMENT expressions. `ctx` is threaded through
812
+ // for consistency with every other resolveExprIdentities call site
813
+ // in this file — an argument can itself be a nested, RESOLVED call
814
+ // expression (e.g. `logEvent(copyEmail(user))`), and without `ctx`
815
+ // that nested call would spuriously widen on the resolved callee's
816
+ // full argument set instead of using its real (possibly narrower)
817
+ // return facts, exactly the same imprecision increment B2 closes for
818
+ // the `assign`/`return` cases. This does not change what this case
819
+ // itself does with the result (still only checks `r.flat` for a
820
+ // widening event) — it only lets a nested `call` sub-expression
821
+ // resolve precisely when `ctx` makes that possible.
822
+ for (const arg of node.args ?? []) {
823
+ const r = resolveExprIdentities(stateIn, arg, ctx);
824
+ if (r.flat.size > 0) {
825
+ widenings.push({ atPath: null, dataElementIds: [...r.flat], reason: 'unresolved-call-arg', line: node.line });
826
+ }
827
+ // Path provenance (§10.2 `call` bare-statement row): one
828
+ // write-out/call-arg record per id in the argument's resolved
829
+ // identity set — the value LEAVES the analysis via an argument.
830
+ // This is an ESCAPE, not a loss (the natural sink-attachment point
831
+ // for Sub-project D), so no lossReason/widenReason here — mirrors
832
+ // `return`'s own write-out hop shape (toPath deliberately null).
833
+ if (ctx?.recordHop) {
834
+ for (const id of r.flat) {
835
+ ctx.recordHop({
836
+ kind: 'write-out', subKind: 'call-arg',
837
+ fromPath: null, toPath: null, dataElementId: id,
838
+ syntacticPath: null, widenReason: null, lossReason: null,
839
+ });
840
+ }
841
+ }
842
+ }
843
+ return { state: stateIn, returnFact: null };
844
+ }
845
+
846
+ case 'return': {
847
+ const resolved = node.value ? resolveExprIdentities(stateIn, node.value, ctx) : { flat: new Set(), widened: false };
848
+ if (resolved.widened && resolved.flat.size > 0) {
849
+ widenings.push({ atPath: null, dataElementIds: [...resolved.flat], reason: 'unresolved-call', line: node.line });
850
+ }
851
+ // Path provenance (Sub-project C, increment 1, Task 2 instrumented
852
+ // site 4 of 4): one write-out/return out-half per id, toPath
853
+ // deliberately null (a return exits the function, it doesn't land at
854
+ // a path) — never a fabricated pseudo-path like '@return'
855
+ // (DESIGN_PATH_PROVENANCE.md §3/§10.1: mixing a fabricated token into
856
+ // the endpoint namespace is exactly Decision 5's forbidden bug class).
857
+ // C3/C4 identify a function exit by
858
+ // `kind === 'write-out' && subKind === 'return' && toPath === null`.
859
+ if (ctx?.recordHop) {
860
+ const returnWidenReason = resolved.widened && resolved.flat.size > 0 ? 'unresolved-call' : null;
861
+ for (const id of resolved.flat) {
862
+ ctx.recordHop({
863
+ kind: 'write-out', subKind: 'return',
864
+ fromPath: null, toPath: null, dataElementId: id,
865
+ syntacticPath: null, widenReason: returnWidenReason, lossReason: null,
866
+ });
867
+ }
868
+ }
869
+ return { state: stateIn, returnFact: resolved.flat };
870
+ }
871
+
872
+ case 'entry':
873
+ case 'exit':
874
+ case 'noop':
875
+ case 'loop-header':
876
+ case 'if':
877
+ case 'throw':
878
+ case 'unknown':
879
+ default:
880
+ return { state: stateIn, returnFact: null };
881
+ }
882
+ }
883
+
884
+ // Mirrors scanner/src/dataflow/engine.js's analyzeFunction ITER_BUDGET
885
+ // (Premortem 2R4.4 / 2R-9): the join-then-changed-check below is already
886
+ // sound and terminating for any well-formed CFG (state only grows via
887
+ // monotonic union over a finite universe of paths/ids per function, so the
888
+ // fixed point is reached in finitely many steps — verified by hand-tracing
889
+ // the loop-back-edge test below, which converges in 6 iterations with no
890
+ // re-visits needed). This cap is purely a defensive backstop against a
891
+ // malformed/generated CFG, matching the real engine's documented posture,
892
+ // not a load-bearing part of the termination proof.
893
+ const ITER_BUDGET = 5000;
894
+
895
+ export function analyzeFunctionFieldIdentity(fn, entryState, ctx) {
896
+ const nodes = fn.cfg.nodes;
897
+ const work = [fn.cfg.entry];
898
+ const inStates = new Map([[fn.cfg.entry, entryState]]);
899
+ const outStates = new Map();
900
+ const widenings = [];
901
+ const returnFactsByNode = new Map();
902
+ let iterations = 0;
903
+ // Path provenance (Sub-project C, increment 1, Decision 7.2): stamped
904
+ // once for the whole analysis, since resolveExprIdentities never sees the
905
+ // enclosing function.
906
+ const scope = fn.qid ?? null;
907
+ // Path provenance (Sub-project C, increment 3, §13.3): computed once per
908
+ // analysis run, alongside `scope` — never per hop. `null` when no
909
+ // recorder is present, matching every other conditional field this file
910
+ // stamps. `hashState(entryState)` is the exact primitive
911
+ // FieldIdentitySummaryCache already keys on (summaries.js's `_key`), so
912
+ // two hops share a `context` iff the cache would consider them the same
913
+ // context.
914
+ const context = ctx?.recordHop ? hashState(entryState) : null;
915
+
916
+ while (work.length) {
917
+ if (++iterations > ITER_BUDGET) break;
918
+ const nid = work.shift();
919
+ const node = nodes[nid];
920
+ if (!node) continue;
921
+ const incoming = inStates.get(nid) ?? emptyState();
922
+ // Path provenance (Decision 7.2): `nodeId`/`line` are stamped here, once
923
+ // per node visit, from the worklist's own map key `nid` — NEVER from
924
+ // `node.id` (hand-built CFG fixtures, e.g. test/lineage/engine-walker
925
+ // .test.js, set no `id` field on their nodes at all; the real parser
926
+ // does set `node.id`, equal to the map key, so `nid` is correct for
927
+ // both). With no recorder present, `stepCtx` is the SAME `ctx` reference
928
+ // — no allocation, nothing for a backward-compatibility test to catch
929
+ // (Decision 7.2's "true by construction" point).
930
+ const stepCtx = ctx?.recordHop
931
+ ? { ...ctx, recordHop: (h) => ctx.recordHop({
932
+ scope, nodeId: nid, line: node.line ?? null,
933
+ context, peerScope: null, peerContext: null,
934
+ ...h,
935
+ }) }
936
+ : ctx;
937
+ const { state: out, returnFact } = step(node, incoming, widenings, stepCtx);
938
+ if (returnFact && returnFact.size > 0) {
939
+ // Union onto any existing fact for this node rather than pushing a
940
+ // new array entry every visit — see Fix 2 in the final whole-branch
941
+ // review: a `return` node revisited by the worklist (its incoming
942
+ // join hadn't settled on an earlier visit) used to produce a second,
943
+ // stale, strictly-weaker entry for the same nodeId/line. A consumer
944
+ // doing `returnFacts.find(f => f.nodeId === X)` would silently get
945
+ // the wrong, under-approximating answer. Mirrors how outStates/
946
+ // inStates already union via joinStates below.
947
+ const existing = returnFactsByNode.get(nid);
948
+ const identities = existing ? new Set([...existing.identities, ...returnFact]) : new Set(returnFact);
949
+ returnFactsByNode.set(nid, { line: node.line, identities });
950
+ }
951
+
952
+ const prevOut = outStates.get(nid);
953
+ const merged = prevOut ? joinStates(prevOut, out) : out;
954
+ if (!prevOut || !statesEqual(prevOut, merged)) {
955
+ outStates.set(nid, merged);
956
+ for (const succ of node.succ ?? []) {
957
+ const prevIn = inStates.get(succ);
958
+ const newIn = prevIn ? joinStates(prevIn, merged) : merged;
959
+ if (!prevIn || !statesEqual(prevIn, newIn)) {
960
+ inStates.set(succ, newIn);
961
+ work.push(succ);
962
+ }
963
+ }
964
+ }
965
+ }
966
+
967
+ const returnFacts = [...returnFactsByNode.entries()]
968
+ .map(([nodeId, fact]) => ({ nodeId, line: fact.line, identities: fact.identities }));
969
+
970
+ const exitState = outStates.get(fn.cfg.exit) ?? emptyState();
971
+ const mutatedParams = new Map();
972
+ for (const param of fn.params) {
973
+ // identitiesAt now aggregates both ancestor coverage AND descendant
974
+ // coverage (see field-identity.js), so this correctly reports every
975
+ // identity recorded on the param's own path or any field under it —
976
+ // e.g. `target.copiedEmail = user.email` is attributed to `target`.
977
+ //
978
+ // NOTE — this is sound but NOT "write-only": if the param's entry-state
979
+ // facts were never touched at all (a purely read-only param), those
980
+ // original facts still survive unchanged into exitState and are
981
+ // reported here too. `mutatedParams` means "what this param carries at
982
+ // function exit" (a safe over-approximation a caller can rely on never
983
+ // under-reporting), not "was this param's value replaced by an
984
+ // assignment." Do not rename this without checking every consumer's
985
+ // expectations first — Sub-project B is the intended reader of this
986
+ // exact contract.
987
+ const ids = identitiesAt(exitState, param);
988
+ if (ids.size > 0) mutatedParams.set(param, ids);
989
+ }
990
+
991
+ return { exitState, returnFacts, mutatedParams, widenings };
992
+ }