@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.5

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 (195) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +779 -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 +379 -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 +841 -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/dist/frontend/index.html +21 -0
  72. package/dist/frontend/src/app.js +176 -0
  73. package/dist/frontend/src/components/evidence-inspector.js +141 -0
  74. package/dist/frontend/src/components/filter-rail.js +119 -0
  75. package/dist/frontend/src/components/query-bar.js +126 -0
  76. package/dist/frontend/src/data/flagship-graph.js +1460 -0
  77. package/dist/frontend/src/export-entry.js +36 -0
  78. package/dist/frontend/src/lib/api-client.js +92 -0
  79. package/dist/frontend/src/lib/contrast.js +34 -0
  80. package/dist/frontend/src/lib/dom.js +24 -0
  81. package/dist/frontend/src/lib/escape-html.js +16 -0
  82. package/dist/frontend/src/lib/flow-path.js +40 -0
  83. package/dist/frontend/src/lib/focus-controls.js +149 -0
  84. package/dist/frontend/src/lib/protection-visual.js +46 -0
  85. package/dist/frontend/src/lib/query-language.js +240 -0
  86. package/dist/frontend/src/lib/row-filters.js +43 -0
  87. package/dist/frontend/src/lib/state.js +84 -0
  88. package/dist/frontend/src/main.js +83 -0
  89. package/dist/frontend/src/shell.js +184 -0
  90. package/dist/frontend/src/views/architecture-view.js +798 -0
  91. package/dist/frontend/src/views/inventory-view.js +292 -0
  92. package/dist/frontend/src/views/privacy-view.js +172 -0
  93. package/dist/frontend/src/views/trace-view.js +206 -0
  94. package/dist/frontend/styles/architecture-view.css +93 -0
  95. package/dist/frontend/styles/filter-rail.css +34 -0
  96. package/dist/frontend/styles/inspector.css +69 -0
  97. package/dist/frontend/styles/inventory-view.css +74 -0
  98. package/dist/frontend/styles/privacy-view.css +86 -0
  99. package/dist/frontend/styles/query-bar.css +107 -0
  100. package/dist/frontend/styles/shell.css +155 -0
  101. package/dist/frontend/styles/tokens.css +128 -0
  102. package/dist/frontend/styles/trace-view.css +95 -0
  103. package/package.json +13 -6
  104. package/src/dataflow/CLAUDE.md +1 -1
  105. package/src/dataflow/catalog.js +42 -0
  106. package/src/dataflow/orm-write-catalog.js +175 -0
  107. package/src/engine.js +92 -1
  108. package/src/ir/CLAUDE.md +1 -0
  109. package/src/ir/chrome-probe.mjs +150 -0
  110. package/src/ir/parser-js.js +94 -7
  111. package/src/lineage/CLAUDE.md +1203 -0
  112. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  113. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  114. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  115. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  116. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  117. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  118. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  119. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  120. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  121. package/src/lineage/classification.js +56 -0
  122. package/src/lineage/coverage.js +658 -0
  123. package/src/lineage/cross-repo-link.js +107 -0
  124. package/src/lineage/dataflow-graph.schema.json +184 -0
  125. package/src/lineage/decision-story.js +206 -0
  126. package/src/lineage/drift-policy.js +279 -0
  127. package/src/lineage/driver.js +135 -0
  128. package/src/lineage/engine.js +992 -0
  129. package/src/lineage/export-briefing.js +628 -0
  130. package/src/lineage/export-csv.js +62 -0
  131. package/src/lineage/export-json.js +238 -0
  132. package/src/lineage/export-privacy.js +258 -0
  133. package/src/lineage/federation-loader.js +111 -0
  134. package/src/lineage/field-identity.js +78 -0
  135. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  136. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  137. package/src/lineage/flow-grade.js +221 -0
  138. package/src/lineage/governance-edit.js +169 -0
  139. package/src/lineage/graph-builder.js +1114 -0
  140. package/src/lineage/graph-diff.js +431 -0
  141. package/src/lineage/graph-snapshot.js +180 -0
  142. package/src/lineage/handling-analyzer.js +168 -0
  143. package/src/lineage/ids.js +349 -0
  144. package/src/lineage/impact-assessment.js +76 -0
  145. package/src/lineage/impact-engine.js +268 -0
  146. package/src/lineage/index.js +281 -0
  147. package/src/lineage/language-coverage-tiers.js +58 -0
  148. package/src/lineage/obligation-mapping.js +126 -0
  149. package/src/lineage/obligation-predicates.js +235 -0
  150. package/src/lineage/observation-adapters.js +282 -0
  151. package/src/lineage/observation-correlation.js +622 -0
  152. package/src/lineage/observation-store.js +497 -0
  153. package/src/lineage/path-query.js +410 -0
  154. package/src/lineage/path-store.js +400 -0
  155. package/src/lineage/protection.js +53 -0
  156. package/src/lineage/recipient-profile.js +192 -0
  157. package/src/lineage/recipient-registry.js +394 -0
  158. package/src/lineage/redact-graph.js +224 -0
  159. package/src/lineage/remediation.js +417 -0
  160. package/src/lineage/resolve-destination.js +91 -0
  161. package/src/lineage/runtime-observation.js +464 -0
  162. package/src/lineage/scenario-diff.js +84 -0
  163. package/src/lineage/scenario-engine.js +251 -0
  164. package/src/lineage/scenario.js +101 -0
  165. package/src/lineage/schema.js +167 -0
  166. package/src/lineage/sink-registry.js +427 -0
  167. package/src/lineage/source-registry.js +357 -0
  168. package/src/lineage/source-seeding.js +212 -0
  169. package/src/lineage/summaries.js +590 -0
  170. package/src/lineage/transform-catalog.js +397 -0
  171. package/src/lineage/transit-protection.js +150 -0
  172. package/src/lineage/validate.js +285 -0
  173. package/src/mcp/CLAUDE.md +7 -1
  174. package/src/mcp/dataflow-tools.js +160 -0
  175. package/src/mcp/server.js +1 -1
  176. package/src/mcp/tools.js +2 -1
  177. package/src/pipeline/scan-health.js +19 -1
  178. package/src/posture/CLAUDE.md +19 -0
  179. package/src/posture/artifact-registry.js +52 -0
  180. package/src/posture/auditor-walkthrough.js +76 -0
  181. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  182. package/src/posture/obligation-evidence-pack.js +202 -0
  183. package/src/posture/remediation-ledger.js +337 -0
  184. package/src/server/CLAUDE.md +47 -0
  185. package/src/server/graph-loader.js +141 -0
  186. package/src/server/http-server.js +325 -0
  187. package/src/server/routes.js +129 -0
  188. package/src/server/security.js +111 -0
  189. package/src/server/static-assets.js +144 -0
  190. package/src/shared/frontend-root.js +52 -0
  191. package/dist/11.index.js +0 -353
  192. package/dist/259.index.js +0 -975
  193. package/dist/317.index.js +0 -300
  194. package/dist/609.index.js +0 -741
  195. package/dist/838.index.js +0 -152
