@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.
- package/CHANGELOG.md +139 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +12 -5
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- 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
|
+
}
|