@@ -0,0 +1,628 @@
1
+ # Intraprocedural Field-Identity Engine — Design Record
2
+
3
+ Scope: Sub-project A of Milestone 1 (see
4
+ `docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-lineage-engine-scoping.md`).
5
+ Single-function analysis only. No interprocedural resolution, no path DAG,
6
+ no registry integration.
7
+
8
+ ## 1. State shape
9
+
10
+ `Map<accessPath: string, Set<dataElementId: string>>` — for each access path
11
+ reachable in a function, the set of data-element IDs it carries. This is the
12
+ direct fix for FR-301: the existing taint engine's state
13
+ (`scanner/src/dataflow/access-paths.js`'s `Set<accessPath>`, boolean per
14
+ path) cannot distinguish "this path carries data element X" from "this path
15
+ carries data element Y" — only "this path is tainted." The existing
16
+ interprocedural summary cache (`scanner/src/dataflow/summaries.js`) is
17
+ explicitly documented as parameter-granularity only ("`f(obj)` with obj.foo
18
+ tainted is treated the same as obj.bar tainted") — reusing that state shape
19
+ would silently violate FR-301 the first time a benchmark case exercised two
20
+ distinct fields on the same object parameter. This is a hard fork, decided
21
+ here, not left to a later task to discover partway through implementation.
22
+
23
+ ## 2. Reuse boundary (binding on every later task and sub-project)
24
+
25
+ **Reused directly, as pure utilities, unmodified:**
26
+ - `scanner/src/dataflow/access-paths.js`'s `accessPathOf`,
27
+ `pathIsCoveredByPrefix`, `isCoveredBy` — all operate purely on path
28
+ strings, no taint state involved. Confirmed by reading the file: none of
29
+ its exports take or return taint-specific state.
30
+ - The real JS/TS IR shape from `scanner/src/ir/parser-js.js` — consumed
31
+ as-is, never reimplemented.
32
+
33
+ **Structurally mirrored (same algorithm, new state type), never imported:**
34
+ - `scanner/src/dataflow/engine.js`'s `analyzeFunction` worklist loop (forward
35
+ dataflow analysis, explicit worklist, join-via-repeated-merge at CFG
36
+ branch-rejoin points, no separate "join node" concept — a rejoin is just
37
+ a point that receives more than one predecessor's merged state over the
38
+ worklist's lifetime). This package's own `analyzeFunctionFieldIdentity`
39
+ (Task 4) copies this ALGORITHM, not this file's code or state.
40
+
41
+ **Never reused, must be reimplemented for field-identity semantics:**
42
+ - `access-paths.js`'s `addPath`/`removePathAndDescendants`/`joinSets` — these
43
+ operate on `Set<string>` (presence/absence). Field identity needs
44
+ Map-shaped equivalents that union *label sets* per path on join, not just
45
+ path presence. See `field-identity.js` (Task 2).
46
+ - `scanner/src/dataflow/summaries.js`'s `SummaryCache` — out of scope for
47
+ this plan (interprocedural is Sub-project B), but its `_hashState`
48
+ approach (canonicalize then hash) is worth mirroring conceptually once
49
+ Sub-project B needs a cache key over field-identity state; `field-identity.js`
50
+ ships a `hashState` now specifically so Sub-project B doesn't have to
51
+ retrofit one onto an already-shipped state shape.
52
+
53
+ **Forbidden absolutely:** importing `scanner/src/dataflow/engine.js` or
54
+ touching its live taint `Set<accessPath>` state. Per
55
+ `scanner/src/lineage/CLAUDE.md` and PRD §3.1/§18.1.
56
+
57
+ ## 3. Ancestor/descendant semantics (bidirectional — corrected from the original one-directional design)
58
+
59
+ `identitiesAt(state, path)` aggregates in BOTH directions:
60
+
61
+ - **Ancestor coverage** (original): a coarser recorded fact about a
62
+ container implies the same fact about anything under it, when nothing
63
+ more specific overrides it — `identitiesAt(state, 'obj.email')` includes
64
+ any identity recorded at `'obj'` itself. Mirrors `access-paths.js`'s
65
+ `isCoveredBy`.
66
+ - **Descendant coverage** (added by the corrected design below): asking
67
+ about a container AS A WHOLE aggregates everything recorded under it —
68
+ `identitiesAt(state, 'obj')` includes an identity recorded only at
69
+ `'obj.email'`. The object legitimately carries every field's identity
70
+ when read as a whole (`return obj` after `obj = {email: X, ssn: Y}` must
71
+ see both).
72
+
73
+ This does NOT reintroduce cross-field leakage: querying `obj.email` never
74
+ picks up a sibling path like `obj.ssn` — neither is a prefix of the other,
75
+ so descendant coverage only ever aggregates when the query path is an
76
+ actual ancestor of the recorded path, never across siblings.
77
+
78
+ ### Corrected design
79
+
80
+ The original design (above, before this note) was one-directional:
81
+ ancestor-visible-from-descendant only, with the reverse deliberately
82
+ excluded ("asking about the whole object doesn't imply you learn a fact
83
+ that was only ever established about one of its fields"). This was wrong,
84
+ and the final whole-branch review (composing pieces no single task review
85
+ had reason to combine) found why: `assign`'s object-literal handling
86
+ combined with the one-directional `identitiesAt` forced a choice between
87
+ two broken options —
88
+
89
+ (a) write the object literal's resolved identities BOTH at each field's
90
+ own sub-path (`rec.email`, `rec.ssn`) AND as a flat union at the
91
+ container's own path (`rec`) — needed so a later whole-object read
92
+ (`return rec`) could see every field, since one-directional
93
+ `identitiesAt` couldn't look downward. But this write is exactly
94
+ FR-301's violation: `identitiesAt(state, 'rec.email')` then
95
+ ancestor-inherits from the coarse `rec` entry and returns
96
+ `{email, ssn}` — both fields, merged, for a query that asked about
97
+ only one. This was the actual bug (see `engine.js`'s pre-fix `assign`
98
+ case), and it was a plan-authoring bug, not an implementer error — the
99
+ flat-union-at-root write was specified verbatim in this project's own
100
+ original brief.
101
+
102
+ (b) drop the flat-union-at-root write and keep only the per-field
103
+ sub-path writes — this fixes (a) but breaks `return rec`: with the
104
+ old one-directional `identitiesAt`, querying `rec` finds nothing,
105
+ since no identity was ever recorded at `rec` itself.
106
+
107
+ The bidirectional design resolves both without a redundant coarse write:
108
+ per-field writes alone are sufficient (`assign`'s object-literal case now
109
+ writes ONLY `rec.email`/`rec.ssn`, never `rec`), `identitiesAt(state,
110
+ 'rec.email')` correctly returns only `{email}` (no ancestor entry exists
111
+ to leak from), and `identitiesAt(state, 'rec')` correctly returns
112
+ `{email, ssn}` via descendant aggregation. Sibling paths remain correctly
113
+ isolated from each other throughout, since aggregation only ever follows
114
+ an actual prefix relationship.
115
+
116
+ Unlike boolean taint (where recording a fact at a coarser path makes any
117
+ existing finer-path fact redundant and `access-paths.js`'s `addPath` prunes
118
+ it away), field-identity state does **not** prune a descendant entry when an
119
+ ancestor gains a *different* identity — `obj` carrying data element X and
120
+ `obj.email` carrying data element Y are both real, independently meaningful
121
+ facts that must coexist (this coexistence IS FR-301's core requirement).
122
+ Pruning is deliberately NOT implemented as an optimization in this first
123
+ slice — `field-identity.js`'s state can carry redundant entries (an
124
+ ancestor and descendant both recording the same identity); this affects
125
+ efficiency, never correctness, since `identitiesAt`'s union already handles
126
+ it. Left as a documented, deferred optimization, not built now, per this
127
+ project's "don't design for hypothetical future requirements" convention.
128
+
129
+ ### The residual principle (round 2 — closes an aliasing gap round 1 left open)
130
+
131
+ The bidirectional `identitiesAt` fix above (round 1) was necessary but not
132
+ sufficient. A final re-review of round 1 found the exact same coarse-merge
133
+ bug survives through one level of variable aliasing, because round 1 only
134
+ applied the "write per-field, not a coarse root value" rule at the object-
135
+ literal call site — `resolveExprIdentities`'s `ident`/`member` case still
136
+ unconditionally returned an empty `byPath`, even when the state genuinely
137
+ had per-field structure recorded under the resolved path. Concretely:
138
+
139
+ ```js
140
+ function combine(user) {
141
+ const copy = user; // copy = user is a plain alias, not an object
142
+ // literal, so round 1's `assign` fix never
143
+ // triggers: resolveExprIdentities(state, user)
144
+ // returned byPath: Map() (always empty for
145
+ // ident/member), so `assign` fell into the
146
+ // byPath-empty branch and wrote the FULL flat
147
+ // union ({email, ssn}) at `copy`'s own path —
148
+ // exactly the coarse-root-write bug round 1
149
+ // fixed for object literals, recreated here.
150
+ const e = copy.email; // identitiesAt('copy.email') then ancestor-
151
+ // inherits from that coarse `copy` entry and
152
+ // returns {email, ssn} — both fields.
153
+ return e; // WRONG: should be {email} only.
154
+ }
155
+ ```
156
+
157
+ This was measurably *worse* than the pre-round-1 code for this exact shape:
158
+ before round 1, a one-directional `identitiesAt` silently dropped this flow
159
+ entirely (safe but useless); round 1's bidirectional `identitiesAt` can now
160
+ correctly resolve `user`'s full field set on read, but `assign` had nowhere
161
+ to put that structured information except a coarse merge — going from
162
+ "silently drops the flow" to "silently merges distinct fields", the more
163
+ serious of FR-301's two failure modes.
164
+
165
+ The fix generalizes round 1's rule into one helper, `residualFlat(flat,
166
+ byPath)` (`engine.js`), applied consistently everywhere a `flat`+`byPath`
167
+ pair is written to a target/key — not special-cased at `assign` alone.
168
+ Two concrete applications from round 2:
169
+
170
+ - `resolveExprIdentities`'s `ident`/`member` case now populates `byPath`
171
+ from the state's recorded descendants of the resolved path — an alias
172
+ like `user` (with `user.email`/`user.ssn` recorded) is now structurally
173
+ indistinguishable from a fresh object literal `{email: ..., ssn: ...}`
174
+ for every downstream consumer of `byPath`.
175
+ - `resolveExprIdentities`'s `object` case writes `residualFlat(r.flat,
176
+ r.byPath)` — not the full `r.flat` — coarsely at a property's own key.
177
+ This closes a *deeper, compounding* version of the same bug that the
178
+ point above alone would reintroduce: `{ a: someAliasedObject }`, where
179
+ `someAliasedObject` is itself an alias with `byPath` structure. Without
180
+ this, the coarse write at key `a` would duplicate what the nested
181
+ `a.x`/`a.y` entries already separate, one level deeper than round 1 ever
182
+ checked.
183
+ - `step()`'s `assign` case collapses round 1's explicit
184
+ byPath-empty-vs-nonempty branching into one rule: write every `byPath`
185
+ entry at its own sub-path, and write `residualFlat(resolved.flat,
186
+ resolved.byPath)` — whatever isn't already captured by that
187
+ structure — coarsely at the target's own root. An empty residual is a
188
+ no-op, so this naturally subsumes both of round 1's cases without
189
+ branching on them explicitly.
190
+
191
+ **This was originally written up as "applied consistently at exactly three
192
+ sites."** That was a mistake independent of whether the three were
193
+ correctly identified: a hand-counted enumeration reads as a closed,
194
+ completed list, and a fixed-count claim is exactly what let a fourth,
195
+ fifth, and sixth site (`union`, `logical`, `assign-expr` — see round 3
196
+ below) go unexamined through two further rounds of scrutiny. The durable
197
+ statement is the general invariant in "The structure-preserving vs.
198
+ structure-flattening invariant (round 3)" below, not a count. Treat any
199
+ future "N sites" phrasing in this document as a bug in the document.
200
+
201
+ `identitiesAt`'s read-side logic (the bidirectional aggregation from round
202
+ 1) is untouched by round 2 or round 3 — only the *write* side (what gets
203
+ recorded by `assign`, and how `resolveExprIdentities` reports structure to
204
+ it) has changed. A whole-object read through an alias (`const copy = user;
205
+ return copy;`) still correctly aggregates every field, exactly as round 1's
206
+ fix already guaranteed, because that guarantee never depended on `assign`
207
+ writing a coarse value — it depended on `identitiesAt`'s descendant
208
+ aggregation, which neither round 2 nor round 3 touches.
209
+
210
+ ### The structure-preserving vs. structure-flattening invariant (round 3 — closes the bug CLASS, not just individual sites)
211
+
212
+ A third re-review found the SAME coarse-merge bug survived, untouched by
213
+ rounds 1-2, in three more `resolveExprIdentities` cases: `union` (ternary),
214
+ `logical` (`||`/`&&`/`??`), and `assign-expr`. All three unconditionally
215
+ returned `byPath: new Map()` regardless of what their operands' `byPath`
216
+ contained — the same class of bug round 2 closed for `ident`/`member` and
217
+ `object`, just not yet applied here.
218
+
219
+ Confirmed via the real parser:
220
+
221
+ ```js
222
+ const c = flag ? user : other; return c.email; // → [email, ssn, name] instead of [email]
223
+ const c = user ?? {}; return c.email; // → [email, ssn] instead of [email]
224
+ const c = flag ? user : user; return c.email; // → [email, ssn] instead of [email]
225
+ ```
226
+
227
+ The third example is the sharpest proof this is a bug rather than a
228
+ legitimate approximation: both ternary branches ARE the same object, so
229
+ there is no real alternative to justify merging `user`'s own fields
230
+ together — yet the pre-fix code did exactly that, purely because the
231
+ ternary *syntax* was present.
232
+
233
+ **Why this is the SAME bug, not the (legitimately different, already-
234
+ accepted) branch-alternatives approximation**: the CFG's own branch join
235
+ (`joinStates`) already handles the equivalent
236
+ `if (flag) { c = user; } else { c = other; } return c.email;` correctly —
237
+ it unions PER PATH, so `c.email`/`c.ssn`/`c.name` stay separated even
238
+ though the join conservatively doesn't know which branch ran. The
239
+ ternary/logical form of the exact same program gave a strictly WORSE
240
+ (wrong) answer for identical semantics — that asymmetry between two
241
+ syntactic forms of the same semantics is the tell. The correct
242
+ approximation is "union per sub-path across alternatives" (exactly what
243
+ `joinStates` already does at the CFG level) — NOT "flatten everything into
244
+ one coarse blob."
245
+
246
+ Rather than enumerate a fixed site count again (see the note above on why
247
+ that failed twice), the durable rule is: every `resolveExprIdentities` case
248
+ falls into one of two categories, and every case in the switch must be
249
+ checked against this invariant whenever the switch changes.
250
+
251
+ - **Structure-preserving** — its result could genuinely BE an existing
252
+ structured value from `state`, by reference/selection, not by computing
253
+ something new. Must forward `byPath`: `ident` (round 2), `member`
254
+ (round 2 for the case where its base is a pure ident/member chain
255
+ resolvable via `accessPathOf`; round 4 added a second path — see below —
256
+ for when the base is NOT a pure chain, e.g. `(user ?? other).email`),
257
+ `object` (constructs new structure directly; round 1/2), `union`
258
+ (selects one branch verbatim; round 3), `logical` (short-circuit
259
+ evaluation can return an operand verbatim; round 3), `assign-expr`
260
+ (simple pass-through of its resolved source; round 3).
261
+ - **Structure-flattening, correctly and by design** — nothing to forward,
262
+ or forwarding would be actively wrong. Stays flat-only, deliberately:
263
+ `literal` (no state involved), `tpl` (template literals always produce a
264
+ new string, primitivizing whatever's interpolated), `binary` (arithmetic/
265
+ comparison operators always produce a new primitive — this is why
266
+ `binary` and `logical` are split into separate cases as of round 3, not
267
+ shared), `array` (this IR's parser transparently unwraps `SpreadElement`
268
+ — see `scanner/src/ir/parser-js.js`'s `exprOf`'s `SpreadElement` case —
269
+ so `[...xs, user]` and `[xs, user]` are byte-identical in the IR; naive
270
+ per-index attribution would actively MISATTRIBUTE a spread source's
271
+ contents to a literal index, which is worse than staying uniformly flat.
272
+ This is corrected from an earlier, factually false version of this
273
+ rationale — "no index-sensitive access paths" — which round 4's review
274
+ found untrue: `accessPathOf`/the parser's `exprOf`/`lhsPath` DO extract
275
+ literal computed keys and can build paths like `arr.0`. The spread
276
+ ambiguity, not a missing capability, is the real reason to stay flat.
277
+ Fixing this properly would require the parser to distinguish spread
278
+ elements from literal elements first — out of scope for this plan),
279
+ `call` (an unresolved call's return value is genuinely UNKNOWN structure
280
+ — correctly flat + `widened: true`, honestly modeling "we don't know,"
281
+ not laundering an identity), `unknown`/default (nothing to preserve).
282
+
283
+ ### The three hop types the invariant covers (round 4 — broadens the invariant beyond `resolveExprIdentities`'s own switch)
284
+
285
+ A fourth re-review found the invariant as stated through round 3 was
286
+ itself scoped too narrowly: it only asked "which `resolveExprIdentities`
287
+ switch cases PRODUCE `byPath`," which is exactly the round-1-through-3
288
+ bug class, but misses two other places structure is lost that are not
289
+ about which case *produces* `byPath` at all.
290
+
291
+ - **Production** — a switch case building a value's structure. This is
292
+ rounds 1-3's fixes, and the category the "structure-preserving vs.
293
+ structure-flattening" list above documents.
294
+ - **Selection** — reading a field off an already-produced structured
295
+ value. Round 3 taught `union`/`logical`/`object`/`assign-expr` to
296
+ correctly report their structure via `byPath` when RESOLVED AS A WHOLE
297
+ (e.g. `const c = user ?? other; return c.email;` correctly resolves to
298
+ `[email]`), but the pre-round-3 `member` case resolved its base only via
299
+ `accessPathOf`, which returns `null` for anything that isn't a pure
300
+ ident/member chain — so `member` fell into `noIdentity()` and silently
301
+ DROPPED the identity the moment the exact same value was read directly,
302
+ with no intermediate variable: `return (user ?? other).email;` returned
303
+ `[]`. Same semantics, two syntactic forms, two different (and one
304
+ wrong) answers — the same tell every prior round used to find its bug.
305
+ Fixed by giving `member` a second resolution path, alongside its
306
+ existing `accessPathOf`-based one, that resolves the base recursively
307
+ and selects `prop` out of the base's `byPath` — the read-side mirror of
308
+ how `object`'s construction attributes a property to its own key.
309
+ - **Write-out** — writing a resolved value to a target path that must
310
+ itself be a valid, non-fabricated path. `step()`'s `assign` case passed
311
+ `node.target` straight through to `removeIdentitiesAt`/`addIdentity`
312
+ with no guard that it was actually a string path. Assignment-expression-
313
+ form destructuring (`({a} = obj)`, lowered by the real parser into one
314
+ `assign` CFG node whose `target` is the raw pattern object, not a
315
+ string) implicitly stringified to the literal string `"[object Object]"`
316
+ — every such destructuring anywhere in a function collided onto this
317
+ one fabricated key, silently merging fields from unrelated statements.
318
+ Fixed by guarding `node.target` to be a string before writing anything
319
+ (skip rather than fabricate a key), matching a precedent already
320
+ established in the sibling taint engine (`scanner/src/dataflow/
321
+ engine.js`'s own `assign` case) that this package had not inherited.
322
+
323
+ **The general rule going forward:** a structured value's `byPath` must
324
+ survive every hop it passes through — anywhere structure is built,
325
+ selected out of, or written to a target — losing it (via an empty
326
+ fallback, a fabricated key, or a coarse merge) at ANY of these three hop
327
+ types is this bug class, regardless of which specific construct triggers
328
+ it. Checking only "does this switch case forward `byPath`" (round 3's own
329
+ framing) is necessary but not sufficient — selection sites outside the
330
+ switch's own recursive calls, and write-out sites in `step()`, must be
331
+ checked too.
332
+
333
+ ### Statically-unknown path components (round 5 — the three hop types were right, but assumed every PATH COMPONENT flowing through them was a real property name)
334
+
335
+ A fifth re-review found that round 4's three-hop-types invariant, while
336
+ correctly identifying *where* structure can be lost, never questioned
337
+ whether the path components flowing through those hops (`node.target`,
338
+ `expr.prop`, `prop.key`) are always real, distinct property names. They
339
+ are not: `scanner/src/dataflow/access-paths.js`'s `accessPathOf` — reused
340
+ directly by this package per §2's reuse boundary — already maps a
341
+ computed member access with a statically-unknown key (`user[k]`, where `k`
342
+ is a variable, not a literal) to a path ending in the literal segment `*`
343
+ (e.g. `"user.*"`). This is an existing, already-established convention in
344
+ the shared IR/access-path layer (`scanner/src/ir/parser-js.js`'s
345
+ `MemberExpression` case and `lhsPath`'s computed-write handling both
346
+ already emit it), not something round 5 invented — but this package had
347
+ never interpreted it specially, and it broke all three hop types at once:
348
+
349
+ - **Selection**: `member`'s path-succeeds branch queried
350
+ `identitiesAt(state, 'user.*')` for `user[k]`, which never matches real
351
+ entries like `'user.email'` — silently returning `[]` instead of
352
+ propagating a widened flow. This directly contradicted this document's
353
+ own §4 "Unresolved function calls" entry, which already named a dynamic
354
+ property key as an example that must propagate as a widened flow, never
355
+ silently vanish (FR-306's "never launder identity into a clean value"
356
+ principle).
357
+ - **Write-out**: every computed-key write on the SAME container lowers to
358
+ the SAME literal target string (`'bag.*'`), since the actual runtime key
359
+ is statically unknown — `bag[k1] = user.email; bag[k2] = user.ssn;`
360
+ produces two `assign` nodes with the identical target `'bag.*'`.
361
+ `step()`'s `assign` case's STRONG update (`removeIdentitiesAt` before
362
+ writing) treated these as the same location a later write legitimately
363
+ overwrites, silently deleting the first write's identity —
364
+ `scanner/src/dataflow/engine.js`'s `_addPathAliasAware` had already
365
+ solved exactly this for the sibling taint engine (a trailing `.*` write
366
+ must be a WEAK update — add to the container, never clear it first) but
367
+ this package had not inherited that precedent, the same pattern as round
368
+ 4's Finding 5 (write-out sites need their own scrutiny, not just
369
+ production/selection).
370
+ - **Production**: `scanner/src/ir/parser-js.js`'s `ObjectExpression`
371
+ handling built each property's `key` as `p.key.name || ...` with no
372
+ check of Babel's own `p.computed` flag — so a computed property with a
373
+ non-literal key expression (`{[k]: v}`) resolved to the key
374
+ EXPRESSION's own variable name (`'k'`), colliding with an explicit,
375
+ non-computed property literally named `k` on the same object:
376
+ `{ k: user.ssn, [k]: user.email }` produced two properties both keyed
377
+ `'k'`, which `resolveExprIdentities`'s `object` case then merged into
378
+ ONE byPath entry — FR-301's core violation, via a more innocuous-looking
379
+ fabricated key than any prior round's finding.
380
+
381
+ **The fix**, entirely at the engine layer for selection/write-out
382
+ (wildcard handling is a policy decision about how to interpret a path
383
+ component, not something baked into `field-identity.js`'s pure state
384
+ primitives) plus a minimal, convention-following change to the shared
385
+ parser for production: a `'*'`-ending (or bare `'*'`) path component is
386
+ now recognized wherever it can appear —
387
+
388
+ - `member`'s path-succeeds branch and its non-path fallback both check for
389
+ a `'*'` key/trailing-`.*` path and resolve the CONTAINER's full
390
+ aggregate (via `identitiesAt`'s existing descendant aggregation, or the
391
+ base's own `flat`), flagged `widened: true`.
392
+ - `step()`'s `assign` case checks for a `'*'`-ending target and performs a
393
+ WEAK update (`addIdentity` onto whatever the container already carries,
394
+ never `removeIdentitiesAt` first), flagged as a `dynamic-property-key`
395
+ widening event.
396
+ - `parser-js.js`'s `ObjectExpression` handling now checks `p.computed`: a
397
+ computed key that is itself a resolvable literal (`{[42]: v}`,
398
+ `{['literal']: v}` — Babel still marks these `computed: true`) still
399
+ resolves to that literal's string form, exactly as before; a computed
400
+ key that is NOT a literal resolves to the literal string `'*'` —
401
+ mirroring the EXISTING computed-member-access convention this same file
402
+ already uses for `obj[k]` reads and writes, not a new one.
403
+ `resolveExprIdentities`'s `object` case folds a `key === '*'` property
404
+ into the object's coarse residual (never a byPath entry keyed `'*'`,
405
+ which would just be a differently-shaped version of the same collision
406
+ bug).
407
+
408
+ **The durable framing, restated once more per this round's own finding
409
+ (subsumes round 4's "three hop types" note and this round's finding
410
+ without needing to re-enumerate constructs again):** every path component
411
+ the IR supplies must be either a real property name or an explicitly
412
+ modeled unknown (`'*'`), and every kill (`removeIdentitiesAt`) must be
413
+ justified as a strong update on a definite, uniquely-identified location —
414
+ a write to an unknown/aliased location must be a weak update instead.
415
+
416
+ ### Two under-enforcements of the round-5 invariant (round 6 — the invariant itself was correct, its application wasn't complete)
417
+
418
+ A sixth re-review found the round-5 invariant CORRECT but under-enforced in
419
+ two places, both fixed together.
420
+
421
+ **Finding 1 — destructuring keys had the same computed-key bug round 5
422
+ fixed for object literals.** Round 5 fixed `parser-js.js`'s
423
+ `ObjectExpression` handling (production, for `{[k]: v}`) but never touched
424
+ `lhsPath`'s `ObjectPattern` branch — the DESTRUCTURING-pattern lowering, a
425
+ different code path in the same file, used for `const {[field]: value} =
426
+ user`. It had the identical bug: a non-literal computed key resolved to the
427
+ key EXPRESSION's own variable name instead of `'*'`, so `const { [field]:
428
+ value } = user; return value;` silently dropped `value`'s identity
429
+ entirely (`returnFacts: []`) — the equivalent `return user[field]` already
430
+ widened correctly per round 5's fix.
431
+
432
+ Fixed by extracting the shared `resolveObjectKey(p)` helper from
433
+ `ObjectExpression`'s already-fixed logic and calling it from both sites, so
434
+ a third instance of this exact bug class can't appear in some future
435
+ object-key-reading code path in this file.
436
+
437
+ **Finding 2 — the round-5 wildcard guards only handled a TRAILING `'*'`,
438
+ not an INTERIOR one.** Round 5's guards in `engine.js` (`path === '*' ||
439
+ path.endsWith('.*')` on the selection side; `node.target === '*' ||
440
+ node.target.endsWith('.*')` on the write-out side) only recognized a
441
+ wildcard segment at the very end of a path. A wildcard can appear in the
442
+ MIDDLE too: `store[k1].name`/`store[k2].name` both lower to the identical
443
+ access path `'store.*.name'` (the `'*'` is interior, not trailing) —
444
+ `endsWith('.*')` is false for this string, so it fell through to the OLD,
445
+ unfixed strong-update/silent-drop behavior. This is round 5's own bug (b)
446
+ recurring one path segment deeper: the second assign's
447
+ `removeIdentitiesAt(state, 'store.*.name')` deleted the first write's
448
+ identity, and the read-side equivalent (`store[k].name = user.ssn; return
449
+ store.a.name;`) silently returned `[]` instead of conservatively resolving
450
+ via the definite prefix.
451
+
452
+ Fixed by replacing both position-dependent checks with two new shared
453
+ helpers in `engine.js`, `definitePrefixBeforeWildcard(path)` and
454
+ `pathHasWildcard(path)` — the former finds the longest wildcard-free prefix
455
+ of a path before its FIRST `'*'` segment, at any position, subsuming round
456
+ 5's trailing-only handling as a special case (`'bag.*'` still resolves to
457
+ `'bag'`, exactly as before) while also correctly handling an interior one
458
+ (`'store.*.name'` now resolves to `'store'`, not falling through
459
+ unguarded).
460
+
461
+ `member`'s non-path fallback branch (round 4, for when `accessPathOf`
462
+ returns `null` because the base isn't a pure ident/member chain) needed no
463
+ change: it never constructs or parses a dotted path string in the first
464
+ place — it resolves the base recursively via the expression tree, and each
465
+ recursive call independently checks its own single-segment `expr.prop ===
466
+ '*'`. An "interior" wildcard in this branch's terms is just an outer
467
+ `member` node whose base (a nested `member`) itself has `prop === '*'` —
468
+ already handled by that same check firing one recursion level in, with no
469
+ path-string parsing involved at any level.
470
+
471
+ **Deferred, not fixed this round (Finding 3 from the same re-review):** a
472
+ widening event recorded by `assign`/`return`'s widening-push always uses
473
+ the hardcoded reason string `'unresolved-call'`, even when the actual
474
+ cause was a dynamic property key (`'dynamic-property-key'` is used
475
+ correctly at the `assign` case's own `'*'`-target weak-update branch, but
476
+ NOT at the general `resolved.widened` push a few lines below it, which
477
+ covers e.g. a `return`-side dynamic-key read). This is Minor and
478
+ soundness-unaffected — the identity SETS themselves are correct either
479
+ way, only the stated cause in the widening ledger can be wrong — and is
480
+ left as a documented follow-up: a real fix would require threading a
481
+ reason string through `resolveExprIdentities`'s return shape more broadly
482
+ than this round's scope covers.
483
+
484
+ ## 4. Per-construct handling (JS/TS, this plan's scope)
485
+
486
+ - **Assignment** (`target: string`, `source: exprDesc`): resolve `source`'s
487
+ identities, clear stale facts at `target` and its descendants
488
+ (reassignment invalidates whatever was there), write the resolved
489
+ identities at `target`.
490
+ - **Object literals**: each property's value is resolved recursively and
491
+ attributed to `target.<key>` (dotted, nested objects produce dotted
492
+ sub-paths like `target.a.b`) — this is the concrete mechanism that proves
493
+ FR-301: two properties on one object literal, from two different data
494
+ elements, land at two different access paths in the state, never merged.
495
+ `assign`'s handling of this case writes ONLY these per-field `byPath`
496
+ entries — never a coarse value at the container's own path (`target`
497
+ itself) — because for a plain object literal the residual is always
498
+ empty (see §3's "Corrected design" and "The residual principle"
499
+ notes for why a coarse root write was tried and rejected, and for the
500
+ general rule this is a special case of). This is the documented,
501
+ correct behavior, not an oversight: `identitiesAt`'s descendant
502
+ aggregation (§3) already answers a whole-object read like `return
503
+ target` correctly from the per-field entries alone. The same residual
504
+ rule applies identically to a plain variable/property alias
505
+ (`target = someOtherRef`) — see §3's "The residual principle" note —
506
+ since round 2 made `resolveExprIdentities` report `byPath` structure
507
+ for those references too, not just for object literals.
508
+ - **Array literals**: flattened, no index sensitivity. **Not** because this
509
+ IR lacks index-sensitive access paths — round 4's review found that
510
+ claim false: `accessPathOf`/the parser's `exprOf`/`lhsPath` DO extract
511
+ literal computed keys for both reads and writes, and paths like `arr.0`
512
+ are buildable. The real reason: the parser transparently unwraps
513
+ `SpreadElement` (`scanner/src/ir/parser-js.js`'s `exprOf`'s
514
+ `SpreadElement` case), so `[...xs, user]` and `[xs, user]` are
515
+ byte-identical in the IR — naive per-index attribution would actively
516
+ MISATTRIBUTE a spread source's contents to a literal index, which is
517
+ worse than staying uniformly flat. An array carrying two different
518
+ fields at two different indices is not distinguishable in this slice;
519
+ this is an accepted, documented limitation, not silently wrong (every
520
+ element's identities are still recorded, just flattened into one bag
521
+ rather than per-index). Fixing this properly would require the parser
522
+ to distinguish spread elements from literal elements first — out of
523
+ scope for this plan.
524
+ - **Object spread/rest — NOW FIXED.** `{...user}` (object spread in a
525
+ literal) and `const {...rest} = user` (object rest in a destructuring
526
+ pattern) were previously silently dropped entirely by the parser —
527
+ confirmed pre-existing since `scanner/src/ir/parser-js.js`'s first
528
+ commit. Both are now fixed — a parser change for each, plus one small
529
+ new branch in this package's own `engine.js` for the spread case (the
530
+ rest fix genuinely needed zero engine changes, reusing an already-built
531
+ wildcard-selection mechanism unchanged; the spread fix did not — see the
532
+ first sub-bullet below):
533
+ - Object literal spread (`{...user}`) merges the resolved source's
534
+ `byPath` structure as top-level siblings, preserving field-level
535
+ distinctness. A spread property contributes to the object's
536
+ structured `byPath`, not to its coarse residual — two spread
537
+ sources (`{...user, ...other}`) each contribute their own fields
538
+ without merging, the same way two explicit properties do. Confirmed
539
+ tested by real before/after `runScan` comparison.
540
+ - Destructuring rest (`const {...rest} = user`) reuses the existing
541
+ wildcard-selection machinery: rest bindings use the container's
542
+ full aggregate (`identitiesAt` of the source over its `path.*`),
543
+ **over-approximating by including sibling-destructured keys rather
544
+ than precisely excluding them** — when `const {a, ...rest} = user`,
545
+ `rest` conservatively carries `user`'s full set, not just
546
+ `{b, c, ...}`. This is a real limitation, explicitly called out so
547
+ it isn't silently assumed to be precise. The tradeoff is worth the
548
+ simplicity, and it's still sound for taint/lineage purposes.
549
+ - Both fixes were confirmed to also resolve the identical blindness
550
+ in `scanner/src/dataflow/`'s shared taint engine, which consumes
551
+ the same parser (no engine changes needed there either).
552
+ - **Array rest's separate, lower-severity, still-unfixed imprecision:**
553
+ Unlike object rest, array rest (`const [first, ...tail] = arr`) is
554
+ NOT a `SpreadElement` that was being filtered out — it's a legitimate
555
+ IR `RestElement` node that already exists in the parsed tree. The
556
+ imprecision here is architectural (index-insensitive flattening, same
557
+ as array literals' own by-design limitation documented above) —
558
+ out of scope for this plan's parser-level fixes, left as a documented
559
+ future refinement if per-index attribution ever becomes feasible.
560
+ - **Member access** (property read): when the whole `object.prop...` chain
561
+ is a pure ident/member chain, resolves via `accessPathOf` + `identitiesAt`
562
+ — no new logic needed, this is what those functions exist for. When the
563
+ base is NOT a pure chain (a ternary, a logical expression, an object
564
+ literal, an assign-expr, a call — anything `accessPathOf` returns `null`
565
+ for), round 4 added a fallback: resolve the base recursively and SELECT
566
+ `prop` out of the base's `byPath` (plus its residual), so reading a field
567
+ directly off one of these constructs gives the same answer as going
568
+ through an intermediate variable first. See the "three hop types" note
569
+ above (the "selection" hop) for the full story.
570
+ - **Template literals and string concatenation (`binary`/`logical` with
571
+ string operators, `tpl` interpolation)**: DO propagate identity normally
572
+ (not flagged as widened/implicit). Rationale: the underlying value is
573
+ genuinely and traceably present in the result — this is different in kind
574
+ from FR-306's "implicit and widened" category, which is about flows whose
575
+ connection to the original field is inferred/approximate (an unresolved
576
+ call, a dynamic property key), not flows where the field's actual value is
577
+ verbatim embedded in a larger string. This is a real design call, stated
578
+ explicitly here so it isn't silently assumed by whichever task implements
579
+ the resolver.
580
+ - **Unresolved function calls** (every call in this plan's scope, since no
581
+ registry/summary integration exists yet): the call's result is treated as
582
+ conservatively carrying the union of its arguments' resolved identities,
583
+ but flagged as a **widening event** (`{atPath, dataElementIds, reason,
584
+ line}`) — a side list the engine returns alongside its main state, never
585
+ silently dropped. This satisfies the project's "never launder identity
586
+ into a clean value" principle (mirrors PRD §18.4's "never translate 'path
587
+ budget exhausted' into 'no path'" spirit) without requiring this plan to
588
+ build the registry integration that would let some calls resolve
589
+ precisely (that's Sub-project D's job).
590
+ - **Destructuring**: resolved (Task 5 read the real IR lowering directly,
591
+ closing the open question this ADR originally left unanswered).
592
+ `const {email, ssn} = user;` lowers to one plain `assign` CFG node per
593
+ bound name — a string `target` (e.g. `'email'`) and a `member`-kind
594
+ `source` (e.g. `{kind: 'member', object: {kind: 'ident', name: 'user'},
595
+ prop: 'email'}`) — see `scanner/src/ir/parser-js.js`'s
596
+ `VariableDeclarator` visitor, the `id.kind === 'object-pattern'` branch,
597
+ around lines 453-463. This is byte-identical to the shape `step()`'s
598
+ `'assign'` case already handles for a plain `member`-sourced assignment,
599
+ so destructuring required zero special-case code in `engine.js` —
600
+ confirmed end to end against the real parser in
601
+ `test/lineage/engine-integration.test.js`.
602
+ - **Ternary/conditional expressions** (`union` kind, both branches kept by
603
+ the parser — never resolved to one) and **logical expressions**
604
+ (`logical` kind, `||`/`&&`/`??`): both/all alternatives' identities are
605
+ unioned PER SUB-PATH — the exact same per-path union `joinStates`
606
+ already performs at the CFG branch-join level, not a coarse flat merge.
607
+ (A previous revision of this bullet claimed this parity while the code
608
+ underneath still flattened `union`/`logical` into one flat blob, entirely
609
+ dropping `byPath` — see round 3's "The structure-preserving vs.
610
+ structure-flattening invariant" note below for the fix and why the false
611
+ claim is believed to be why the gap survived two rounds of review: it read
612
+ as an already-accepted approximation rather than a bug.) `binary`
613
+ (arithmetic/comparison operators) is deliberately NOT part of this
614
+ group — see the invariant below for why.
615
+ - **`unknown`-kind expressions** (JSX, anything the parser doesn't handle):
616
+ resolve to "carries no identity" — fails open, matching the general IR
617
+ contract's own `unknown` CFG-node-kind treatment. Not flagged as a
618
+ coverage gap by this plan (that accounting is AC-11/coverage-ledger
619
+ territory — Sub-project E's job, not this one's).
620
+
621
+ ## 5. What this plan explicitly does NOT build (so later sub-projects don't assume it's here)
622
+
623
+ Interprocedural call resolution and summaries (Sub-project B); the path DAG
624
+ (Sub-project C); any connection to the source/sink registry or
625
+ transformation-kind catalog (Sub-project D); any `DataFlowGraph v1` output
626
+ (Sub-project E); any wiring into `runScan`/the CLI. This plan's output is
627
+ consumed directly by tests with hand-supplied entry facts, nothing else,
628
+ by design.