@clear-capabilities/agentic-security-scanner 0.144.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 +390 -0
- package/bin/agentic-security.js +3813 -83
- 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} +13 -4
- 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/2271.index.js +165 -0
- 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} +14 -6
- 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} +14 -6
- 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} +67 -3
- 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} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- 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} +26 -8
- 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} +580 -187
- 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 +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- 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/lsp/server.js +49 -2
- 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 +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- 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/src/util/git-hardening.js +128 -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,3451 @@
|
|
|
1
|
+
# Path Provenance Recording — Design Record
|
|
2
|
+
|
|
3
|
+
Scope: Sub-project C of Milestone 1, increment C1 (see
|
|
4
|
+
`docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-subproject-c-scoping.md`).
|
|
5
|
+
This document decides **how provenance is recorded** as data flows through
|
|
6
|
+
the field-identity engine. It does **not** decide the storage structure
|
|
7
|
+
(`path-store.js`, C4), the reconstruction query (C5), or the FR-306 edge
|
|
8
|
+
grading (C6) — those consume what is decided here.
|
|
9
|
+
|
|
10
|
+
Binding on every later Sub-project C increment, the same way
|
|
11
|
+
`DESIGN_INTRAPROCEDURAL.md` is binding on `field-identity.js` / `engine.js`.
|
|
12
|
+
If an implementation increment finds a decision here to be wrong or
|
|
13
|
+
incomplete once real code is attempted, **fix this document with a dated
|
|
14
|
+
note explaining what changed and why** — do not silently diverge code from
|
|
15
|
+
doc. That is exactly what Sub-project A's own six-round history shows is
|
|
16
|
+
the expensive failure mode.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 0. The requirements this must satisfy
|
|
21
|
+
|
|
22
|
+
Verbatim from `AGENTIC_SECURITY_DATA_FLOW_EXPLORER_PRD.md` (repo root,
|
|
23
|
+
untracked per this repo's convention):
|
|
24
|
+
|
|
25
|
+
- **FR-303:** *"The engine must retain a compact predecessor/provenance DAG
|
|
26
|
+
from which ordered paths can be reconstructed. It must not eagerly
|
|
27
|
+
materialize every possible path during scanning because that creates path
|
|
28
|
+
explosion."*
|
|
29
|
+
- **FR-305:** *"When several paths connect the same source and sink, the UI
|
|
30
|
+
must show a path count and allow the user to inspect each path.
|
|
31
|
+
Deduplication may collapse identical internal segments but cannot hide
|
|
32
|
+
materially different transformations or controls."*
|
|
33
|
+
- **FR-306:** *"Implicit/control-dependent and unknown-field widened flows
|
|
34
|
+
must be visually distinct and lower-confidence."*
|
|
35
|
+
- **§18.4**, the load-bearing constraint: *"Never translate 'path budget
|
|
36
|
+
exhausted' into 'no path.'"*
|
|
37
|
+
|
|
38
|
+
And two hard local constraints, inherited:
|
|
39
|
+
|
|
40
|
+
- **`field-identity.js` is read-only.** Its `Map<accessPath,
|
|
41
|
+
Set<dataElementId>>` shape and all seven exports stay byte-for-byte
|
|
42
|
+
unmodified. Provenance may not become part of that Map's value type.
|
|
43
|
+
Re-opening FR-301's correctness surface under a richer shape is a bad
|
|
44
|
+
trade against six rounds of adversarial review.
|
|
45
|
+
- **Backward compatibility is the acceptance bar.** With no recorder
|
|
46
|
+
supplied, every observable output of `resolveExprIdentities` /
|
|
47
|
+
`analyzeFunctionFieldIdentity` / `summaries.js` / `driver.js` must be
|
|
48
|
+
byte-identical to today's.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 1. Decision 1 — provenance is an emitted RECORD STREAM, not analysis state
|
|
53
|
+
|
|
54
|
+
**Decided:** the engine emits *hop records* through an optional, write-only
|
|
55
|
+
callback supplied on the existing `ctx` object. It does not accumulate,
|
|
56
|
+
store, join, or read back any provenance structure of its own.
|
|
57
|
+
|
|
58
|
+
Three alternatives were considered and rejected:
|
|
59
|
+
|
|
60
|
+
- **(a) Provenance in the state value type** (`Map<path, Map<id,
|
|
61
|
+
Set<ProvenanceEdge>>>`). Rejected: forbidden by the constraint above, and
|
|
62
|
+
for good reason — every `identitiesAt` / `joinStates` / `removeIdentitiesAt`
|
|
63
|
+
/ `statesEqual` / `hashState` invariant would need re-deriving under a new
|
|
64
|
+
shape, and `hashState` (the summary cache key, Sub-project B) would start
|
|
65
|
+
discriminating contexts by *provenance* rather than by *facts*, silently
|
|
66
|
+
exploding B6's per-function context cap.
|
|
67
|
+
- **(b) A second, parallel dataflow state** ("last writer per path"), joined
|
|
68
|
+
at CFG merge points alongside `state`. Rejected: it would have to
|
|
69
|
+
participate in the worklist's fixed point, which means real changes to
|
|
70
|
+
`analyzeFunctionFieldIdentity`'s convergence logic, a second `statesEqual`,
|
|
71
|
+
and a second termination argument. That is a rewrite of A's core in
|
|
72
|
+
everything but name.
|
|
73
|
+
- **(c) Enriching `resolveExprIdentities`'s return shape** with an `origins`
|
|
74
|
+
map, so write-out sites emit complete, self-contained edges. Rejected —
|
|
75
|
+
though it is the closest call. It would force every switch case to build a
|
|
76
|
+
nested origin structure, which is a provenance DAG re-implemented *inside*
|
|
77
|
+
the resolver, one increment early and without the deduplication C4 exists
|
|
78
|
+
to provide. It also cannot represent a hop that has a source but no
|
|
79
|
+
destination (a bare `call` argument; a return) without inventing a second
|
|
80
|
+
channel anyway.
|
|
81
|
+
|
|
82
|
+
**Why a callback rather than an array on `ctx`.** The consumer chooses
|
|
83
|
+
storage: an array for a test, a deduplicating set for C4, a budget-capped
|
|
84
|
+
sink for C5. It also mirrors `ctx.resolveCallSummary`, the precedent
|
|
85
|
+
Sub-project B's increment 2 already established for extending this engine
|
|
86
|
+
additively.
|
|
87
|
+
|
|
88
|
+
**The write-only invariant** (this is what makes "zero behavior change"
|
|
89
|
+
provable rather than merely hoped for):
|
|
90
|
+
|
|
91
|
+
> The engine must never read from the recorder, never branch on anything it
|
|
92
|
+
> returns, and never let a recorder's presence change `state`, `returnFacts`,
|
|
93
|
+
> `mutatedParams`, `widenings`, or `exitState`. The single permitted
|
|
94
|
+
> `ctx.recordHop`-conditional behavior is **extra computation whose result is
|
|
95
|
+
> discarded** (e.g. resolving a source expression purely to learn which
|
|
96
|
+
> identities a skipped write would have carried). `recordHop`'s return value
|
|
97
|
+
> is ignored.
|
|
98
|
+
|
|
99
|
+
A corollary worth stating because it is easy to get wrong: a recorder may be
|
|
100
|
+
**lossy** (drop records past a budget) without affecting analysis results at
|
|
101
|
+
all. That is what lets C5's path budget live entirely in the consumer.
|
|
102
|
+
|
|
103
|
+
**`recordHop` must not throw.** Deliberately un-guarded, matching
|
|
104
|
+
`resolveCallSummary`'s existing precedent — swallowing a consumer's exception
|
|
105
|
+
would hide a real bug in exchange for nothing. A throwing recorder aborts the
|
|
106
|
+
analysis; that is a consumer defect.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## 2. Decision 2 — the DAG's node granularity is `(scope, accessPath, dataElementId)`; hop records are HALF-EDGES
|
|
111
|
+
|
|
112
|
+
This is the decision the rest of the shape follows from, so it is stated
|
|
113
|
+
before the shape.
|
|
114
|
+
|
|
115
|
+
### 2.1 What a DAG node is
|
|
116
|
+
|
|
117
|
+
A reconstructed path that a human can read is a sequence of **state
|
|
118
|
+
locations**: `user.email` → `u.email` → `o.email` → *(returned)*. Every step
|
|
119
|
+
in that sequence is a place the engine actually recorded an identity. The
|
|
120
|
+
things that happen *between* two such locations — an object literal, a
|
|
121
|
+
template literal, a ternary, a resolved call — are **not additional nodes**.
|
|
122
|
+
They are properties of the edge.
|
|
123
|
+
|
|
124
|
+
**Decided:** the DAG's node is `(scope, accessPath, dataElementId)`.
|
|
125
|
+
Expression-internal constructs annotate edges; they never create nodes.
|
|
126
|
+
|
|
127
|
+
This is what makes the structure *compact* in FR-303's sense: `user.email`
|
|
128
|
+
read in forty places is one node, and the forty reads are forty edges out of
|
|
129
|
+
it. A node-per-expression design would be a materialized-path structure in
|
|
130
|
+
disguise.
|
|
131
|
+
|
|
132
|
+
### 2.2 Why records are half-edges, and how they join
|
|
133
|
+
|
|
134
|
+
A complete edge needs both endpoints. The engine never has both at once:
|
|
135
|
+
|
|
136
|
+
- `resolveExprIdentities` knows the **source** path (it reads `state`) but
|
|
137
|
+
has no idea where the value will eventually land — it is deep inside an
|
|
138
|
+
expression tree and its caller may be an `assign`, a `return`, a call
|
|
139
|
+
argument, or nothing at all.
|
|
140
|
+
- `step()`'s `assign` knows the **destination** path but receives only
|
|
141
|
+
`{flat, byPath, widened}` from the resolver — no source-path information
|
|
142
|
+
whatsoever.
|
|
143
|
+
|
|
144
|
+
So a record is a half-edge, and its `kind` says which half:
|
|
145
|
+
|
|
146
|
+
| Hop type | Half | `fromPath` | `toPath` | Emitted by |
|
|
147
|
+
|---|---|---|---|---|
|
|
148
|
+
| `production` | inbound | the contributing state key, or `null` for a construct that reads no state | `null` | `resolveExprIdentities` cases |
|
|
149
|
+
| `selection` | inbound | the contributing state key, or `null` | `null` | `member`'s two resolution branches |
|
|
150
|
+
| `write-out` | outbound | `null` | the exact path passed to `addIdentity`, or `null` for a hop with no landing path | `step()` cases |
|
|
151
|
+
|
|
152
|
+
**The join rule (binding on C4):** in-half and out-half records join into an
|
|
153
|
+
edge when they share `(scope, nodeId, dataElementId)`.
|
|
154
|
+
**Superseded by §13.3 (increment C3): the key is now the four-part
|
|
155
|
+
`(scope, nodeId, dataElementId, context)`** — see §13.0/§13.3. Every use of
|
|
156
|
+
the three-part form in this document predates C3 and should be read with
|
|
157
|
+
`context` appended.
|
|
158
|
+
Either form is sound only because
|
|
159
|
+
of an invariant that must be re-checked, not assumed:
|
|
160
|
+
|
|
161
|
+
> **Every CFG node kind, in one `step()` invocation, has at most one *write
|
|
162
|
+
> destination expression* — one `target`, one `return value`, one argument
|
|
163
|
+
> list with no destination at all.** A future CFG node kind with two
|
|
164
|
+
> independent destinations (e.g. a lowered swap, or a multi-assignment node)
|
|
165
|
+
> breaks the join rule and needs an explicit correlation token before it can
|
|
166
|
+
> be instrumented.
|
|
167
|
+
|
|
168
|
+
That is an *enumeration principle*, deliberately not a count of today's node
|
|
169
|
+
kinds — see §10.
|
|
170
|
+
|
|
171
|
+
**A gap a design review found in this rule as originally written, closed
|
|
172
|
+
here:** the rule above says WHEN two records join; it did not say what a
|
|
173
|
+
`null`-`fromPath` in-half means when it joins alongside a non-null one at
|
|
174
|
+
the same key. This matters concretely: `const o = {email: u.email}` at one
|
|
175
|
+
CFG node produces, for `data:email`, TWO in-halves (a `selection` hop from
|
|
176
|
+
`u.email`, AND a `production/object` hop with `fromPath: null`) and ONE
|
|
177
|
+
out-half (`write-out` to `o.email`). Read literally, the join rule would
|
|
178
|
+
emit a spurious extra edge with no real source. The intended semantics,
|
|
179
|
+
already implicit in §2.1's worked examples but not stated as a rule: **a
|
|
180
|
+
`null`-`fromPath` in-half is an ANNOTATION on the edges formed by any
|
|
181
|
+
non-null in-half at the same key, not an edge-forming half-edge of its
|
|
182
|
+
own.** It forms a real edge only when NO non-null in-half exists at that
|
|
183
|
+
key — which is precisely the "value with no prior aliasing source" case
|
|
184
|
+
(a literal, or — the one case that matters for interprocedural stitching —
|
|
185
|
+
a resolved call's return value, which is C3's join point, not C1/C2's).
|
|
186
|
+
|
|
187
|
+
> **CORRECTED 2026-08-30 by increment C4 — see §14.4.** The parenthesis
|
|
188
|
+
> above names the resolved-call return value, and the rule then EXCLUDES
|
|
189
|
+
> it: in the real C3 stream the argument expression's own non-null in-half
|
|
190
|
+
> sits at the *same* join key as the `production/call-resolved` hop, so
|
|
191
|
+
> "only when NO non-null in-half exists" demotes the return stitch to an
|
|
192
|
+
> annotation and silently deletes it (measured — a store built that way
|
|
193
|
+
> leaves the callee's exit node with zero outgoing edges). **The
|
|
194
|
+
> discriminator is `peerScope`, not `fromPath`:** a null `fromPath` with a
|
|
195
|
+
> non-null `peerScope` is PEER-ADDRESSED — its source is the callee's own
|
|
196
|
+
> function-exit node — and is always edge-forming, except when it also
|
|
197
|
+
> carries a `lossReason` (§13.6's context-cap marker, whose callee has no
|
|
198
|
+
> recorded body, so an edge from it would fabricate an origin). The rule as
|
|
199
|
+
> written above remains correct, unchanged, for the genuinely source-less
|
|
200
|
+
> case: `peerScope === null`.
|
|
201
|
+
|
|
202
|
+
The three hop types are exactly `DESIGN_INTRAPROCEDURAL.md`'s already-hardened
|
|
203
|
+
production / selection / write-out taxonomy. That is not a coincidence to be
|
|
204
|
+
grateful for; it is the point. Every place structure can be *lost* is a place
|
|
205
|
+
provenance must be *emitted*, because they are the same set of places. A
|
|
206
|
+
provenance site that is not one of those three is a sign the taxonomy is
|
|
207
|
+
being extended without the review the taxonomy earned.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## 3. Decision 3 — the hop record shape
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
/**
|
|
215
|
+
* One hop record = one half-edge for ONE data element at ONE CFG node.
|
|
216
|
+
* Every field is always present; nullable fields carry `null`, never
|
|
217
|
+
* `undefined` and never an omitted key (a stable shape is what lets C4
|
|
218
|
+
* hash a record for deduplication without a canonicalization step).
|
|
219
|
+
*
|
|
220
|
+
* Increment C3 adds three more always-present fields to this shape —
|
|
221
|
+
* `context`, `peerScope`, `peerContext` — and moves the join key to
|
|
222
|
+
* `(scope, nodeId, dataElementId, context)`. See §13.0 and §13.3; this
|
|
223
|
+
* block is otherwise unchanged.
|
|
224
|
+
*
|
|
225
|
+
* This completeness guarantee is delivered by `analyzeFunctionFieldIdentity`'s
|
|
226
|
+
* OWN worklist wrapper (§7.2's "progressive stamping"), not by
|
|
227
|
+
* `resolveExprIdentities`/`step()` individually — those only ever emit the
|
|
228
|
+
* SEMANTIC fields (kind/subKind/fromPath/toPath/dataElementId/
|
|
229
|
+
* syntacticPath/widenReason/lossReason). `scope`/`nodeId`/`line` are
|
|
230
|
+
* stamped onto every record by the wrapper as it flows through `ctx`,
|
|
231
|
+
* BEFORE any site ever sees it. A whole-branch review confirmed this
|
|
232
|
+
* empirically: calling `resolveExprIdentities` directly with a bare
|
|
233
|
+
* `{recordHop}` (bypassing `analyzeFunctionFieldIdentity`) emits records
|
|
234
|
+
* missing `scope`/`nodeId`/`line` entirely — not `null`, ABSENT. Unreachable
|
|
235
|
+
* in shipped code today (the sole caller, `summaries.js`'s `resolveCallSummary`,
|
|
236
|
+
* passes no `ctx` at all — see §7.4's ctx holes), but §7.4 tells C3 to wire a
|
|
237
|
+
* recorder in at exactly that site. **Binding on C3:** any new emission path
|
|
238
|
+
* that does not route through `analyzeFunctionFieldIdentity`'s own wrapper
|
|
239
|
+
* must independently stamp all three progressive fields itself, or this
|
|
240
|
+
* completeness guarantee — and the no-canonicalization contract C4 is being
|
|
241
|
+
* designed around — silently breaks.
|
|
242
|
+
*/
|
|
243
|
+
{
|
|
244
|
+
kind: 'production' | 'selection' | 'write-out',
|
|
245
|
+
subKind: string, // the specific case: 'ident' | 'member' | 'object' |
|
|
246
|
+
// 'array' | 'tpl' | 'binary' | 'logical' | 'union' |
|
|
247
|
+
// 'call' | 'call-resolved' | 'assign-expr' |
|
|
248
|
+
// 'assign' | 'assign-weak' | 'return' | 'call-arg'
|
|
249
|
+
scope: string | null, // the analyzed function's qid; null when unknown
|
|
250
|
+
dataElementId: string, // ALWAYS a non-null id — see Decision 4
|
|
251
|
+
fromPath: string | null, // a REAL state key — see Decision 5 and 6
|
|
252
|
+
toPath: string | null, // the exact path handed to addIdentity — see Decision 5
|
|
253
|
+
syntacticPath: string | null, // the path the IR supplied / the analysis queried,
|
|
254
|
+
// when it differs from the endpoint actually used;
|
|
255
|
+
// null when they are the same
|
|
256
|
+
nodeId: string, // the CFG node id (the worklist key — see Decision 7)
|
|
257
|
+
line: number | null, // node.line, for display
|
|
258
|
+
widenReason: string | null, // null = explicit flow; otherwise the reason
|
|
259
|
+
// ('unresolved-call' | 'dynamic-property-key' | …)
|
|
260
|
+
lossReason: string | null, // null = the identity propagated; otherwise
|
|
261
|
+
// WHY this hop is a dead end ('unsupported-target' | …)
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Deviations from the plan's suggested starting shape, and why
|
|
266
|
+
|
|
267
|
+
- **`widened: boolean` → `widenReason: string | null`.** The boolean and a
|
|
268
|
+
reason string side by side are redundant, and redundant fields drift — the
|
|
269
|
+
exact failure this codebase punishes elsewhere. `widened` is recoverable as
|
|
270
|
+
`widenReason !== null`. The upgrade also **closes, at the hop layer,
|
|
271
|
+
`DESIGN_INTRAPROCEDURAL.md`'s explicitly-deferred Finding 3** (round 6):
|
|
272
|
+
the `widenings` ledger mislabels a dynamic-property-key widening as
|
|
273
|
+
`'unresolved-call'` because the reason is stamped at `assign`/`return`,
|
|
274
|
+
far from where the cause is known. A hop is recorded *at the site that knows
|
|
275
|
+
its own cause*, so it can label correctly for free. C6 should read
|
|
276
|
+
`widenReason` from hops, not from `widenings`. (The `widenings` ledger's own
|
|
277
|
+
mislabel is untouched and remains open; this does not fix it, it routes
|
|
278
|
+
around it.)
|
|
279
|
+
- **`lossReason` added.** §18.4's most load-bearing constraint is that a
|
|
280
|
+
truncated or unfollowable path must never look like the absence of a path.
|
|
281
|
+
A dead end that is *recorded as a dead end* is the data-layer form of that
|
|
282
|
+
guarantee; an unrecorded drop is invisible and indistinguishable from "no
|
|
283
|
+
flow exists." Without this field, C5 physically cannot satisfy AC-10 from
|
|
284
|
+
the data alone.
|
|
285
|
+
- **`scope` added.** C3 stitches across functions; a hop with no owner cannot
|
|
286
|
+
be stitched. Adding it now costs one field; adding it in C3 means
|
|
287
|
+
re-instrumenting everything C2 wrote.
|
|
288
|
+
- **`syntacticPath` added.** Decision 5 forces endpoints to be real state
|
|
289
|
+
paths, which discards the IR's own framing (`store.*.name` becomes `store`).
|
|
290
|
+
That framing is the only human-readable trace of *why* a hop widened; it
|
|
291
|
+
belongs somewhere, and it must not be in the endpoint.
|
|
292
|
+
- **`nodeId` kept, but sourced differently.** See Decision 7 — hand-built test
|
|
293
|
+
fixtures do not set `node.id`, so it must come from the worklist key.
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## 4. Decision 4 — one record per `dataElementId`, never a Set-valued record
|
|
298
|
+
|
|
299
|
+
**Confirmed** (the plan's own proposal), with stronger reasoning than the
|
|
300
|
+
plan gave, plus one argument the plan did not have.
|
|
301
|
+
|
|
302
|
+
The plan's argument was that per-identity records let a later query
|
|
303
|
+
distinguish "two elements took the same route" from "one route carries N
|
|
304
|
+
elements." True, but weak on its own — a Set could be exploded at query time.
|
|
305
|
+
|
|
306
|
+
The two decisive arguments:
|
|
307
|
+
|
|
308
|
+
**(1) The half-edge join is only well-defined per identity.** With
|
|
309
|
+
`(scope, nodeId, dataElementId)` as the join key, a Set-valued half-edge
|
|
310
|
+
would have to be exploded before joining anyway, so the Set buys nothing and
|
|
311
|
+
costs a normalization step. Worse, a Set-valued in-half `{ids: {A, B}}`
|
|
312
|
+
paired with a Set-valued out-half `{ids: {A}}` makes B's fate **ambiguous**:
|
|
313
|
+
was B read and deliberately not written (folded into a residual, dropped at
|
|
314
|
+
an unsupported target), or is the out-half merely under-reporting? Per
|
|
315
|
+
identity, "B has an in-half and no out-half at this node" is an unambiguous,
|
|
316
|
+
readable fact — and it is exactly the fact `lossReason` exists to explain.
|
|
317
|
+
|
|
318
|
+
**(2) A worked example where a Set-valued record is outright wrong.** With
|
|
319
|
+
state `{ user: {data:blob}, user.email: {data:email} }` (an ancestor and a
|
|
320
|
+
descendant both carrying identity — a shape §3 of
|
|
321
|
+
`DESIGN_INTRAPROCEDURAL.md` explicitly says must be allowed to coexist),
|
|
322
|
+
`const u = user;` resolves `flat = {data:blob, data:email}` and
|
|
323
|
+
`byPath = {email: {data:email}}`, residual `{data:blob}`. The `assign` then
|
|
324
|
+
writes **two different paths**: `u` ← `data:blob` and `u.email` ←
|
|
325
|
+
`data:email`. Per identity, the records come out exactly right:
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
in: production/ident from 'user' id data:blob syntacticPath 'user'
|
|
329
|
+
in: production/ident from 'user.email' id data:email syntacticPath 'user'
|
|
330
|
+
out: write-out/assign to 'u' id data:blob
|
|
331
|
+
out: write-out/assign to 'u.email' id data:email
|
|
332
|
+
→ edges: user → u (data:blob), user.email → u.email (data:email)
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
A Set-valued record — one in-half `{from: 'user', ids: {blob, email}}`, one
|
|
336
|
+
out-half `{to: 'u', ids: {blob, email}}` — would report `user → u` carrying
|
|
337
|
+
both, losing `user.email → u.email` entirely. That is FR-301's own merge bug,
|
|
338
|
+
recreated one layer up in the provenance representation, in exactly the
|
|
339
|
+
ancestor/descendant shape Sub-project A's round 1 was fixed to handle.
|
|
340
|
+
|
|
341
|
+
**The cost, disclosed:** record volume is O(identities × hops), and the
|
|
342
|
+
worklist re-emits on every revisit (Decision 8). Mitigations: the recorder is
|
|
343
|
+
opt-in, so a scan that does not want provenance pays literally nothing; and
|
|
344
|
+
C4 deduplicates by record content, which collapses the revisit multiplicity
|
|
345
|
+
and all repeated reads of the same location.
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## 5. Decision 5 — endpoints are paths the engine actually read from or wrote to; never a syntactic path
|
|
350
|
+
|
|
351
|
+
> **Invariant (the Sub-project C analogue of round 5's own):** every non-null
|
|
352
|
+
> `fromPath` / `toPath` on a hop record must be a path the engine actually
|
|
353
|
+
> **queried against** or **passed to `addIdentity`**. A wildcard-bearing
|
|
354
|
+
> syntactic path is never an endpoint. The wildcard is an *attribute* of the
|
|
355
|
+
> hop (`widenReason: 'dynamic-property-key'`, `syntacticPath` carrying the
|
|
356
|
+
> raw form), never part of its endpoint.
|
|
357
|
+
|
|
358
|
+
### This answers the plan's Step 5 question, and the answer is "no"
|
|
359
|
+
|
|
360
|
+
The plan asked whether `toPath` "naturally carries the `'*'`-suffixed path
|
|
361
|
+
already, same as the existing write-out logic." **It does not, and it must
|
|
362
|
+
not.** Reading the actual code settles it: `step()`'s wildcard branch does
|
|
363
|
+
not write to `node.target` at all —
|
|
364
|
+
|
|
365
|
+
```js
|
|
366
|
+
const containerPath = definitePrefixBeforeWildcard(node.target); // 'store.*.name' → 'store'
|
|
367
|
+
for (const id of allIds) wState = addIdentity(wState, containerPath, id);
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
— it writes at the **definite prefix**. So the only correct `toPath` for
|
|
371
|
+
`store[k].name = user.ssn` is `'store'`, with `syntacticPath: 'store.*.name'`
|
|
372
|
+
and `widenReason: 'dynamic-property-key'`. The read side is symmetric:
|
|
373
|
+
`member`'s wildcard branch queries `identitiesAt(state, basePath)` where
|
|
374
|
+
`basePath` is the same definite prefix, so `fromPath` is `'store'`, not
|
|
375
|
+
`'store.*.name'`.
|
|
376
|
+
|
|
377
|
+
### Why getting this wrong would be expensive
|
|
378
|
+
|
|
379
|
+
If C2 recorded `toPath: 'store.*.name'`, the DAG would grow a node
|
|
380
|
+
`(store.*.name, id)` that **no read hop anywhere can ever match** — because
|
|
381
|
+
no read hop ever names that path either, for the same reason. Backward
|
|
382
|
+
reconstruction from a sink would hit that node and find zero predecessors,
|
|
383
|
+
and would report *no path* for every flow that passed through a dynamic
|
|
384
|
+
property key. That is §18.4's single most load-bearing constraint —
|
|
385
|
+
"never translate 'path budget exhausted' into 'no path'" — violated in a
|
|
386
|
+
new disguise: not budget exhaustion, but a fabricated endpoint, producing
|
|
387
|
+
the same silent, confident, wrong "there is no flow here."
|
|
388
|
+
|
|
389
|
+
This is the same bug class as round 4's `"[object Object]"` target and round
|
|
390
|
+
5's `'*'` object key: **a path component that is not a real, resolved
|
|
391
|
+
location, used as an identity key.** The rule above is the general form.
|
|
392
|
+
|
|
393
|
+
### Two sub-cases the rule already covers
|
|
394
|
+
|
|
395
|
+
- **`typeof node.target !== 'string'`** (assignment-expression destructuring,
|
|
396
|
+
`({a} = obj)`): the engine deliberately skips rather than fabricating a key.
|
|
397
|
+
Provenance must not fabricate one either. This is a **loss** site — see the
|
|
398
|
+
checklist in §10.2 for how to record it.
|
|
399
|
+
- **`prop.key === '*'`** in an `object` literal: the engine folds it into the
|
|
400
|
+
coarse residual and writes it at the container root. So the eventual
|
|
401
|
+
`toPath` is the container, from the residual write — already correct under
|
|
402
|
+
this rule, with `widenReason: 'dynamic-property-key'` carried on the
|
|
403
|
+
production hop.
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## 6. Decision 6 — `fromPath` is the CONTRIBUTING state key, not the queried path
|
|
408
|
+
|
|
409
|
+
This decision does not appear in the plan at all, and it is the one most
|
|
410
|
+
likely to have been discovered late and expensively.
|
|
411
|
+
|
|
412
|
+
`identitiesAt(state, path)` aggregates **bidirectionally** (round 1 of
|
|
413
|
+
Sub-project A): querying `u.email` sees an identity recorded at the ancestor
|
|
414
|
+
`u`, and querying `u` sees identities recorded at descendants `u.email` /
|
|
415
|
+
`u.ssn`. So the path a read hop *queried* is very often **not** a key in
|
|
416
|
+
`state` at all.
|
|
417
|
+
|
|
418
|
+
If `fromPath` were the queried path, the DAG would contain nodes with no
|
|
419
|
+
incoming edges that nonetheless are not sources — e.g. a node `(u.email, id)`
|
|
420
|
+
created by a read, when the identity actually lives at `(u, id)`. Backward
|
|
421
|
+
reconstruction would terminate there and report a path that begins in the
|
|
422
|
+
middle of the program. Every flow that passes through ancestor or descendant
|
|
423
|
+
coverage — which is most of them, because that is how object structure is
|
|
424
|
+
modeled here — would silently truncate.
|
|
425
|
+
|
|
426
|
+
**Decided:** at a state-backed read, emit **one record per (contributing
|
|
427
|
+
state key, dataElementId)** pair, where a contributing key is a key of
|
|
428
|
+
`state` that (a) contains that id and (b) stands in a prefix-coverage
|
|
429
|
+
relation to the queried path, in either direction — i.e. exactly the keys
|
|
430
|
+
`identitiesAt` unioned to produce the answer. The queried path goes in
|
|
431
|
+
`syntacticPath`.
|
|
432
|
+
|
|
433
|
+
Mechanically this needs one new pure helper in `engine.js` (not in
|
|
434
|
+
`field-identity.js` — no change there), roughly:
|
|
435
|
+
|
|
436
|
+
```js
|
|
437
|
+
// Which state keys actually contributed `id` to identitiesAt(state, path)?
|
|
438
|
+
// Mirrors identitiesAt's own bidirectional coverage test exactly; if that
|
|
439
|
+
// test ever changes, this must change with it or the DAG silently
|
|
440
|
+
// disconnects. Pure, allocation-only-when-recording.
|
|
441
|
+
function contributingKeys(state, path, id) { /* … */ }
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
**Cost, corrected — a design review caught this understated:** naively
|
|
445
|
+
calling `contributingKeys(state, path, id)` once per id (as the signature
|
|
446
|
+
above suggests) is O(|state| × |ids at path|), not O(|state|) — the state
|
|
447
|
+
scan repeats per id. The fix is a single pass: scan `state` once against
|
|
448
|
+
`path` (the same prefix-coverage test `identitiesAt` already does), and for
|
|
449
|
+
each covered key partition ITS ids by contributing key in one step, rather
|
|
450
|
+
than re-scanning per id. That is O(|state|) total for all ids at a path, the
|
|
451
|
+
cost this section originally claimed. C2's implementer should build the
|
|
452
|
+
single-pass version, not the naive per-id loop the signature above implies
|
|
453
|
+
— and it must be called **only when `ctx?.recordHop` is present**, squarely
|
|
454
|
+
within Decision 1's "extra discarded computation" allowance.
|
|
455
|
+
|
|
456
|
+
### Worked example — the plan's own Task 2 fixture, traced end to end
|
|
457
|
+
|
|
458
|
+
```js
|
|
459
|
+
function f(user) {
|
|
460
|
+
const u = user; // n1
|
|
461
|
+
const o = { email: u.email, ssn: u.ssn }; // n2
|
|
462
|
+
return o; // n3
|
|
463
|
+
}
|
|
464
|
+
// entry state: user.email → data:email, user.ssn → data:ssn
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
| node | records emitted |
|
|
468
|
+
|---|---|
|
|
469
|
+
| n1 | `production/ident from 'user.email' id data:email syntacticPath 'user'`<br>`production/ident from 'user.ssn' id data:ssn syntacticPath 'user'`<br>`write-out/assign to 'u.email' id data:email`<br>`write-out/assign to 'u.ssn' id data:ssn` |
|
|
470
|
+
| n2 | `selection/member from 'u.email' id data:email`<br>`selection/member from 'u.ssn' id data:ssn`<br>`production/object from null id data:email`<br>`production/object from null id data:ssn`<br>`write-out/assign to 'o.email' id data:email`<br>`write-out/assign to 'o.ssn' id data:ssn` |
|
|
471
|
+
| n3 | `production/ident from 'o.email' id data:email syntacticPath 'o'`<br>`production/ident from 'o.ssn' id data:ssn syntacticPath 'o'`<br>`write-out/return to null id data:email`<br>`write-out/return to null id data:ssn` |
|
|
472
|
+
|
|
473
|
+
Joining by `(scope, nodeId, dataElementId)` yields exactly:
|
|
474
|
+
|
|
475
|
+
```
|
|
476
|
+
data:email : user.email → u.email → o.email → ⟨return⟩ (via: object at n2)
|
|
477
|
+
data:ssn : user.ssn → u.ssn → o.ssn → ⟨return⟩ (via: object at n2)
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Two ordered paths, field-distinct end to end, from a structure containing
|
|
481
|
+
fourteen deduplicated records (the table above: 4 at n1 + 6 at n2 + 4 at n3)
|
|
482
|
+
and zero materialized paths. Note that the two elements never touch, at any
|
|
483
|
+
hop — which is FR-301's requirement carried into FR-303's structure.
|
|
484
|
+
|
|
485
|
+
**Correction found during Task 2's real implementation (this document
|
|
486
|
+
previously said "twelve" here, which was simply a miscount of the table
|
|
487
|
+
two paragraphs above — a genuine defect, not a deliberate simplification):**
|
|
488
|
+
fourteen is the count once EVERY hop type in the table is instrumented,
|
|
489
|
+
which requires `member`'s selection case (the two `selection/member` rows
|
|
490
|
+
at n2) — that is increment C2's job, not C1's. Task 2's own four-site POC
|
|
491
|
+
(§11 — `ident`, `object`, `assign`, `return`; deliberately NOT `member`)
|
|
492
|
+
was run against this exact fixture and produces exactly **twelve**
|
|
493
|
+
deduplicated records for it (4 at n1 + 4 at n2, since the two
|
|
494
|
+
`selection/member` rows never fire + 4 at n3) — confirmed by
|
|
495
|
+
`scanner/test/lineage/engine-provenance.test.js`, which pins this count.
|
|
496
|
+
So "twelve" was a real number, just attached to the wrong scope (it
|
|
497
|
+
describes Task 2's own POC output, not this section's full-coverage
|
|
498
|
+
worked example) — both twelve and fourteen are now stated explicitly,
|
|
499
|
+
against the scope each actually belongs to, so a future reader doesn't
|
|
500
|
+
have to re-derive which is which by hand.
|
|
501
|
+
|
|
502
|
+
Had `fromPath` been the queried path, n1 would have emitted
|
|
503
|
+
`from 'user'` for both ids and n3 `from 'o'` for both, and the reconstruction
|
|
504
|
+
would have been `user → u.email` / `user → u.ssn` — two paths that begin at a
|
|
505
|
+
container the engine never recorded anything at, merging the two elements'
|
|
506
|
+
first hop. The plan's own POC fixture is therefore already sufficient to
|
|
507
|
+
expose this; Task 2 should assert the *contributing-key* form explicitly.
|
|
508
|
+
|
|
509
|
+
---
|
|
510
|
+
|
|
511
|
+
## 7. Decision 7 — the injection mechanism, and the exact threading audit
|
|
512
|
+
|
|
513
|
+
### 7.1 The parameter
|
|
514
|
+
|
|
515
|
+
`ctx.recordHop(record)` — an optional callback on the **existing** `ctx`
|
|
516
|
+
object, mirroring `ctx.resolveCallSummary` exactly (Sub-project B, increment
|
|
517
|
+
2). No signature change to `resolveExprIdentities`,
|
|
518
|
+
`analyzeFunctionFieldIdentity`, `step()`, or any exported function.
|
|
519
|
+
|
|
520
|
+
### 7.2 Progressive stamping — call sites supply only semantic fields
|
|
521
|
+
|
|
522
|
+
`resolveExprIdentities` does **not** know `nodeId`, `line`, or `scope`. It is
|
|
523
|
+
called from `step(node, …)` and never receives the node. Rather than thread
|
|
524
|
+
three more arguments through fifteen call sites (and rely on nobody ever
|
|
525
|
+
forgetting one), the recorder is **enriched as it descends**:
|
|
526
|
+
|
|
527
|
+
- `analyzeFunctionFieldIdentity` stamps `scope` (from `fn.qid ?? null`) once,
|
|
528
|
+
for the whole analysis.
|
|
529
|
+
- `step` stamps `nodeId` and `line` once per node visit.
|
|
530
|
+
|
|
531
|
+
```js
|
|
532
|
+
// in analyzeFunctionFieldIdentity, before the worklist:
|
|
533
|
+
const scope = fn.qid ?? null;
|
|
534
|
+
// in the worklist, per node (nid is the map KEY — see the warning below):
|
|
535
|
+
const stepCtx = ctx?.recordHop
|
|
536
|
+
? { ...ctx, recordHop: (h) => ctx.recordHop({ scope, nodeId: nid, line: node.line ?? null, ...h }) }
|
|
537
|
+
: ctx;
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
Two properties this buys:
|
|
541
|
+
|
|
542
|
+
- **A call site cannot forget or mis-supply `nodeId`/`line`/`scope`.** That
|
|
543
|
+
entire bug class is structurally impossible.
|
|
544
|
+
- **With no recorder, `ctx` is passed through byte-identically** — no
|
|
545
|
+
allocation, no new object, nothing for a backward-compatibility test to
|
|
546
|
+
catch. The `? :` above is not an optimization; it is the mechanism by
|
|
547
|
+
which "zero behavior change when absent" is true by construction.
|
|
548
|
+
|
|
549
|
+
Spread order (`{scope, nodeId, line, ...h}`) puts the stamped fields first so
|
|
550
|
+
a record may deliberately override them — needed in C3, where a hop written
|
|
551
|
+
into a *callee's* entry state must carry the callee's `scope`, not the
|
|
552
|
+
caller's.
|
|
553
|
+
|
|
554
|
+
> **`nodeId` must come from the worklist's map key (`nid`), not `node.id`.**
|
|
555
|
+
> Verified by reading `test/lineage/engine-walker.test.js`: hand-built CFG
|
|
556
|
+
> fixtures set no `id` field on their nodes at all, only the map key. The
|
|
557
|
+
> real parser (`parser-js.js`) does set `node.id`, and sets it equal to the
|
|
558
|
+
> key — so `nid` is correct for both and `node.id` is correct for only one.
|
|
559
|
+
> A hop stamped `nodeId: undefined` would collapse every node in a
|
|
560
|
+
> hand-built-fixture function onto one join key, merging unrelated hops.
|
|
561
|
+
|
|
562
|
+
### 7.3 Threading audit — inside `engine.js`, nothing further is needed
|
|
563
|
+
|
|
564
|
+
Verified by enumerating every call site rather than counting them:
|
|
565
|
+
|
|
566
|
+
```
|
|
567
|
+
$ grep -n "resolveExprIdentities(" src/lineage/engine.js
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
**Every call site in `engine.js` already passes `ctx` unconditionally** —
|
|
571
|
+
`member`'s recursive base, `object`, `array`, `tpl`, `binary` (×2), `logical`
|
|
572
|
+
(×2), `union`, `call`'s args, `assign-expr`, and `step()`'s three call sites
|
|
573
|
+
(`assign`, `call`, `return`). The enumeration principle, not the count:
|
|
574
|
+
*every* `resolveExprIdentities` call in this file threads `ctx`, and any new
|
|
575
|
+
one must. So instrumenting one case is sufficient to prove the mechanism; no
|
|
576
|
+
new plumbing exists anywhere in `engine.js`.
|
|
577
|
+
|
|
578
|
+
### 7.4 Threading audit — `summaries.js` and `driver.js` have three real holes
|
|
579
|
+
|
|
580
|
+
The same audit run across the package finds **three places where `ctx` is
|
|
581
|
+
dropped or reconstructed**, all outside `engine.js`. None blocks C2; all
|
|
582
|
+
three block C3, and they are named here so C3 does not rediscover them the
|
|
583
|
+
expensive way:
|
|
584
|
+
|
|
585
|
+
| Site | Problem |
|
|
586
|
+
|---|---|
|
|
587
|
+
| `summaries.js:291`, in `entryStateFromCall` | `resolveExprIdentities(callerState, callArgs[i])` is called with **no `ctx` at all**. Argument→parameter binding is the single most important interprocedural hop, and it is currently unrecordable. `entryStateFromCall`'s signature must gain a way to receive one. |
|
|
588
|
+
| `summaries.js:357`, in `createCallSummaryResolver` | Constructs a **fresh** `{ resolveCallSummary }` ctx for the callee's `analyzeFunctionFieldIdentity`, discarding any `recordHop` the caller had. A resolved call chain therefore records the caller's hops and none of the callee's. |
|
|
589
|
+
| `driver.js:65` | Same fresh-`{ resolveCallSummary }` construction for every top-level function. The project-wide entry point cannot pass a recorder in at all today. |
|
|
590
|
+
|
|
591
|
+
There is also a **missing call-site identity** problem C3 must solve:
|
|
592
|
+
`resolveCallSummary(calleeExpr, callArgs, callerState)` has no access to the
|
|
593
|
+
calling CFG node, so a cross-function hop cannot be stamped with the call
|
|
594
|
+
site it crossed at. Recommended fix, consistent with §7.2: `step`'s derived
|
|
595
|
+
ctx already carries the stamped `recordHop`; extend the same derived ctx with
|
|
596
|
+
the plain values (`ctx.hopSite = {scope, nodeId, line}`) so `summaries.js` can
|
|
597
|
+
read them without any signature change. Decide this in C3, not now — but do
|
|
598
|
+
not design C3's hop shape as though the call site were available for free.
|
|
599
|
+
|
|
600
|
+
This entire subsection exists because of Sub-project A's round-4 lesson,
|
|
601
|
+
generalized: *the check must not stop at the file you are editing.* Round 4's
|
|
602
|
+
gap survived three reviews because everyone checked
|
|
603
|
+
`resolveExprIdentities`'s switch and nobody checked `step()`. The Sub-project
|
|
604
|
+
C version of that mistake is checking `engine.js` and not checking
|
|
605
|
+
`summaries.js` / `driver.js`.
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## 8. Decision 8 — the worklist re-emits; deduplicate at the consumer
|
|
610
|
+
|
|
611
|
+
`analyzeFunctionFieldIdentity` is a fixed-point worklist: a node is
|
|
612
|
+
re-`step()`ed whenever an incoming state changes. So **every hop is emitted
|
|
613
|
+
once per node visit**, not once per program point.
|
|
614
|
+
|
|
615
|
+
**Decided:** the engine does *not* suppress duplicates. Doing so would
|
|
616
|
+
require a second per-node memo — i.e. Decision 1's rejected option (b) in
|
|
617
|
+
miniature. C4 deduplicates by record content.
|
|
618
|
+
|
|
619
|
+
**Why duplicates are harmless and no early-iteration hop is ever wrong:**
|
|
620
|
+
the state visible at a node is **monotone** across worklist iterations.
|
|
621
|
+
`inStates`/`outStates` are only ever updated through `joinStates`, which
|
|
622
|
+
unions; `step` is monotone in its input (each case only unions more into what
|
|
623
|
+
it produces, and `assign`'s kill removes a *fixed*, content-independent set of
|
|
624
|
+
paths). Therefore the identities resolvable at a node on iteration *k+1* are a
|
|
625
|
+
superset of those on iteration *k*, so the hops emitted on iteration *k* are a
|
|
626
|
+
**subset** of those emitted on the final iteration. Duplicates are exact
|
|
627
|
+
repeats; nothing recorded early is later invalidated. Content-deduplication
|
|
628
|
+
is therefore both safe and complete.
|
|
629
|
+
|
|
630
|
+
**Inherited imprecision, disclosed:** because `outStates.set(nid, joinStates(prevOut, out))`
|
|
631
|
+
joins a node's *own* successive outputs, a strong update inside a loop is
|
|
632
|
+
weakened across iterations — a pre-existing property of Sub-project A's
|
|
633
|
+
worklist, not something provenance introduces. Hop records inherit it exactly:
|
|
634
|
+
inside a loop, a re-assigned variable may retain in-halves from a prior
|
|
635
|
+
iteration's source. Recording it faithfully is correct; "fixing" it here would
|
|
636
|
+
mean disagreeing with the analysis the hops are supposed to explain.
|
|
637
|
+
|
|
638
|
+
---
|
|
639
|
+
|
|
640
|
+
## 9. Known imprecisions of this design, disclosed up front
|
|
641
|
+
|
|
642
|
+
None of these is a soundness hole — each over-approximates *paths*, never
|
|
643
|
+
under-reports *identities*. They are listed so a later increment measures
|
|
644
|
+
them rather than rediscovering them.
|
|
645
|
+
|
|
646
|
+
### 9.1 The half-edge cross-join can invent a path
|
|
647
|
+
|
|
648
|
+
Within one CFG node, all in-halves for an id join with all out-halves for
|
|
649
|
+
that id. When the **same** `dataElementId` is read from ≥2 distinct paths
|
|
650
|
+
**and** written to ≥2 distinct paths at the same node, the cross product
|
|
651
|
+
contains edges that never really happened:
|
|
652
|
+
|
|
653
|
+
```js
|
|
654
|
+
const x = { a: p.email, b: q.email }; // both p.email and q.email carry data:email
|
|
655
|
+
// in-halves : p.email, q.email out-halves: x.a, x.b
|
|
656
|
+
// joined : 4 edges — of which p.email→x.b and q.email→x.a are phantom
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
**Decided: detect and mark, do not prevent.** C4 can identify this exactly —
|
|
660
|
+
at a `(scope, nodeId, dataElementId)` group, `distinctInPaths ≥ 2 &&
|
|
661
|
+
distinctOutPaths ≥ 2` — and mark the resulting edges
|
|
662
|
+
`ambiguousCorrelation: true`. FR-306 already requires a lower-confidence
|
|
663
|
+
grade for less-certain hops; this feeds it directly, and FR-305's constraint
|
|
664
|
+
is about not *hiding* differences, which marking satisfies and silence would
|
|
665
|
+
not.
|
|
666
|
+
|
|
667
|
+
> **CORRECTED by increment C4 — see §14.7.** The group-level measure above
|
|
668
|
+
> (`distinctInPaths ≥ 2 && distinctOutPaths ≥ 2` on the WHOLE group) is too
|
|
669
|
+
> coarse: applied to a plain resolved call, it marks every edge in the
|
|
670
|
+
> group — including both edges of the correct call-boundary chain — because
|
|
671
|
+
> the annotation-only `call-resolved` in-half (`fromPath: null`) counts as
|
|
672
|
+
> a second "distinct" in-path even though it never forms its own edge
|
|
673
|
+
> (§2.2's correction). §14.7 replaces this with a per-*pairing* measure
|
|
674
|
+
> (ambiguous only when the SPECIFIC in-half/out-half pair being joined has
|
|
675
|
+
> a same-key sibling on both sides), verified against a real resolved-call
|
|
676
|
+
> fixture where the group-level form over-marks 3 of 5 edges and the
|
|
677
|
+
> per-pairing form marks exactly the 1 genuinely ambiguous one. The
|
|
678
|
+
> `slot`-field discussion below is unaffected by this correction.
|
|
679
|
+
|
|
680
|
+
**The known fix, if C4 measures this as a real problem:** add a `slot` field
|
|
681
|
+
— the sub-path *within the value under construction* that a hop contributes
|
|
682
|
+
to — and join on `(scope, nodeId, dataElementId, slot)`. It is deferred, not
|
|
683
|
+
overlooked, because it requires threading a slot prefix down through
|
|
684
|
+
`resolveExprIdentities`'s recursion for the **in-expression** case (the
|
|
685
|
+
resolver does not know its own position in its parent). The cost is lower
|
|
686
|
+
than that framing suggests for a real, non-exotic shape a design review
|
|
687
|
+
confirmed by execution: a plain alias of a multi-field object —
|
|
688
|
+
`const u = user;` where `state` already has `user.a: X, user.b: X` — trips
|
|
689
|
+
this exact condition (`distinctInPaths=2, distinctOutPaths=2` for the same
|
|
690
|
+
id) with **neither** half needing anything threaded through, since both
|
|
691
|
+
in-halves (`user.a`, `user.b`) and both out-halves (`u.a`, `u.b`) already
|
|
692
|
+
carry their own sub-path as their own `fromPath`/`toPath`. A `slot` for
|
|
693
|
+
THIS shape is a same-node string transform, not a signature change — only
|
|
694
|
+
the in-expression case (`{a: p.email, b: q.email}`, where the resolver
|
|
695
|
+
itself must know it's building the `a` vs `b` slot) needs the recursion
|
|
696
|
+
change. Do not adopt either half speculatively; measure first — but do not
|
|
697
|
+
assume both halves cost the same when deciding whether to.
|
|
698
|
+
|
|
699
|
+
**Frequency, corrected:** the paragraph above previously called this "a
|
|
700
|
+
narrow case that has not yet been shown to occur." A design review
|
|
701
|
+
reproduced it with the plain-alias shape by running the real walker — it is
|
|
702
|
+
not exotic, and any object carrying the same identity at two fields, then
|
|
703
|
+
aliased or passed through untouched, hits it. This does not change the
|
|
704
|
+
decision (detect-and-mark is still correct, and still sound — nothing is
|
|
705
|
+
silently wrong), but `ambiguousCorrelation: true` should be expected to
|
|
706
|
+
appear on a real, non-trivial share of ordinary aliasing edges, not treated
|
|
707
|
+
as a rare corner case when C4 is scoping how much weight to give it in
|
|
708
|
+
FR-306's confidence grading.
|
|
709
|
+
|
|
710
|
+
### 9.2 The DAG is flow-insensitive at reconstruction time
|
|
711
|
+
|
|
712
|
+
The forward analysis is flow-sensitive; the DAG is not. `a = user.email; a = other.email;`
|
|
713
|
+
— where both carry `data:email` — leaves two predecessors for `(a, data:email)`,
|
|
714
|
+
and reconstruction will report both, though only the second reaches a later
|
|
715
|
+
sink.
|
|
716
|
+
|
|
717
|
+
Note the narrowness: this needs the **same** id written to the **same** path
|
|
718
|
+
from **different** sources. When the ids differ, the killed identity simply
|
|
719
|
+
has no outgoing hop past the kill point and backward reconstruction never
|
|
720
|
+
visits it — the dead branch is naturally unreachable, at no cost.
|
|
721
|
+
|
|
722
|
+
**Lever available to C5, not mandated here:** every edge carries `nodeId` and
|
|
723
|
+
`line`, so a reconstruction can require hop ordering to be non-decreasing
|
|
724
|
+
along a path, or de-prioritize paths that are not. Whether that is worth its
|
|
725
|
+
false-negative risk on loops and back-edges is C5's call, made against real
|
|
726
|
+
measurements.
|
|
727
|
+
|
|
728
|
+
### 9.3 Cyclic DAGs are possible
|
|
729
|
+
|
|
730
|
+
`x = y; y = x;` produces a two-cycle for a shared id. Reconstruction (C5) must
|
|
731
|
+
be cycle-safe by construction — a visited set per path, plus the hop budget —
|
|
732
|
+
and a path truncated by cycle-breaking must be reported as truncated, per
|
|
733
|
+
§18.4. Stated here so C5 does not treat acyclicity as an inherited guarantee:
|
|
734
|
+
**the "DAG" in FR-303 names the intent, not a property this recording
|
|
735
|
+
mechanism enforces.**
|
|
736
|
+
|
|
737
|
+
### 9.4 The join key does not distinguish entry contexts — a real gap for C3, named now
|
|
738
|
+
|
|
739
|
+
> **CLOSED by increment C3 — see §13.3**, which adds the `context` field
|
|
740
|
+
> proposed at the end of this section and moves the join key to
|
|
741
|
+
> `(scope, nodeId, dataElementId, context)`. The worked example below was
|
|
742
|
+
> reproduced, and both phantoms shown excluded, in this design's own
|
|
743
|
+
> now-deleted PoC file at design time, and again in the permanent suite
|
|
744
|
+
> (`test/lineage/engine-provenance-interprocedural.test.js`) once shipped.
|
|
745
|
+
> The section is kept as written because it is the reasoning §13.3 rests on.
|
|
746
|
+
|
|
747
|
+
`(scope, nodeId, dataElementId)` is the join key throughout this document
|
|
748
|
+
(§2.2). It is sufficient for ONE analysis run of ONE function under ONE
|
|
749
|
+
entry state. It is NOT sufficient once `FieldIdentitySummaryCache` (Sub-
|
|
750
|
+
project B, B1/B6) computes up to 16 distinct entry contexts for the SAME
|
|
751
|
+
qid — which `driver.js` already triggers today, independent of anything
|
|
752
|
+
Sub-project C adds. Two contexts of the same function emit hops that share
|
|
753
|
+
`(scope, nodeId, dataElementId)` but describe DIFFERENT endpoints:
|
|
754
|
+
|
|
755
|
+
```js
|
|
756
|
+
function g(x) { const y = x; return y; }
|
|
757
|
+
// context A (entry: x.email → data:email): in-half from 'x.email', out-half to 'y.email'
|
|
758
|
+
// context B (entry: x → data:email): in-half from 'x', out-half to 'y'
|
|
759
|
+
// joined at n1 for data:email: 2 in-halves × 2 out-halves = 4 edges,
|
|
760
|
+
// 2 of which (x.email→y, x→y.email) never actually happened in either context
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
§8's monotonicity argument does not cover this — it is scoped to worklist
|
|
764
|
+
revisits WITHIN one analysis run, and neither context's hop set is a subset
|
|
765
|
+
of the other's. This is a different failure from §9.1's cross-join (that one
|
|
766
|
+
is real ambiguity within a single, real execution; this one mixes hops from
|
|
767
|
+
executions that never coexisted).
|
|
768
|
+
|
|
769
|
+
**Not this increment's or C2's problem to fix** — C1/C2 only ever run one
|
|
770
|
+
entry state at a time, so it cannot manifest yet. **Named here, now,
|
|
771
|
+
specifically so C3 (which is what actually turns on multi-context analysis
|
|
772
|
+
for provenance) does not rediscover it expensively**, matching this
|
|
773
|
+
document's own stated purpose. The fix is additive and cheap when C3 gets
|
|
774
|
+
there: add a `context` field (e.g. `hashState(entryState)`, reusing the
|
|
775
|
+
exact primitive `FieldIdentitySummaryCache` already keys on) to the record
|
|
776
|
+
and fold it into the join key — no change to the half-edge model, the node
|
|
777
|
+
granularity, or anything C1/C2 build.
|
|
778
|
+
|
|
779
|
+
### 9.5 The worklist's own iteration budget is an unrepresented truncation
|
|
780
|
+
|
|
781
|
+
`analyzeFunctionFieldIdentity`'s `ITER_BUDGET` (5000) is a defensive backstop
|
|
782
|
+
against a malformed/generated CFG — on a real, well-formed CFG the fixed
|
|
783
|
+
point is reached in finitely many steps and the budget is never hit (see
|
|
784
|
+
`engine.js`'s own comment on `ITER_BUDGET`). But if it ever IS hit, the
|
|
785
|
+
worklist `break`s and returns a silently partial fixed point — exactly the
|
|
786
|
+
"path budget exhausted" class §18.4 requires never be presented as "no
|
|
787
|
+
path." This design has no `lossReason` value for it today (`§3`'s value set
|
|
788
|
+
covers per-hop losses like `unsupported-target`/`dynamic-property-key`, not
|
|
789
|
+
a whole-analysis-run truncation). Defensive-only, not expected to fire on
|
|
790
|
+
real code — but §18.4 treats exactly this constraint as load-bearing, so it
|
|
791
|
+
should not be the one gap this document leaves unnamed. **For whichever
|
|
792
|
+
increment first surfaces analysis-level (not per-hop) truncation to a
|
|
793
|
+
consumer** (plausibly C5's reconstruction-result shape, per the scoping
|
|
794
|
+
doc's own §3): a function whose `analyzeFunctionFieldIdentity` run hit
|
|
795
|
+
`ITER_BUDGET` should mark its ENTIRE result set as budget-truncated, not
|
|
796
|
+
leave individual hops looking complete.
|
|
797
|
+
|
|
798
|
+
### 9.6 A callee's body is recorded once per ENTRY CONTEXT, not once per call site
|
|
799
|
+
|
|
800
|
+
**Added by increment C3, from a measurement, not a prediction** — see §13.4
|
|
801
|
+
for the run that produced it.
|
|
802
|
+
|
|
803
|
+
`FieldIdentitySummaryCache.compute(qid, entryState, analyzeFn)` memoizes:
|
|
804
|
+
`analyzeFn` — the thing that transitively emits the callee's own hops —
|
|
805
|
+
runs only on a cache MISS. So when two call sites bind the *same*
|
|
806
|
+
`(qid, entryState)`, the callee's internal hops are emitted **once**, for
|
|
807
|
+
whichever call site missed first. The later, cache-hitting call site emits
|
|
808
|
+
its own `call-arg-bind` and `call-resolved` hops (neither goes through the
|
|
809
|
+
cache) and nothing else.
|
|
810
|
+
|
|
811
|
+
**Accepted as a disclosed property, and it is not a loss** — *provided*
|
|
812
|
+
`peerContext` is recorded (§13.2). Both call sites' `call-arg-bind` hops
|
|
813
|
+
carry the same `peerContext`, which is exactly the `context` stamped on the
|
|
814
|
+
single recorded copy of the callee's body, so a consumer walking forward
|
|
815
|
+
from either call site reaches that body. The body is **shared, not
|
|
816
|
+
orphaned**; sharing one recorded body across N call sites that produce the
|
|
817
|
+
same callee behaviour *is* FR-303's compactness requirement, not a gap in
|
|
818
|
+
it. Proven by execution, not argued: the PoC asserts, for the two-call-site
|
|
819
|
+
fixture, that every bind hop's `peerContext` is a context whose body is
|
|
820
|
+
present in the record stream.
|
|
821
|
+
|
|
822
|
+
Two caveats, stated so a later increment does not rediscover them:
|
|
823
|
+
|
|
824
|
+
- **Without `peerContext` this WOULD be a silent loss**, and the naive
|
|
825
|
+
wiring is precisely the one that omits it. That is what makes
|
|
826
|
+
`peerContext` load-bearing rather than decorative, and it is why §13.4
|
|
827
|
+
does not need the much larger "cache stores and replays a per-summary hop
|
|
828
|
+
list" fix that was the alternative on the table.
|
|
829
|
+
- **The one genuinely orphaned case is a B6 cap degradation**, where the
|
|
830
|
+
bound context's body was never computed at all. That is a real §18.4
|
|
831
|
+
violation, it is separate from this section, and §13.6 handles it.
|
|
832
|
+
|
|
833
|
+
---
|
|
834
|
+
|
|
835
|
+
## 10. Full instrumentation checklist for increment C2
|
|
836
|
+
|
|
837
|
+
**Not to be implemented by this increment.** Increment C1's proof-of-concept
|
|
838
|
+
covers four sites only (§11). This table is C2's work order, written now while
|
|
839
|
+
the design is fresh, matching `DESIGN_INTRAPROCEDURAL.md`'s own precedent of
|
|
840
|
+
naming what is deferred rather than being silent about it.
|
|
841
|
+
|
|
842
|
+
**The enumeration principle, not a count:** *every* `case` in
|
|
843
|
+
`resolveExprIdentities`'s switch and *every* `case` in `step()`'s switch — plus
|
|
844
|
+
the `default` of each — must be visited and given a verdict, including a
|
|
845
|
+
verdict of "emits nothing, because …". A hand-counted "these N sites" claim is
|
|
846
|
+
precisely what let Sub-project A's rounds 3, 4 and 5 each ship an unexamined
|
|
847
|
+
site (see `DESIGN_INTRAPROCEDURAL.md`'s own note that "any future 'N sites'
|
|
848
|
+
phrasing in this document is a bug in the document"). If the switch grows a
|
|
849
|
+
case, this table is stale until it grows a row.
|
|
850
|
+
|
|
851
|
+
### 10.1 `resolveExprIdentities` — every case
|
|
852
|
+
|
|
853
|
+
| case | hop type(s) | `fromPath` | `toPath` | notes |
|
|
854
|
+
|---|---|---|---|---|
|
|
855
|
+
| `ident` | production | contributing state key per id (Decision 6) | `null` | `syntacticPath` = the resolved path. `byPath` construction reads the same keys — do not double-emit. |
|
|
856
|
+
| `member` (path branch, no wildcard) | selection | contributing state key per id | `null` | `syntacticPath` = the resolved dotted path. |
|
|
857
|
+
| `member` (path branch, wildcard) | selection | `definitePrefixBeforeWildcard(path)` — **never** the `'*'` path | `null` | `widenReason: 'dynamic-property-key'`, `syntacticPath` = the raw `'…*…'` path. Decision 5. |
|
|
858
|
+
| `member` (non-path base, `prop !== '*'`) | selection | `null` (base is an in-flight value, not a state key) | `null` | The base's own recursion already emitted the state-backed in-halves; this hop only annotates the selection. |
|
|
859
|
+
| `member` (non-path base, `prop === '*'`) | selection | `null` | `null` | `widenReason: 'dynamic-property-key'`. |
|
|
860
|
+
| `literal` | — | — | — | Emits nothing: no identity exists to have provenance. |
|
|
861
|
+
| `unknown` / `default` | — | — | — | Emits nothing. **Consider** a `lossReason: 'unmodelled-expression'` marker for the coverage ledger — but only when an identity is demonstrably being dropped, which for these cases it is not (nothing was resolved). Decide in C2 with evidence, do not add speculatively. |
|
|
862
|
+
| `object` (plain property) | production | `null` | `null` | Per id in the property's contribution. Edge annotation (`via: 'object'`). Structure is *preserved* here — no `lossReason`. |
|
|
863
|
+
| `object` (spread property) | production | `null` | `null` | `subKind: 'object'`, but distinguish spread in a note if C4 needs it — the byPath merge is structurally different (top-level siblings). |
|
|
864
|
+
| `object` (`prop.key === '*'`) | production | `null` | `null` | `widenReason: 'dynamic-property-key'`. Folds into the residual, so the eventual `toPath` is the container root — already correct under Decision 5. |
|
|
865
|
+
| `array` | production | `null` | `null` | Structure-flattening **by design** (spread ambiguity, ADR §4). **No `lossReason`** — identity propagates fully; only per-index distinction is lost, which is a precision fact, not an identity loss. Getting this wrong would flood the coverage ledger with false gaps. |
|
|
866
|
+
| `tpl` | production | `null` | `null` | Transformation-bearing: the identity is embedded in a new string. Prime FR-307 / Sub-project D raw material. No `widenReason` — ADR §4 is explicit that this is an *explicit* flow, not a widened one. |
|
|
867
|
+
| `binary` | production | `null` | `null` | Same as `tpl`. Deliberately separate from `logical`. |
|
|
868
|
+
| `logical` | production | `null` | `null` | Structure-preserving (short-circuit returns an operand verbatim). |
|
|
869
|
+
| `union` (ternary) | production | `null` | `null` | Structure-preserving. Both branches emit; the resulting multiple in-halves are *correct* — this is FR-305's genuine multiple-path case, not §9.1's phantom. |
|
|
870
|
+
| `call` (unresolved) | production | `null` | `null` | `widenReason: 'unresolved-call'`. |
|
|
871
|
+
| `call` (resolved via `ctx.resolveCallSummary`) | production | `null` | `null` | `subKind: 'call-resolved'`, `widenReason: null`. The cross-function stitching itself is **C3**, not C2 — C2 records only that a resolved call contributed. |
|
|
872
|
+
| `assign-expr` | production | `null` | `null` | Pure pass-through; forwards the source's `widened`, so forward its `widenReason` too. Note the known limitation: it does **not** write to state, so there is no write-out hop — an in-half with no out-half that is *not* a loss. |
|
|
873
|
+
|
|
874
|
+
**2026-08-30 implementation note (C2, Task 1):** the `assign-expr` row's
|
|
875
|
+
"forward its `widenReason` too" instruction assumed a real reason string was
|
|
876
|
+
available to forward. It isn't — `resolveExprIdentities`'s return shape is
|
|
877
|
+
`{flat, byPath, widened}`, a boolean, not a reason string (that's exactly
|
|
878
|
+
Decision 3's own already-disclosed gap: only a *hop*, not the general
|
|
879
|
+
return value, carries `widenReason`). Resolved by applying the SAME
|
|
880
|
+
documented-approximate `'unresolved-call'` convention `step()`'s
|
|
881
|
+
`assign`/`return` cases already use when forwarding a bare `widened` flag
|
|
882
|
+
(`r.widened && r.flat.size > 0 ? 'unresolved-call' : null`) — not a new
|
|
883
|
+
mechanism, just the existing one, applied consistently. This inherits
|
|
884
|
+
those cases' already-disclosed mislabeling risk (a widening actually
|
|
885
|
+
caused by a dynamic property key can read as `'unresolved-call'`); it does
|
|
886
|
+
not worsen it. A real fix still needs `resolveExprIdentities` to thread an
|
|
887
|
+
actual reason string through its return value, out of C2's scope.
|
|
888
|
+
|
|
889
|
+
### 10.2 `step()` — every CFG node kind
|
|
890
|
+
|
|
891
|
+
| case | hop type | `fromPath` | `toPath` | notes |
|
|
892
|
+
|---|---|---|---|---|
|
|
893
|
+
| `assign`, target not a string | write-out | `null` | `null` | **Loss site.** `lossReason: 'unsupported-target'`. Requires resolving `node.source` purely to learn the ids. **Not merely discarded computation, a design review flagged this understated:** resolving `node.source` recursively runs the FULL `resolveExprIdentities` tree for that expression, which — when `ctx?.recordHop` is present — genuinely EMITS real in-half (`production`/`selection`) hops for whatever `node.source` reads, exactly as it would for any other resolved expression. These are not spurious: they correctly join with THIS row's `lossReason` write-out to show "this data was read here, then lost, because the target couldn't be represented" — arguably necessary for §18.4's transparency requirement, not incidental. If no identity resolves from `node.source`, none of that fires and there is nothing to lose. **CORRECTION (a later final whole-branch review, increment C2): "permitted (Decision 1) but must be guarded on `ctx?.recordHop`" — this document's own earlier wording — is WRONG, and describes a real bug increment C2 shipped and then fixed.** `resolveExprIdentities` is not side-effect-free when `ctx.resolveCallSummary` is present: its `call` case can trigger `FieldIdentitySummaryCache.compute()` for a callee, which registers a context against that function's distinct-context cap (`summaries.js`). Gating the resolve itself (not just the hop emission) on `ctx?.recordHop` meant a recorder's mere PRESENCE could consume cap budget a no-recorder run never would, silently changing a LATER, unrelated call site's own resolution once the cap was hit — reproduced with real parsed source, in the unsound direction (attaching a recorder made the analysis LOSE an identity a no-recorder run kept). Decision 1's "extra, discarded computation" allowance covers computation that is genuinely inert to skip; this resolve never qualified, because it can mutate cache state a sibling call site later reads. **The resolve itself must always run unconditionally; only the HOP EMISSION may be gated on `ctx?.recordHop`** — exactly the pattern the sibling `assign` (normal) branch already used, which is why that branch never exhibited this bug. Regression-tested with a real `FieldIdentitySummaryCache` at a low cap in `test/lineage/engine-provenance.test.js`. |
|
|
894
|
+
| `assign`, wildcard target | write-out | `null` | `definitePrefixBeforeWildcard(node.target)` | `subKind: 'assign-weak'`, `widenReason: 'dynamic-property-key'`, `syntacticPath` = raw target. **Weak update** — no kill. One record per `(containerPath, id)`. |
|
|
895
|
+
| `assign`, normal — residual write | write-out | `null` | `node.target` | One record per id in the residual. |
|
|
896
|
+
| `assign`, normal — `byPath` writes | write-out | `null` | `` `${node.target}.${subPath}` `` | **One record per `addIdentity` call**, at the exact sub-path written. Recording `node.target` here instead is the most likely C2 mistake: it would claim `o` where the identity is really at `o.email`, mismatching the granularity every read hop uses and disconnecting the DAG. |
|
|
897
|
+
| `assign`, normal — the kill (`removeIdentitiesAt(stateIn, node.target)`) | — | — | — | **No row of its own, and that is the correct answer, not an oversight** (§10's own rule requires every case get a verdict, including "emits nothing, because…" — this is that verdict, made explicit per a design review's request). §9.2 already covers this from the reconstruction side: a killed identity simply has no outgoing hop past the kill point, so backward reconstruction never visits the dead branch — at no representation cost. Nothing to emit here beyond what the surrounding residual/byPath writes above already record. |
|
|
898
|
+
| `call` (bare call statement) | write-out | `null` | `null` | `subKind: 'call-arg'`. The value leaves the analysis via an argument — not a loss, an escape. This is the natural sink-attachment point for Sub-project D. |
|
|
899
|
+
| `return` | write-out | `null` | `null` | `subKind: 'return'`. Deliberately **not** a pseudo-path like `'@return'` — mixing a fabricated token into the endpoint namespace is the bug class of Decision 5. C3/C4 identify a function exit by `kind === 'write-out' && subKind === 'return' && toPath === null`, scoped by `scope`. |
|
|
900
|
+
| `throw` | — | — | — | Currently a no-op in `step()`. Emits nothing. Revisit only if `throw` ever becomes a real transfer function. |
|
|
901
|
+
| `entry` / `exit` / `noop` / `loop-header` / `if` / `unknown` / `default` | — | — | — | No transfer, nothing to record. `if` is where **implicit/control-dependent** flow would eventually be recorded for FR-306's first half — the engine models no implicit flow today, so there is nothing to emit; do not invent one in C2. |
|
|
902
|
+
|
|
903
|
+
### 10.3 Cross-file sites C3 must add (not C2)
|
|
904
|
+
|
|
905
|
+
`summaries.js`'s `entryStateFromCall` (argument→parameter write-outs, in the
|
|
906
|
+
**callee's** scope), `applyAtCallSite` (callee mutation→caller write-outs), and
|
|
907
|
+
`summaryFromAnalysisResult`'s return-fact union (the callee-exit→caller-value
|
|
908
|
+
hop). Plus the three ctx holes and the missing call-site identity in §7.4. C3
|
|
909
|
+
must also mark hops recorded during a B5 bottom-stub round and hops from a
|
|
910
|
+
B6 context-capped, degraded summary — §3 of the scoping doc names both, and
|
|
911
|
+
neither is representable in today's shape without a new `subKind` or
|
|
912
|
+
`lossReason` value. That is C3's call to make, and it is additive.
|
|
913
|
+
|
|
914
|
+
> **2026-08-30 correction (increment C3, per this document's own
|
|
915
|
+
> fix-rather-than-diverge policy).** Three of this paragraph's instructions
|
|
916
|
+
> were wrong once real code was attempted, and §13 supersedes them:
|
|
917
|
+
>
|
|
918
|
+
> - *"argument→parameter write-outs, in the **callee's** scope"* — **no.**
|
|
919
|
+
> The binding out-half must be stamped with the **caller's**
|
|
920
|
+
> `scope`/`nodeId`/`context`, because that is the only way it joins with
|
|
921
|
+
> the in-halves the argument expression's own resolution emits (which run
|
|
922
|
+
> under the caller's `stepCtx`). The callee's identity is carried on the
|
|
923
|
+
> new `peerScope`/`peerContext` fields instead. See §13.2.
|
|
924
|
+
> - *"C3 must also mark hops recorded during a B5 bottom-stub round"* —
|
|
925
|
+
> there is **nothing to mark**: a bottom stub's `returnFlat` is empty, so
|
|
926
|
+
> `case 'call'` emits no `call-resolved` hop at all on that round. See
|
|
927
|
+
> §13.6, which measured this.
|
|
928
|
+
> - *"`applyAtCallSite` (callee mutation→caller write-outs)"* — **deferred,
|
|
929
|
+
> not done.** `applyAtCallSite` is exported and unit-tested but is wired
|
|
930
|
+
> into nothing: `engine.js` never calls it (a fact `test/lineage/driver
|
|
931
|
+
> .test.js` already records). Instrumenting a mechanism that never runs
|
|
932
|
+
> would ship untested-by-construction hop code. See §13.7's exclusions.
|
|
933
|
+
|
|
934
|
+
**Also C3's, and load-bearing, not optional:** §9.4's `context` field. C3 is
|
|
935
|
+
what actually exercises multiple entry contexts for the same qid through
|
|
936
|
+
this recording mechanism — closing §9.4 is a precondition for C3's own hops
|
|
937
|
+
being correct, not a nice-to-have alongside them.
|
|
938
|
+
|
|
939
|
+
---
|
|
940
|
+
|
|
941
|
+
## 11. What increment C1's proof-of-concept (Task 2) covers
|
|
942
|
+
|
|
943
|
+
Four sites only: `resolveExprIdentities`'s `ident` and `object`, and
|
|
944
|
+
`step()`'s `assign` (non-wildcard branch) and `return`. Enough to prove one
|
|
945
|
+
in-half, one annotation-only hop, one out-half, and one exit marker join into
|
|
946
|
+
a real, ordered path against real parsed JS/TS — and nothing more. Everything
|
|
947
|
+
in §10 beyond those four is C2's.
|
|
948
|
+
|
|
949
|
+
Task 2 should assert, specifically:
|
|
950
|
+
|
|
951
|
+
1. **Opt-out is genuinely zero-cost.** An existing scenario, run with no
|
|
952
|
+
`recordHop`, produces byte-identical output.
|
|
953
|
+
2. **Contributing-key semantics** (Decision 6), not queried-path semantics —
|
|
954
|
+
the §6 worked example distinguishes them and is the natural fixture.
|
|
955
|
+
3. **Per-identity records** (Decision 4) — two ids through one construct give
|
|
956
|
+
two records, never one carrying a Set.
|
|
957
|
+
4. **`nodeId` comes from the worklist key** (Decision 7) — assert distinct
|
|
958
|
+
`nodeId`s on a hand-built fixture whose nodes set no `id` field.
|
|
959
|
+
|
|
960
|
+
---
|
|
961
|
+
|
|
962
|
+
## 12. What this document deliberately does NOT decide
|
|
963
|
+
|
|
964
|
+
The storage structure and its stable ID (`path-store.js`, `ids.js`'s `pathId`
|
|
965
|
+
— C4); the backward-walk query, the alternate-path cap, the prioritization
|
|
966
|
+
rule, and the truncation-is-never-silent result shape (C5); how `widenReason`
|
|
967
|
+
maps onto FR-306's evidence grades (C6); any `DataFlowGraph v1` output
|
|
968
|
+
(Sub-project E); source/sink registries and transformation-kind recognition
|
|
969
|
+
(Sub-project D); collapsing repeated library/framework nodes into typed
|
|
970
|
+
summary hops (§18.4 — home undecided, plausibly D or C4).
|
|
971
|
+
|
|
972
|
+
**Updated 2026-08-30 by increment C3.** Nothing needed removing from this
|
|
973
|
+
list — the questions §13 closes were open in §7.4 / §9.4 / §10.3, never in
|
|
974
|
+
this section's own prose. For the record, §13 now decides: the `context` field and the join-key extension (§9.4 →
|
|
975
|
+
§13.3); the `resolveCallSummary` / `entryStateFromCall` signature changes
|
|
976
|
+
and the three §7.4 ctx holes (§13.1); the argument→parameter binding hop's
|
|
977
|
+
shape (§13.2); whether call-site identity needs its own `hopSite` field
|
|
978
|
+
(§13.5 — it does not); and how a B5/B6-degraded resolution is represented
|
|
979
|
+
(§13.6). Added to this list by §13, i.e. deliberately punted further:
|
|
980
|
+
|
|
981
|
+
- **How C4 materializes a cross-scope half-edge.** §13.2 records the peer
|
|
982
|
+
endpoint's `(scope, context)`; it does not decide whether C4 renders the
|
|
983
|
+
caller→callee transition as one edge, two, or a typed "call" segment.
|
|
984
|
+
- **Instrumenting `applyAtCallSite`** — deferred until it is actually wired
|
|
985
|
+
into `engine.js` (see §10.3's correction and §13.7).
|
|
986
|
+
- **Interprocedural provenance for the hand-rolled-parser languages.**
|
|
987
|
+
`createCallGraphLookup` resolves nothing for Python/Ruby/PHP/Go/Java/C#/
|
|
988
|
+
Kotlin IR (their `callee` is a flat dotted string, deliberately not
|
|
989
|
+
resolved — see `summaries.js`'s `_resolvableCalleeName`), and no
|
|
990
|
+
member-expression callee resolves anywhere without CHA. Every such call
|
|
991
|
+
takes the unresolved fallback, so it gets `production/call` and no
|
|
992
|
+
cross-function hops at all. C3 does not change that boundary; it inherits
|
|
993
|
+
it from B3, and it is a coverage fact a consumer must not read as "no
|
|
994
|
+
flow crosses this call."
|
|
995
|
+
|
|
996
|
+
Two levers are named but **not** adopted here, with their trigger conditions,
|
|
997
|
+
so a later increment adopts them on evidence rather than on taste: the `slot`
|
|
998
|
+
correlation field (§9.1) and hop-order filtering during reconstruction (§9.2).
|
|
999
|
+
|
|
1000
|
+
**Updated 2026-08-30 by increment C4.** The first item on this list — "the
|
|
1001
|
+
storage structure and its stable ID (`path-store.js`, `ids.js`'s `pathId` —
|
|
1002
|
+
C4)" — is now **decided in §14**, including the two things this section
|
|
1003
|
+
could not have anticipated: cross-function node addressing (§14.3) and the
|
|
1004
|
+
correction §2.2's annotation rule needed before the resolved-call return
|
|
1005
|
+
value could be stitched at all (§14.4). §14 also settles the "one edge, two,
|
|
1006
|
+
or a typed call segment" question this section punted (two cross-scope
|
|
1007
|
+
edges — §14.3/§14.4), declines the library/framework-collapse item as
|
|
1008
|
+
Sub-project D's rather than C4's (§14.9), and supplies part of the evidence
|
|
1009
|
+
§9.1's `slot` lever was waiting on (§14.7). Everything else on this list is
|
|
1010
|
+
still open, and `pathId` itself is now deliberately left unclaimed for C5
|
|
1011
|
+
(§14.5).
|
|
1012
|
+
|
|
1013
|
+
**Updated 2026-08-30 by increment C6.** This list's third item — "how
|
|
1014
|
+
`widenReason` maps onto FR-306's evidence grades (C6)" — is now **decided
|
|
1015
|
+
in §16**, and the answer is wider than the item's own wording: `widenReason`
|
|
1016
|
+
is one of *four* grading inputs (`ambiguousCorrelation`, `lossReason`,
|
|
1017
|
+
`widenReason`, and — the one §14.9's own correction warned about, measured
|
|
1018
|
+
larger than that warning states — reasons carried in `annotations[]`
|
|
1019
|
+
rather than on the edge's top-level arrays, §16.5). §16 also declines to
|
|
1020
|
+
reuse `protection.js`'s `EVIDENCE_GRADES` for it, with the reason stated
|
|
1021
|
+
(§16.2), and keeps the `implicit` half of FR-306 as a **reserved,
|
|
1022
|
+
unreachable** tier rather than inventing an implicit-flow analysis §10.2
|
|
1023
|
+
explicitly forbids (§16.3). Everything else on this list — the backward-walk
|
|
1024
|
+
question aside, which §15 closed — is still open.
|
|
1025
|
+
|
|
1026
|
+
---
|
|
1027
|
+
|
|
1028
|
+
## 13. Interprocedural hop recording (Sub-project C, increment 3)
|
|
1029
|
+
|
|
1030
|
+
**Implemented.** Design landed as `a2d42695` (fix round `fa27354e`, correction
|
|
1031
|
+
`a8846463`); shipped by Task 2 (`5ee9143d`) and Task 3 (`119cff02`), whole-branch
|
|
1032
|
+
reviewed clean at `ac6ed4c2`. The section below is kept in its original,
|
|
1033
|
+
design-time voice (future tense, "the follow-up task must…") as the historical
|
|
1034
|
+
record of what was decided and why — read `summaries.js`/`engine.js`/`driver.js`
|
|
1035
|
+
themselves, or `scanner/src/lineage/CLAUDE.md`'s own module-table rows, for the
|
|
1036
|
+
current, as-shipped description. The design-time PoC file this section
|
|
1037
|
+
originally shipped alongside it (`engine-provenance-interprocedural-poc.test.js`)
|
|
1038
|
+
no longer exists — Task 3 absorbed everything it proved into the permanent
|
|
1039
|
+
suite (`engine-provenance-interprocedural.test.js`, `engine-provenance.test.js`,
|
|
1040
|
+
`driver.test.js`) and deleted it, per item 15 below.
|
|
1041
|
+
|
|
1042
|
+
Added 2026-08-30. Everything in this section is **decided**, not proposed,
|
|
1043
|
+
and every behavioural claim in it was produced by running code in
|
|
1044
|
+
`scanner/test/lineage/engine-provenance-interprocedural-poc.test.js` — a
|
|
1045
|
+
throwaway-named PoC committed alongside this section, which the follow-up
|
|
1046
|
+
implementation task should re-point at the shipped functions and then fold
|
|
1047
|
+
into `engine-provenance.test.js`.
|
|
1048
|
+
|
|
1049
|
+
> **Fix round 1 (2026-08-30), from this increment's own task review.** One
|
|
1050
|
+
> BLOCKING defect and three disclosure gaps were found in §13's first
|
|
1051
|
+
> draft and are corrected in place, each marked where it applies: §13.2a
|
|
1052
|
+
> now forwards a RECORDER-ONLY ctx at the hole-2 site (the original
|
|
1053
|
+
> full-ctx forwarding changed analysis results with no recorder attached,
|
|
1054
|
+
> in the unsound direction under a tight cap); §13.2 discloses the
|
|
1055
|
+
> multi-argument cross-join; §13.6 is now prototyped rather than only
|
|
1056
|
+
> designed, and pins how C4 must read its loss hop under §2.2's annotation
|
|
1057
|
+
> rule. Recorded here rather than silently rewritten, per this document's
|
|
1058
|
+
> own policy.
|
|
1059
|
+
|
|
1060
|
+
**What C3 does NOT touch:** `field-identity.js` (unchanged, byte-for-byte,
|
|
1061
|
+
as in every prior increment), and the isolation rule (`src/lineage/` may
|
|
1062
|
+
import pure utilities from `src/dataflow/`, never `dataflow/engine.js` or
|
|
1063
|
+
`dataflow/summaries.js`). Every change below is additive and inert when
|
|
1064
|
+
`ctx.recordHop` is absent, matching Decision 1.
|
|
1065
|
+
|
|
1066
|
+
### 13.0 The record shape gains exactly three fields
|
|
1067
|
+
|
|
1068
|
+
Extending §3's shape. Same contract as every existing field: **always
|
|
1069
|
+
present, `null` when inapplicable, never `undefined`, never an omitted
|
|
1070
|
+
key**, so C4 can still hash a record without a canonicalization step.
|
|
1071
|
+
|
|
1072
|
+
```js
|
|
1073
|
+
context: string | null, // hashState(entryState) of the analysis run
|
|
1074
|
+
// this hop was emitted in. null only when a
|
|
1075
|
+
// hop is emitted outside analyzeFunctionFieldIdentity.
|
|
1076
|
+
peerScope: string | null, // the OTHER function's qid, on a cross-function hop
|
|
1077
|
+
peerContext: string | null, // that function's entry-context hash
|
|
1078
|
+
```
|
|
1079
|
+
|
|
1080
|
+
`peerScope`/`peerContext` name the function on the far side of a
|
|
1081
|
+
cross-function hop. **The direction is read off the existing `kind` field,
|
|
1082
|
+
not off a fourth new field** — §2.2 already defines `kind` as "which half
|
|
1083
|
+
this is", so a `write-out` hop's peer is its *destination* and a
|
|
1084
|
+
`production` hop's peer is its *source*. That is why this is two fields and
|
|
1085
|
+
not four (`fromScope`/`fromContext`/`toScope`/`toContext`): the direction is
|
|
1086
|
+
already in the record.
|
|
1087
|
+
|
|
1088
|
+
Only two hop shapes ever set them: `write-out/call-arg-bind` (§13.2) and
|
|
1089
|
+
`production/call-resolved` (§13.2's return half). Every other hop carries
|
|
1090
|
+
`null` for both, stamped by the wrapper in §13.3.
|
|
1091
|
+
|
|
1092
|
+
**Additivity, verified rather than assumed.** No test in
|
|
1093
|
+
`engine-provenance.test.js` compares a hop object by `deepEqual` or asserts
|
|
1094
|
+
a closed set of keys. Its two shape guards (lines ~595 and ~1383) are
|
|
1095
|
+
`hasOwnProperty` + `!== undefined` over a REQUIRED list, and its
|
|
1096
|
+
count assertions (`hops.length === 14`, and the per-shape counts) run on
|
|
1097
|
+
`dedupeHops`, whose key is `JSON.stringify(h, Object.keys(h).sort())`.
|
|
1098
|
+
`context` is constant within a single analysis run, so it cannot split a
|
|
1099
|
+
dedupe group. The PoC re-runs the §6 fixture with all three fields stamped
|
|
1100
|
+
on and still gets exactly 14 deduplicated records. **No existing assertion
|
|
1101
|
+
needs updating.**
|
|
1102
|
+
|
|
1103
|
+
### 13.1 `resolveCallSummary` gains a 4th parameter; `case 'call'` is its one call site
|
|
1104
|
+
|
|
1105
|
+
**Decided:**
|
|
1106
|
+
|
|
1107
|
+
```js
|
|
1108
|
+
resolveCallSummary(calleeExpr, callArgs, callerState, ctx) // ctx: NEW, 4th, optional
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
and `engine.js`'s `case 'call'` (today engine.js:505) becomes
|
|
1112
|
+
|
|
1113
|
+
```js
|
|
1114
|
+
const summary = ctx.resolveCallSummary(expr.callee, expr.args ?? [], state, ctx);
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
That is the whole of hole 1. `ctx` there is already the *stamped* `stepCtx`
|
|
1118
|
+
(§7.2), threaded down unchanged through every recursive
|
|
1119
|
+
`resolveExprIdentities` call (§7.3), so passing it hands `summaries.js`
|
|
1120
|
+
both the caller's recorder **and** the caller's `scope`/`nodeId`/`line`/
|
|
1121
|
+
`context` stamping in one object. Fourth position keeps every existing
|
|
1122
|
+
3-parameter resolver stub — including the hand-built ones in
|
|
1123
|
+
`engine-provenance.test.js` and `engine-integration.test.js` — working
|
|
1124
|
+
untouched.
|
|
1125
|
+
|
|
1126
|
+
**Why an explicit parameter and not `this`.** Today `case 'call'` invokes
|
|
1127
|
+
the resolver as a *method* (`ctx.resolveCallSummary(...)`), so `this` is
|
|
1128
|
+
already the stamped `stepCtx`. The PoC exploits exactly that to prove
|
|
1129
|
+
reachability without modifying `engine.js`, and pins it with an assertion.
|
|
1130
|
+
It is **not** the shipped fix: `createCallSummaryResolver` passes the
|
|
1131
|
+
resolver down as `{ resolveCallSummary }` and `driver.js` builds a fresh
|
|
1132
|
+
ctx object per function, so any caller that destructures
|
|
1133
|
+
(`const { resolveCallSummary } = ctx`) silently gets `this === undefined`
|
|
1134
|
+
and the recorder vanishes with no error. A load-bearing channel must not
|
|
1135
|
+
depend on call syntax.
|
|
1136
|
+
|
|
1137
|
+
**Measured, before the change:** the resolver receives `arguments.length
|
|
1138
|
+
=== 3` and `arguments[3] === undefined`.
|
|
1139
|
+
|
|
1140
|
+
### 13.2 The argument→parameter binding hop
|
|
1141
|
+
|
|
1142
|
+
Two changes, one at each end of a resolved call.
|
|
1143
|
+
|
|
1144
|
+
**(a) `entryStateFromCall` gains `ctx` as an optional 4th parameter — and
|
|
1145
|
+
forwards a RECORDER-ONLY derivation of it, never the ctx itself:**
|
|
1146
|
+
|
|
1147
|
+
```js
|
|
1148
|
+
entryStateFromCall(paramNames, callArgs, callerState, ctx) // ctx: NEW, 4th, optional
|
|
1149
|
+
// inside, once, before the loop:
|
|
1150
|
+
const argCtx = ctx?.recordHop ? { recordHop: ctx.recordHop } : undefined;
|
|
1151
|
+
// ... then, per argument:
|
|
1152
|
+
const resolved = resolveExprIdentities(callerState, callArgs[i], argCtx);
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
Return shape unchanged (`summaries.test.js`'s existing calls keep working).
|
|
1156
|
+
It has exactly **one** call site, `summaries.js:338` inside
|
|
1157
|
+
`createCallSummaryResolver`; `driver.js` does not call it at all.
|
|
1158
|
+
|
|
1159
|
+
That forwarding is what makes the argument's **in-halves** exist: ordinary
|
|
1160
|
+
`production`/`selection` hops with correct contributing-key `fromPath`s
|
|
1161
|
+
(Decision 6), stamped with the caller's `scope`/`nodeId`/`context` because
|
|
1162
|
+
`ctx.recordHop` is the caller's already-stamped recorder (§7.2).
|
|
1163
|
+
|
|
1164
|
+
> **Why `resolveCallSummary` MUST be stripped here (2026-08-30, fix round 1
|
|
1165
|
+
> — this section's first draft forwarded the full `ctx` and was wrong).**
|
|
1166
|
+
> `resolveExprIdentities` branches on `ctx?.resolveCallSummary`. Handing it
|
|
1167
|
+
> a live one at *this* site makes an argument that is itself a resolvable
|
|
1168
|
+
> call — `sink(scrub(user))` — start resolving interprocedurally, where the
|
|
1169
|
+
> shipped engine takes the unresolved-call fallback. That changes the
|
|
1170
|
+
> ANALYSIS RESULT with **no recorder attached anywhere**, breaking this
|
|
1171
|
+
> sub-project's flat "byte-identical without a recorder" bar. Reproduced
|
|
1172
|
+
> two ways, both recorder-free, both in the PoC:
|
|
1173
|
+
>
|
|
1174
|
+
> - `function scrub(u){return {safe:1}} function sink(p){return p}
|
|
1175
|
+
> function caller(user){const out = sink(scrub(user)); return out;}`
|
|
1176
|
+
> seeded `user.email → data:email`: shipped keeps `data:email` in `out`;
|
|
1177
|
+
> full-ctx forwarding **drops** it.
|
|
1178
|
+
> - Two call sites sharing a cap-1 cache: the extra nested resolve consumes
|
|
1179
|
+
> the callee's only context slot, so a later, unrelated call degrades to
|
|
1180
|
+
> an empty summary — an identity the shipped engine KEEPS is **lost**.
|
|
1181
|
+
> That is the unsound direction, and it is the same class of bug C2
|
|
1182
|
+
> already shipped once and fixed (§10.2's `assign`/unsupported-target
|
|
1183
|
+
> correction: a recorder's mere presence must never consume cap budget).
|
|
1184
|
+
>
|
|
1185
|
+
> The recorder-only derivation adds hop RECORDING without adding
|
|
1186
|
+
> RESOLUTION. With a recorder attached, the argument's in-half is then
|
|
1187
|
+
> recorded **honestly against the path the analysis actually took** —
|
|
1188
|
+
> `production/call` with `widenReason: 'unresolved-call'`, not a
|
|
1189
|
+
> `call-resolved` that never happened — which §8 requires anyway ("fixing"
|
|
1190
|
+
> it here would mean disagreeing with the analysis the hops exist to
|
|
1191
|
+
> explain).
|
|
1192
|
+
>
|
|
1193
|
+
> **Note for whoever writes the guard:** §13.7 item 16's own
|
|
1194
|
+
> with-recorder/without-recorder comparison is structurally BLIND to this,
|
|
1195
|
+
> because the divergence moves both arms identically. The guard that
|
|
1196
|
+
> catches it must compare against a **hardcoded pre-C3 golden literal**
|
|
1197
|
+
> (§13.7 item 15b), never "the shipped resolver" by name — once this task's
|
|
1198
|
+
> own wiring lands, the shipped resolver IS that wiring, so a live
|
|
1199
|
+
> comparison degenerates into `assert.deepEqual(result, result)`. The PoC's
|
|
1200
|
+
> two regression tests (three arms: shipped / fixed / the hazard, the last
|
|
1201
|
+
> pinned so the test cannot go vacuous) prove this NOW, while "shipped"
|
|
1202
|
+
> still means something distinct — the golden values they hardcode are
|
|
1203
|
+
> what item 15b's follow-up test must carry forward.
|
|
1204
|
+
|
|
1205
|
+
**(b) The binding out-half is emitted in `createCallSummaryResolver`,
|
|
1206
|
+
not in `entryStateFromCall`:**
|
|
1207
|
+
|
|
1208
|
+
```js
|
|
1209
|
+
{
|
|
1210
|
+
kind: 'write-out', subKind: 'call-arg-bind',
|
|
1211
|
+
fromPath: null,
|
|
1212
|
+
toPath: <paramName> | `${paramName}.${subPath}`,
|
|
1213
|
+
dataElementId: <id>,
|
|
1214
|
+
syntacticPath: null, widenReason: null, lossReason: null,
|
|
1215
|
+
peerScope: <callee qid>, peerContext: hashState(<callee entryState>),
|
|
1216
|
+
}
|
|
1217
|
+
```
|
|
1218
|
+
|
|
1219
|
+
emitted once per `(path, id)` entry of the freshly built `entryState`, and
|
|
1220
|
+
guarded on `ctx?.recordHop`.
|
|
1221
|
+
|
|
1222
|
+
Four decisions, each with its reason:
|
|
1223
|
+
|
|
1224
|
+
- **`kind: 'write-out'`, not `'production'`.** §2.2's taxonomy is
|
|
1225
|
+
directional: `production`/`selection` are *inbound* halves carrying a
|
|
1226
|
+
`fromPath`; `write-out` is the *outbound* half carrying a `toPath`. The
|
|
1227
|
+
binding has a real destination path and joins with the argument
|
|
1228
|
+
expression's inbound halves at the caller's node — it is structurally the
|
|
1229
|
+
same event as `assign`, which is exactly how `summaries.js` already
|
|
1230
|
+
describes `entryStateFromCall` in its own header. `production/call-resolved`
|
|
1231
|
+
is `kind: 'production'` for the opposite reason: it is the call's *output*
|
|
1232
|
+
side, an inbound half at the caller.
|
|
1233
|
+
- **`fromPath: null`**, not `contributingKeys(...)` against the argument's
|
|
1234
|
+
`accessPathOf`. Every write-out in §10.2 carries `fromPath: null` and
|
|
1235
|
+
relies on the resolver's own in-halves for the source; because (a)
|
|
1236
|
+
already forwards `ctx`, those in-halves are emitted with correct
|
|
1237
|
+
contributing keys for free. Computing them a second time here would
|
|
1238
|
+
emit a duplicate, differently-shaped source for the same edge. The PoC
|
|
1239
|
+
asserts the argument's `production/ident from 'a.email'` in-half and the
|
|
1240
|
+
bind out-half share `(scope, nodeId, dataElementId, context)` — i.e. the
|
|
1241
|
+
edge joins under §2.2's existing rule with no special case.
|
|
1242
|
+
- **`toPath` is the exact path written**, `u` or `u.email`, never the
|
|
1243
|
+
coarse `u` when a sub-path was written — the same granularity rule §10.2
|
|
1244
|
+
flags as "the most likely C2 mistake".
|
|
1245
|
+
- **`peerScope`/`peerContext` are mandatory here, not decorative.**
|
|
1246
|
+
`toPath: 'u'` is a path in the *callee's* namespace, recorded on a hop
|
|
1247
|
+
stamped with the *caller's* scope. Without `peerScope`, C4 would create
|
|
1248
|
+
the DAG node `(callerScope, 'u', id)` — colliding with any caller-local
|
|
1249
|
+
variable named `u`. That is Decision 5's bug class (an endpoint that is
|
|
1250
|
+
not the location it names) in a new disguise. `peerContext` is what makes
|
|
1251
|
+
the binding land in the *right* context of the callee, i.e. §9.4's own
|
|
1252
|
+
failure one level up, and it is also what makes §9.6 a sharing property
|
|
1253
|
+
rather than a loss.
|
|
1254
|
+
|
|
1255
|
+
**Arguments that are not path-shaped.** A literal argument resolves to no
|
|
1256
|
+
identity, so no entry appears in `entryState` and **no hop is emitted at
|
|
1257
|
+
all** — the same verdict §10.1 gives `literal`. An argument that is itself
|
|
1258
|
+
an unresolved call *does* carry ids: it emits a bind hop with
|
|
1259
|
+
`fromPath: null` and `widenReason: null`, because the widening is already
|
|
1260
|
+
carried on its own `production/call` in-half (`widenReason: 'unresolved-call'`)
|
|
1261
|
+
which joins with this out-half at the same key. Duplicating it would
|
|
1262
|
+
double-grade the edge. Both cases are pinned in the PoC.
|
|
1263
|
+
|
|
1264
|
+
**Disclosed consequence of `fromPath: null` at a MULTI-ARGUMENT call site
|
|
1265
|
+
(2026-08-30, fix round 1).** When two arguments at the same call site carry
|
|
1266
|
+
the **same** `dataElementId`, the 4-part join key does **not** separate them
|
|
1267
|
+
— both in-halves and both bind out-halves share
|
|
1268
|
+
`(scope, nodeId, dataElementId, context)`, so the cross product names the
|
|
1269
|
+
wrong parameter half the time:
|
|
1270
|
+
|
|
1271
|
+
```js
|
|
1272
|
+
function two(p, q) { … }
|
|
1273
|
+
two(m, n); // m.email and n.email both carry data:email
|
|
1274
|
+
// in-halves : m.email, n.email out-halves: p.email, q.email
|
|
1275
|
+
// joined : 4 edges — m.email→q.email and n.email→p.email are phantom
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
This is **§9.1's already-disclosed cross-join, not a new bug class** — the
|
|
1279
|
+
same shape at a call boundary instead of inside an object literal — and it
|
|
1280
|
+
is handled the same way: C4 detects it exactly
|
|
1281
|
+
(`distinctInPaths ≥ 2 && distinctOutPaths ≥ 2` at a group) and marks
|
|
1282
|
+
`ambiguousCorrelation: true`. Reproduced in the PoC, so it is a measured
|
|
1283
|
+
property rather than a hypothetical. Two notes for whoever revisits it:
|
|
1284
|
+
|
|
1285
|
+
- The bind hop is the **cheapest possible instance of §9.1's own `slot`
|
|
1286
|
+
lever**. §9.1 defers `slot` because the in-expression case needs a slot
|
|
1287
|
+
prefix threaded down through `resolveExprIdentities`'s recursion — but
|
|
1288
|
+
here the **parameter index is known for free** at the emission site
|
|
1289
|
+
(`fn.params[i]`), on both halves, with nothing to thread. If §9.1's
|
|
1290
|
+
evidence threshold for adopting `slot` is ever met, this case closes for
|
|
1291
|
+
the price of one field.
|
|
1292
|
+
- Do **not** try to close it by putting the argument's path in `fromPath`
|
|
1293
|
+
instead: that reintroduces the double-emission this section rejects, and
|
|
1294
|
+
it still would not correlate the halves — it would only make the phantom
|
|
1295
|
+
edges harder to detect.
|
|
1296
|
+
|
|
1297
|
+
**(c) The return direction.** `case 'call'`'s existing
|
|
1298
|
+
`production/call-resolved` hop (engine.js:512-520) gains
|
|
1299
|
+
`peerScope`/`peerContext`, so C4 can connect it to the callee's own
|
|
1300
|
+
`write-out/return` hops (`kind === 'write-out' && subKind === 'return' &&
|
|
1301
|
+
toPath === null`, scoped by `scope` **and now `context`**). The information
|
|
1302
|
+
comes from `createCallSummaryResolver` returning a **fresh wrapper**:
|
|
1303
|
+
|
|
1304
|
+
```js
|
|
1305
|
+
return summary ? { ...summary, resolvedQid: qid, resolvedContext: calleeContext } : summary;
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
A fresh object every call — the cached summary is never mutated, so
|
|
1309
|
+
`fieldSummaryEq` and the B5 refinement loop are untouched. `engine.js`
|
|
1310
|
+
reads only `returnFlat`/`returnByPath`, so a resolver that does *not*
|
|
1311
|
+
supply these (every existing hand-built test stub) must yield
|
|
1312
|
+
`peerScope: null`, not `undefined` — read them as `summary.resolvedQid ?? null`.
|
|
1313
|
+
The PoC proves both halves: the identity is available at the site, and the
|
|
1314
|
+
augmented return leaves the analysis result identical to the shipped
|
|
1315
|
+
resolver's.
|
|
1316
|
+
|
|
1317
|
+
### 13.3 The `context` field and the join-key extension
|
|
1318
|
+
|
|
1319
|
+
**Decided:** `context = hashState(entryState)` — the exact primitive
|
|
1320
|
+
`FieldIdentitySummaryCache` already keys on (`summaries.js`'s `_key`), so
|
|
1321
|
+
two hops share a `context` iff the cache would consider them the same
|
|
1322
|
+
context. Reusing it rather than inventing a second notion is what keeps
|
|
1323
|
+
"which body does this call site's `peerContext` point at" answerable by
|
|
1324
|
+
string equality.
|
|
1325
|
+
|
|
1326
|
+
**Stamped in exactly one place:** `analyzeFunctionFieldIdentity`'s existing
|
|
1327
|
+
per-node `stepCtx` wrapper (engine.js:864-866), computed once per analysis
|
|
1328
|
+
alongside `scope`:
|
|
1329
|
+
|
|
1330
|
+
```js
|
|
1331
|
+
const scope = fn.qid ?? null;
|
|
1332
|
+
const context = ctx?.recordHop ? hashState(entryState) : null; // only when recording
|
|
1333
|
+
...
|
|
1334
|
+
const stepCtx = ctx?.recordHop
|
|
1335
|
+
? { ...ctx, recordHop: (h) => ctx.recordHop({
|
|
1336
|
+
scope, nodeId: nid, line: node.line ?? null,
|
|
1337
|
+
context, peerScope: null, peerContext: null,
|
|
1338
|
+
...h,
|
|
1339
|
+
}) }
|
|
1340
|
+
: ctx;
|
|
1341
|
+
```
|
|
1342
|
+
|
|
1343
|
+
`hashState` is added to `engine.js`'s existing `./field-identity.js` import
|
|
1344
|
+
— no new dependency, no isolation-rule concern. Cost is one O(|state|) hash
|
|
1345
|
+
per analysis, and only when a recorder is attached.
|
|
1346
|
+
|
|
1347
|
+
Two properties this buys, both already relied on by §7.2 and now
|
|
1348
|
+
load-bearing for C3:
|
|
1349
|
+
|
|
1350
|
+
- **Zero change at the 15 existing `recordHop` call sites.** The three new
|
|
1351
|
+
fields are stamped by the wrapper, before `...h`, so a site that supplies
|
|
1352
|
+
none gets the correct nulls and a site that supplies `peerScope`/
|
|
1353
|
+
`peerContext` overrides them — §7.2's spread-order rule, used for the
|
|
1354
|
+
purpose it was written for.
|
|
1355
|
+
- **A nested analysis's stamps win.** When a callee's hops flow out through
|
|
1356
|
+
the caller's already-stamped `recordHop` (§13.4's wiring), the callee's
|
|
1357
|
+
own wrapper has already put `scope`/`nodeId`/`line`/`context` into `h`,
|
|
1358
|
+
so the caller's outer stamp cannot overwrite them. Proven in the PoC: a
|
|
1359
|
+
hop recorded two resolved hops deep carries `inner`'s qid and `inner`'s
|
|
1360
|
+
own context, not `outer`'s.
|
|
1361
|
+
|
|
1362
|
+
**The join key becomes `(scope, nodeId, dataElementId, context)`**,
|
|
1363
|
+
superseding §2.2's three-part key everywhere in this document. §9.4's
|
|
1364
|
+
worked example, reconstructed by running the real cache over
|
|
1365
|
+
`function g(x) { const y = x; return y; }` under two contexts:
|
|
1366
|
+
|
|
1367
|
+
| join key | joinable pairs | phantoms |
|
|
1368
|
+
|---|---|---|
|
|
1369
|
+
| `(scope, nodeId, dataElementId)` | `x→y`, `x→y.email`, `x.email→y`, `x.email→y.email` | 2 |
|
|
1370
|
+
| `(scope, nodeId, dataElementId, context)` | `x→y`, `x.email→y.email` | 0 |
|
|
1371
|
+
|
|
1372
|
+
Exactly the failure §9.4 predicted, and exactly the fix it proposed.
|
|
1373
|
+
|
|
1374
|
+
**Existing tests broken by this: none** — see §13.0 for the evidence.
|
|
1375
|
+
|
|
1376
|
+
### 13.4 The cache-hit finding, and why it does NOT need the big fix
|
|
1377
|
+
|
|
1378
|
+
The question was whether `FieldIdentitySummaryCache.compute`'s memoization
|
|
1379
|
+
silently suppresses a callee's own internal hops on a cache HIT, and
|
|
1380
|
+
whether that forces the cache to store and replay a per-summary hop list.
|
|
1381
|
+
|
|
1382
|
+
**Measured** (two call sites to the same callee, deliberately seeded so
|
|
1383
|
+
both produce the identical entry state and `hashState` collides):
|
|
1384
|
+
|
|
1385
|
+
- The callee's internal hops appear for **one** entry context, not two —
|
|
1386
|
+
the second call site is a cache hit and `analyzeFn` never runs again.
|
|
1387
|
+
Transitively true for its own callee too (`inner`, two hops deep).
|
|
1388
|
+
- Both call sites *do* emit their own `call-arg-bind` and
|
|
1389
|
+
`production/call-resolved` hops — those are emitted outside `compute`.
|
|
1390
|
+
- Control run, two *distinct* entry contexts: two distinct sets of
|
|
1391
|
+
callee-internal hops, at both depths. So the suppression is genuinely the
|
|
1392
|
+
cache key doing its job, not a wiring bug.
|
|
1393
|
+
|
|
1394
|
+
**Decided: (a), accept and disclose — recorded as §9.6.** The big fix (the
|
|
1395
|
+
cache storing a hop list per summary and replaying it on every hit) is
|
|
1396
|
+
**rejected**, for a reason the measurement makes concrete rather than for
|
|
1397
|
+
cost: because both call sites' bind hops carry the same `peerContext`, and
|
|
1398
|
+
that `peerContext` equals the `context` stamped on the one recorded body,
|
|
1399
|
+
**nothing is orphaned** — a consumer walking forward from either call site
|
|
1400
|
+
reaches the same, correctly-contexted body. Replaying would emit N
|
|
1401
|
+
byte-identical copies of a body that C4 deduplicates by content anyway
|
|
1402
|
+
(Decision 8's mechanism), i.e. cost with no information gained, and it
|
|
1403
|
+
would make record volume O(call sites × callee size) — the opposite of
|
|
1404
|
+
FR-303.
|
|
1405
|
+
|
|
1406
|
+
**Sizing, since the plan asked for it honestly:** were it ever needed, the
|
|
1407
|
+
replay fix is roughly *(i)* a `Map<cacheKey, hopRecord[]>` alongside
|
|
1408
|
+
`_cache`, *(ii)* wrapping `analyzeFn`'s recorder to tee into that list,
|
|
1409
|
+
*(iii)* replaying on the hit path in `compute` — but *(iv)* the replayed
|
|
1410
|
+
records would need the *hitting* call site's identity re-stamped onto them
|
|
1411
|
+
to be worth anything, which is a per-record rewrite, and *(v)* it interacts
|
|
1412
|
+
with B5's refinement loop, which re-invokes `analyzeFn` and would need the
|
|
1413
|
+
list reset per round. Non-trivial, and unnecessary. Do not do it without
|
|
1414
|
+
evidence from C4/C5 that shared bodies are actually a reconstruction
|
|
1415
|
+
problem.
|
|
1416
|
+
|
|
1417
|
+
### 13.5 Call-site identity: no `hopSite` field — §7.4's recommendation is superseded
|
|
1418
|
+
|
|
1419
|
+
§7.4 proposed `ctx.hopSite = {scope, nodeId, line}` as plain values on the
|
|
1420
|
+
derived ctx, so `summaries.js` could stamp a cross-function hop with the
|
|
1421
|
+
call site it crossed at.
|
|
1422
|
+
|
|
1423
|
+
**Decided: not needed, and not added.** §13.1 threads the entire stamped
|
|
1424
|
+
`stepCtx` into `resolveCallSummary`, and that ctx's `recordHop` *already*
|
|
1425
|
+
applies `{scope, nodeId, line, context}` to everything emitted through it.
|
|
1426
|
+
The `call-arg-bind` hop therefore carries the call site's full identity
|
|
1427
|
+
with no new field, no new plumbing, and — decisively — **the same values
|
|
1428
|
+
the argument's own in-halves carry**, which is the property that makes the
|
|
1429
|
+
edge join at all. A separate `hopSite` field would be a second, parallel
|
|
1430
|
+
copy of information the stamping mechanism already delivers, i.e. exactly
|
|
1431
|
+
the redundant-fields-drift failure mode §3 rejected `widened` for.
|
|
1432
|
+
|
|
1433
|
+
C4/C5 get call-site identity as: the caller-side `(scope, nodeId, line,
|
|
1434
|
+
context)` on a `call-arg-bind` / `call-resolved` hop, plus
|
|
1435
|
+
`(peerScope, peerContext)` for the function on the other side. That is
|
|
1436
|
+
strictly more than `hopSite` would have carried.
|
|
1437
|
+
|
|
1438
|
+
### 13.6 B5/B6 degradation marking: the mechanism is decided here, the emission ships with §13.7
|
|
1439
|
+
|
|
1440
|
+
Three things were measured before deciding.
|
|
1441
|
+
|
|
1442
|
+
**Finding 1 — a B6 cap degradation is, today, completely silent.** With
|
|
1443
|
+
`maxContextsPerFn: 1` and two call sites requesting genuinely different
|
|
1444
|
+
contexts of the same callee, the second resolution degrades to the
|
|
1445
|
+
empty-entry fallback. Its `returnFlat` is empty, so `case 'call'`'s
|
|
1446
|
+
`for (const id of flat)` loop emits **nothing** — there is not even a hop
|
|
1447
|
+
present to carry a marker. The call site's `call-arg-bind` hop points at a
|
|
1448
|
+
`peerContext` that has no body anywhere in the record stream, and nothing
|
|
1449
|
+
says why. That is §18.4's constraint violated in the interprocedural
|
|
1450
|
+
dimension: "context budget exhausted" is indistinguishable from "no flow
|
|
1451
|
+
crosses this call."
|
|
1452
|
+
|
|
1453
|
+
**Finding 2 — the fallback is the *same object* as the empty-entry
|
|
1454
|
+
summary.** `compute`'s cap branch returns
|
|
1455
|
+
`this._cache.get(this._key(qid, emptyState())) ?? emptyFieldSummary()`;
|
|
1456
|
+
object identity, not a copy (asserted in the PoC). Setting a flag on it in
|
|
1457
|
+
place would retroactively mark the **precise** empty-entry summary as
|
|
1458
|
+
degraded for every later reader — a real bug, avoided only by knowing
|
|
1459
|
+
about it in advance.
|
|
1460
|
+
|
|
1461
|
+
**Finding 3 — B5's bottom stub needs no marking.** The stub is
|
|
1462
|
+
`{...emptyFieldSummary(), _recursive: true}`; empty `returnFlat`, so no
|
|
1463
|
+
`call-resolved` hop is emitted on that round either. And B5's refinement
|
|
1464
|
+
re-invokes `analyzeFn`, re-emitting the callee's hops with the *better*
|
|
1465
|
+
summary; per Decision 8's monotonicity argument the stub round's output is
|
|
1466
|
+
a subset of the refined round's, so the duplicate-tolerant record stream
|
|
1467
|
+
absorbs it. Nothing to mark, nothing to strip.
|
|
1468
|
+
|
|
1469
|
+
**Decided — B6 marking is IN SCOPE for C3, B5 marking is not (there is
|
|
1470
|
+
nothing to mark).** Not deferred to C4/C6: C4 grades edges that exist, and
|
|
1471
|
+
this is about an edge that does *not* exist. Only the site that performed
|
|
1472
|
+
the degradation knows it happened; by C4 the information is gone. The shape:
|
|
1473
|
+
|
|
1474
|
+
1. `FieldIdentitySummaryCache.compute`'s cap-degradation branch returns a
|
|
1475
|
+
**shallow copy** — `const degraded = { ...fallback, degradedReason: 'context-cap' };`
|
|
1476
|
+
— and caches the copy. Never mutates `fallback` (Finding 2).
|
|
1477
|
+
`degradedReason` is a **permanent, documented, externally-visible
|
|
1478
|
+
field**, deliberately unlike `_recursive`: `_recursive` is stripped
|
|
1479
|
+
because it is a transient recursion-in-progress marker, whereas a
|
|
1480
|
+
degraded summary stays degraded for the life of the cache entry. A
|
|
1481
|
+
string, not a boolean, so a later increment can add reasons without a
|
|
1482
|
+
shape change. `fieldSummaryEq` deliberately does **not** compare it —
|
|
1483
|
+
it is diagnostic, exactly like `widenings`.
|
|
1484
|
+
2. `createCallSummaryResolver`, when `summary.degradedReason` is set and
|
|
1485
|
+
`ctx?.recordHop` is present, emits **one loss hop per id that entered
|
|
1486
|
+
the callee** (i.e. per `(path, id)` of the entry state it just built):
|
|
1487
|
+
|
|
1488
|
+
```js
|
|
1489
|
+
{
|
|
1490
|
+
kind: 'production', subKind: 'call-resolved',
|
|
1491
|
+
fromPath: null, toPath: null, dataElementId: <id>,
|
|
1492
|
+
syntacticPath: null, widenReason: null,
|
|
1493
|
+
lossReason: 'context-cap-degraded',
|
|
1494
|
+
peerScope: <callee qid>, peerContext: <callee context>,
|
|
1495
|
+
}
|
|
1496
|
+
```
|
|
1497
|
+
|
|
1498
|
+
Emitted at the resolver, not in `engine.js`, precisely because
|
|
1499
|
+
`engine.js`'s loop over an empty `returnFlat` cannot fire. The ids come
|
|
1500
|
+
from the *argument* side because those are exactly the identities whose
|
|
1501
|
+
downstream fate is now unrepresented — Decision 4's "always a real,
|
|
1502
|
+
non-null `dataElementId`" is satisfied without inventing one.
|
|
1503
|
+
`lossReason`, not `widenReason`, because §3 defines `lossReason` as "why
|
|
1504
|
+
this hop is a dead end", which is precisely what it is.
|
|
1505
|
+
|
|
1506
|
+
**How C4 must read this hop, pinned so §2.2 cannot silently swallow it
|
|
1507
|
+
(2026-08-30, fix round 1).** The loss hop has `fromPath: null` *and*
|
|
1508
|
+
`toPath: null`, so under §2.2's annotation rule it is an **annotation on
|
|
1509
|
+
whatever edges the non-null in-halves form at the same join key**, not an
|
|
1510
|
+
edge-forming half of its own — and once (a) records the argument's own
|
|
1511
|
+
in-half, that is the common case at a degraded call site with a
|
|
1512
|
+
path-shaped argument. **That is the intended reading, deliberately**: the
|
|
1513
|
+
marker belongs *on* the real `argument → parameter` edge, saying "this
|
|
1514
|
+
data was bound into a callee whose summary the engine honestly degraded;
|
|
1515
|
+
its downstream is unrepresented." It is the same relationship
|
|
1516
|
+
§10.2's `unsupported-target` write-out already has with the in-halves it
|
|
1517
|
+
joins. When there is no non-null in-half (a literal or otherwise
|
|
1518
|
+
path-less argument), §2.2's rule makes it edge-forming instead, which is
|
|
1519
|
+
also correct — the "value with no prior aliasing source" case. **C4 must
|
|
1520
|
+
surface it under both readings; what it must never do is drop it**,
|
|
1521
|
+
because dropping it restores exactly the §18.4 silence Finding 1 measured.
|
|
1522
|
+
|
|
1523
|
+
**Prototyped, not just designed (2026-08-30, fix round 1).** Round 0 left
|
|
1524
|
+
this the one mechanism in §13 that was specified without being run, which
|
|
1525
|
+
contradicted this section's own opening claim. It is now executed in the
|
|
1526
|
+
PoC via a `MarkingSummaryCache` subclass that overrides the cap branch
|
|
1527
|
+
exactly as item 1 above specifies. Confirmed by running: the degraded call
|
|
1528
|
+
site's hop carries `lossReason: 'context-cap-degraded'` and a
|
|
1529
|
+
`peerContext` that provably has no recorded body; the precisely-resolved
|
|
1530
|
+
call site's `call-resolved` hop carries no marker; the shallow copy leaves
|
|
1531
|
+
the precise empty-entry summary unmarked (Finding 2, in both directions);
|
|
1532
|
+
and a recorder-free run is unaffected by marking, which is what makes
|
|
1533
|
+
`degradedReason` diagnostic rather than a fact.
|
|
1534
|
+
|
|
1535
|
+
Wiring the emission into shipped `summaries.js` is the follow-up task's job
|
|
1536
|
+
(§13.7), like every other site here.
|
|
1537
|
+
|
|
1538
|
+
### 13.7 What the follow-up implementation task must do
|
|
1539
|
+
|
|
1540
|
+
Written the way §10.1/§10.2 were written for C2, so the next brief needs no
|
|
1541
|
+
re-derivation. Files, in dependency order.
|
|
1542
|
+
|
|
1543
|
+
**`scanner/src/lineage/engine.js`**
|
|
1544
|
+
|
|
1545
|
+
| # | Site | Change |
|
|
1546
|
+
|---|---|---|
|
|
1547
|
+
| 1 | imports (line 2) | add `hashState` to the existing `./field-identity.js` import |
|
|
1548
|
+
| 2 | `analyzeFunctionFieldIdentity`, ~line 848 | `const context = ctx?.recordHop ? hashState(entryState) : null;` beside the existing `scope` |
|
|
1549
|
+
| 3 | `stepCtx` wrapper, ~line 864-866 | stamp `context, peerScope: null, peerContext: null` before `...h` (§13.3's snippet, verbatim) |
|
|
1550
|
+
| 4 | `case 'call'`, line 505 | pass `ctx` as the 4th argument to `ctx.resolveCallSummary`. **This is the ONLY place the full ctx crosses into `summaries.js`** — items 6/7 below must not let `resolveCallSummary` reach `resolveExprIdentities` from there (§13.2a's boxed warning). |
|
|
1551
|
+
| 5 | `case 'call'` resolved branch, ~line 512-520 | add `peerScope: summary.resolvedQid ?? null, peerContext: summary.resolvedContext ?? null` to the `call-resolved` hop (`?? null`, never bare — a 3-arg test stub supplies neither) |
|
|
1552
|
+
|
|
1553
|
+
**`scanner/src/lineage/summaries.js`**
|
|
1554
|
+
|
|
1555
|
+
| # | Site | Change |
|
|
1556
|
+
|---|---|---|
|
|
1557
|
+
| 6 | `entryStateFromCall`, line 286 | add optional 4th param `ctx`, then derive `const argCtx = ctx?.recordHop ? { recordHop: ctx.recordHop } : undefined;` ONCE before the loop and pass **`argCtx`, never `ctx`**, to `resolveExprIdentities` (line 291). Stripping `resolveCallSummary` is load-bearing, not tidiness — forwarding the full ctx changes the analysis result with no recorder attached, in the unsound direction under a tight cap. See §13.2a. Return shape unchanged. |
|
|
1558
|
+
| 7 | `createCallSummaryResolver`'s closure, line 334 | add optional 4th param `ctx`; pass it to `entryStateFromCall` (which does the stripping in item 6 — keep the derivation inside `entryStateFromCall`, so the hazard cannot reappear via a future second caller) |
|
|
1559
|
+
| 8 | same, after `entryStateFromCall` | compute `const calleeContext = hashState(entryState);` and, when `ctx?.recordHop`, emit one `write-out/call-arg-bind` per `(path, id)` of `entryState` (§13.2b's exact shape) |
|
|
1560
|
+
| 9 | same, inside `cache.compute`'s callback, line 357 | build the callee ctx as `ctx?.recordHop ? { resolveCallSummary, recordHop: ctx.recordHop } : { resolveCallSummary }` — **hole 3**. Do not re-stamp `context` here; the callee's own `analyzeFunctionFieldIdentity` (change #2/#3) does it, and its stamps win by spread order. |
|
|
1561
|
+
| 10 | same, at return | wrap: `{ ...summary, resolvedQid: qid, resolvedContext: calleeContext }` — a fresh object, never a mutation |
|
|
1562
|
+
| 11 | `FieldIdentitySummaryCache.compute`, cap branch, lines 115-123 | return/cache a **shallow copy** carrying `degradedReason: 'context-cap'` (§13.6, Finding 2). Prototyped in the PoC as `MarkingSummaryCache` — but that subclass re-derives `willDegrade` by re-testing the cap (`!seen.has(hash) && seen.size >= this._maxContextsPerFn`) *outside* `compute`, purely because a subclass cannot see which branch `super.compute` took. Do NOT carry that re-derivation inline: mark `fallback` at its one real call site, inside the existing cap-branch `if` (lines 115-123), right where the branch is already decided — `const fallback = { ...base, degradedReason: 'context-cap' };` before `this.set(...)`/`return fallback;`, never a mutation of the shared object in place (the same fallback can be cached under multiple keys, e.g. the function's own empty-entry summary — mutating it in place would leak the marking there too), and never a second cap test. |
|
|
1563
|
+
| 12 | `createCallSummaryResolver` | when `summary.degradedReason` and `ctx?.recordHop`, emit the §13.6 loss hop per entry-state id |
|
|
1564
|
+
| 13 | `fieldSummaryEq` comment | note that `degradedReason` is deliberately not compared (diagnostic, like `widenings`) |
|
|
1565
|
+
|
|
1566
|
+
**`scanner/src/lineage/driver.js`**
|
|
1567
|
+
|
|
1568
|
+
| # | Site | Change |
|
|
1569
|
+
|---|---|---|
|
|
1570
|
+
| 14 | `runFieldIdentityAnalysis`, line 65 | accept `opts.recordHop` and spread it into the per-function ctx **conditionally** (`...(opts.recordHop ? { recordHop: opts.recordHop } : {})`), so a caller that supplies none gets a byte-identical `{ resolveCallSummary }` — Decision 7.2's "true by construction" |
|
|
1571
|
+
|
|
1572
|
+
**Tests**
|
|
1573
|
+
|
|
1574
|
+
| # | Change |
|
|
1575
|
+
|---|---|
|
|
1576
|
+
| 15 | Re-point `engine-provenance-interprocedural-poc.test.js`'s local prototypes at the shipped functions, delete the `this`-binding stand-in and the "hole is real" tests (they will correctly start failing), and fold what remains into `engine-provenance.test.js`. Drop the PoC file and its `package.json` `test:lineage` entry in the same commit. |
|
|
1577
|
+
| 15b | Add the **golden-baseline** regression the PoC now carries: for a fixture whose call ARGUMENT is itself a resolvable call, and again for a two-call-site cap-1 cache, assert the new wiring's recorder-free result equals a **hardcoded pre-C3 golden literal** (`['data:email']` and `['data:other-email']` respectively — the exact values the PoC pins), comparing the full canonicalized `{exitState, returnFacts, mutatedParams, widenings}` shape (matching item 16's own canonicalization), not just `returnFacts`' identities. **Do NOT compare against "the shipped resolver"** — after this task lands, the shipped resolver IS the new wiring, so a live comparison degenerates into `assert.deepEqual(result, result)`, a vacuous, always-passing test. Only a value fixed independently of whichever implementation is live stays meaningful. Item 16's with/without-recorder comparison cannot catch this class either way (the divergence moves both arms identically) — the guard must be a fixed golden, not a relative comparison. |
|
|
1578
|
+
| 16 | Extend the existing **write-only invariant** test (`engine-provenance.test.js`, ~line 245) with at least one multi-function fixture driven through a real `FieldIdentitySummaryCache`, run with and without a recorder. This is the guard that catches the C2-era class of bug where a recorder's presence perturbed cache-cap accounting; C3 adds three new recorder-conditional branches inside `summaries.js`, so it must cover them. |
|
|
1579
|
+
| 17 | Add a `driver.js` test proving `opts.recordHop` reaches every function in a multi-file project AND that omitting it leaves `runFieldIdentityAnalysis`'s `results`/`cache` unchanged. |
|
|
1580
|
+
|
|
1581
|
+
**Deliberately NOT in the follow-up's scope:** instrumenting
|
|
1582
|
+
`applyAtCallSite` (not wired into `engine.js`; see §10.3's correction), the
|
|
1583
|
+
cache hop-replay fix (§13.4), a `hopSite` field (§13.5), and any change to
|
|
1584
|
+
`field-identity.js` (never).
|
|
1585
|
+
|
|
1586
|
+
---
|
|
1587
|
+
|
|
1588
|
+
## 14. `path-store.js`: the compact DAG (Sub-project C, increment 4)
|
|
1589
|
+
|
|
1590
|
+
Added 2026-08-30 by increment C4's design task. Everything in this section
|
|
1591
|
+
is **decided**, not proposed, and every behavioural claim and every number
|
|
1592
|
+
in it was produced by running code in
|
|
1593
|
+
`scanner/test/lineage/path-store-poc.test.js` — a throwaway-named PoC
|
|
1594
|
+
committed alongside this section, which prototypes `path-store.js` and the
|
|
1595
|
+
two new `ids.js` functions LOCALLY (shipped source is unmodified by this
|
|
1596
|
+
design task, exactly as C3's own design task did). §14.10 is the follow-up
|
|
1597
|
+
implementation task's file/line checklist.
|
|
1598
|
+
|
|
1599
|
+
Two questions were open when this increment was scoped, and neither is
|
|
1600
|
+
answerable on paper. Both are now answered by execution:
|
|
1601
|
+
|
|
1602
|
+
- **Q1 — cross-function node addressing.** A `write-out/call-arg-bind`
|
|
1603
|
+
hop's destination is `(peerScope, peerContext, toPath, dataElementId)`,
|
|
1604
|
+
**not** `(scope, context, …)`. §14.3.
|
|
1605
|
+
- **Q2 — does `call-resolved`'s `fromPath: null` ever form a real edge?**
|
|
1606
|
+
**Yes** — it is the caller-side half of the return stitch, and its source
|
|
1607
|
+
is the callee's own function-exit node. But §2.2's annotation rule, read
|
|
1608
|
+
literally, demotes exactly this hop to an annotation and silently deletes
|
|
1609
|
+
the stitch. §14.4 corrects §2.2 in place.
|
|
1610
|
+
|
|
1611
|
+
### 14.1 What `path-store.js` is, and the isolation boundary it introduces
|
|
1612
|
+
|
|
1613
|
+
`path-store.js` is a **pure consumer of a hop-record stream**. It takes
|
|
1614
|
+
hop records — the exact 14-field shape §3 + §13.0 define — and builds a
|
|
1615
|
+
deduplicated DAG. It does not run analysis, and:
|
|
1616
|
+
|
|
1617
|
+
> **`path-store.js` must NEVER import `engine.js`, `summaries.js`, or
|
|
1618
|
+
> `driver.js`.** It consumes their OUTPUT, never their internals. This is a
|
|
1619
|
+
> stronger, additional boundary on top of the existing "`src/lineage/` may
|
|
1620
|
+
> import pure utilities from `src/dataflow/`, never that package's
|
|
1621
|
+
> `engine.js`/`summaries.js`" rule.
|
|
1622
|
+
|
|
1623
|
+
This is not tidiness. It is what makes the store testable at all right now:
|
|
1624
|
+
there is still no source registry (Sub-projects D/E), so
|
|
1625
|
+
`runFieldIdentityAnalysis` analyzes every function from `emptyState()` and a
|
|
1626
|
+
real project-wide driver run emits **zero** hops today. A store that could
|
|
1627
|
+
only be exercised through the driver would be untestable by construction. A
|
|
1628
|
+
store fed a hand-built or hand-seeded array is testable immediately, and
|
|
1629
|
+
every fixture below does exactly that.
|
|
1630
|
+
|
|
1631
|
+
It also means `path-store.js`'s only dependency is `ids.js`.
|
|
1632
|
+
|
|
1633
|
+
### 14.2 The node: `(scope, context, kind, path | siteNodeId, dataElementId)`
|
|
1634
|
+
|
|
1635
|
+
§2.1 decided the node is `(scope, accessPath, dataElementId)`. C4 makes two
|
|
1636
|
+
additions, both forced by evidence rather than taste.
|
|
1637
|
+
|
|
1638
|
+
**(a) `context` is part of node identity.** §13.3 already moved the *join
|
|
1639
|
+
key* to four parts. The node must follow, for a reason §13.2 states
|
|
1640
|
+
directly: `peerContext` "is what makes the binding land in the *right*
|
|
1641
|
+
context of the callee." If node identity ignored `context`, `peerContext`
|
|
1642
|
+
would be decorative and §9.4's phantom would return one level up — two entry
|
|
1643
|
+
contexts of one function would share a node, and a backward walk in
|
|
1644
|
+
context B could leave through an edge that only ever existed in context A.
|
|
1645
|
+
Cost, measured: the two-context `function g(x) { const y = x; return y; }`
|
|
1646
|
+
fixture builds 6 nodes with `context` in the identity and would collapse to
|
|
1647
|
+
5 without it. The blow-up is bounded by the B6 per-function context cap
|
|
1648
|
+
(default 16), and correctness wins.
|
|
1649
|
+
|
|
1650
|
+
**(b) Terminal endpoints get their own node `kind`, never a fabricated
|
|
1651
|
+
path.** §10.2 is explicit that a `return` must not be given a pseudo-path
|
|
1652
|
+
like `'@return'`, and that C3/C4 identify a function exit structurally
|
|
1653
|
+
(`kind === 'write-out' && subKind === 'return' && toPath === null`). C4 needs
|
|
1654
|
+
those endpoints to *be* nodes, so it keeps them in a separate namespace
|
|
1655
|
+
rather than inventing a path string that could collide with a real one:
|
|
1656
|
+
|
|
1657
|
+
| node `kind` | created by | keyed on | why |
|
|
1658
|
+
|---|---|---|---|
|
|
1659
|
+
| `path` | any half with a non-null `fromPath`/`toPath` | `(scope, context, path, id)` | the ordinary state location |
|
|
1660
|
+
| `return` | `write-out/return`; also *addressed* by `production/call-resolved` via `(peerScope, peerContext)` | `(scope, context, id)` — **per function-context, not per CFG node** | forced: a `call-resolved` hop names only `(peerScope, peerContext)`, with no node id, so every return site of a context must aggregate into one exit node |
|
|
1661
|
+
| `escape` | `write-out/call-arg` (a bare call statement) | `(scope, context, siteNodeId, id)` | the value leaves the analysis; §10.2 calls this the natural sink-attachment point for Sub-project D. Nothing addresses it from elsewhere, so per-CFG-node precision is free |
|
|
1662
|
+
| `loss` | any `write-out` with `toPath === null` and a non-null `lossReason` (today: `assign`/`unsupported-target`) | `(scope, context, siteNodeId, id)` | §18.4: a dead end that is *recorded as a dead end* is the data-layer form of "never present a truncation as an absence" |
|
|
1663
|
+
| `origin` | §2.2's surviving half — a group whose ONLY in-halves are annotations | `(scope, context, siteNodeId, id)` | "a value with no prior aliasing source" |
|
|
1664
|
+
|
|
1665
|
+
`origin` is exercised only by a hand-built hop pair in this task's PoC — no
|
|
1666
|
+
real-parser fixture in C1-C3's own instrumentation reaches it today, since
|
|
1667
|
+
every construct that carries a `dataElementId` currently inherits it from
|
|
1668
|
+
somewhere already in `state` (a contributing key `production`/`selection`
|
|
1669
|
+
hop always accompanies it). This is a real, honestly-disclosed gap, but not
|
|
1670
|
+
a dead branch: it is the exact shape a Sub-project D source registry will
|
|
1671
|
+
produce the first time it seeds an identity at a source site with no prior
|
|
1672
|
+
state to point to (a null-`fromPath`, null-`peerScope` in-half is precisely
|
|
1673
|
+
"this value originates here, not upstream"). Kept, tested with the hand-built
|
|
1674
|
+
pair, and left for D's own real-parser coverage rather than removed.
|
|
1675
|
+
|
|
1676
|
+
Expression-internal constructs still create **no** nodes (§2.1). An object
|
|
1677
|
+
literal, a ternary, a template literal survive as `annotations[]` on the
|
|
1678
|
+
edges they helped form — pinned by a test asserting the `production/object`
|
|
1679
|
+
hop is present as an annotation and that no node was created for it.
|
|
1680
|
+
|
|
1681
|
+
### 14.3 Half-edge classification — the exact rules, and the answer to Q1
|
|
1682
|
+
|
|
1683
|
+
Grouped by §13.3's four-part join key `(scope, nodeId, dataElementId,
|
|
1684
|
+
context)`. Within a group:
|
|
1685
|
+
|
|
1686
|
+
**In-halves** (`kind` is `production` or `selection`):
|
|
1687
|
+
|
|
1688
|
+
1. `fromPath !== null` → **sourced**, at `(scope, context, path, id)`.
|
|
1689
|
+
2. `fromPath === null && peerScope !== null && lossReason === null` →
|
|
1690
|
+
**peer-sourced**, at `(peerScope, peerContext, ⟨return⟩, id)`. See §14.4.
|
|
1691
|
+
3. otherwise → **annotation**.
|
|
1692
|
+
|
|
1693
|
+
**Out-halves** (`kind === 'write-out'`), in this order:
|
|
1694
|
+
|
|
1695
|
+
1. `toPath !== null && peerScope !== null` → **peer-targeted**, at
|
|
1696
|
+
`(peerScope, peerContext, toPath, id)`. **This is Q1's answer.**
|
|
1697
|
+
2. `toPath !== null` → **targeted**, at `(scope, context, toPath, id)`.
|
|
1698
|
+
3. `subKind === 'return'` → the `return` terminal.
|
|
1699
|
+
4. `subKind === 'call-arg'` → the `escape` terminal.
|
|
1700
|
+
5. `lossReason !== null` → the `loss` terminal.
|
|
1701
|
+
6. otherwise → **unclassified**, recorded in `diagnostics().unclassified`
|
|
1702
|
+
and never silently dropped. Empty for every fixture in the PoC — which
|
|
1703
|
+
is the closed-set proof that rules 1-5 cover today's whole out-half
|
|
1704
|
+
vocabulary (`assign`, `assign-weak`, `call-arg`, `call-arg-bind`,
|
|
1705
|
+
`return`, and `assign`-with-`unsupported-target`).
|
|
1706
|
+
|
|
1707
|
+
**Q1, proven rather than argued.** For
|
|
1708
|
+
`function helper(u) { return u.email; } function caller(a) { const out = helper(a); return out; }`
|
|
1709
|
+
seeded `a.email → data:email`, the bind hop is stamped `scope: caller`,
|
|
1710
|
+
`peerScope: helper`, `toPath: 'u.email'`. The PoC asserts that the node id
|
|
1711
|
+
computed from `(peerScope, peerContext, 'u.email', id)` is **byte-identical**
|
|
1712
|
+
to the node id computed from the callee's OWN
|
|
1713
|
+
`selection/member from 'u.email'` hop (`scope: helper`, its own `context`) —
|
|
1714
|
+
i.e. peer addressing lands exactly on a node the callee independently
|
|
1715
|
+
created. The naive alternative computes a different id, and
|
|
1716
|
+
`store.getNode(thatId)` is `null`: it is an orphan no hop anywhere can reach.
|
|
1717
|
+
|
|
1718
|
+
And the hazard is not hypothetical. With a caller that also has a local
|
|
1719
|
+
variable named `u`, the PoC shows own-scope addressing of the callee's
|
|
1720
|
+
parameter produces the **same id** as the caller's own local `u` — Decision
|
|
1721
|
+
5's bug class ("an endpoint that is not the location it names") in a new
|
|
1722
|
+
disguise, exactly as §13.2 predicted. Peer addressing keeps them apart, and
|
|
1723
|
+
no binding edge lands on the caller-local `u`.
|
|
1724
|
+
|
|
1725
|
+
**A pair whose BOTH endpoints are peer-addressed is excluded.** At any
|
|
1726
|
+
resolved call, the `call-resolved` in-half and the `call-arg-bind` out-half
|
|
1727
|
+
share a join key (asserted in the PoC), so a naive full cross product pairs
|
|
1728
|
+
them and manufactures a `callee ⟨return⟩ → callee parameter` edge that no
|
|
1729
|
+
program ever executed — a fabricated cycle, created by C4 rather than by the
|
|
1730
|
+
code. Such a pair always describes a transition *entirely inside* the
|
|
1731
|
+
callee, which the callee's own hops already record, so excluding it can
|
|
1732
|
+
never drop a real caller-side fact. Nothing else is pruned; see §14.7.
|
|
1733
|
+
|
|
1734
|
+
### 14.4 Q2 — the return stitch, and a correction to §2.2
|
|
1735
|
+
|
|
1736
|
+
Running the two-function fixture above with a recorder produces, at the
|
|
1737
|
+
caller's `const out = helper(a)` CFG node, **four** hops sharing one join
|
|
1738
|
+
key:
|
|
1739
|
+
|
|
1740
|
+
```
|
|
1741
|
+
production/ident fromPath 'a.email' (the argument's own in-half)
|
|
1742
|
+
write-out/call-arg-bind toPath 'u.email' peer=(helper, Ch)
|
|
1743
|
+
production/call-resolved fromPath null peer=(helper, Ch)
|
|
1744
|
+
write-out/assign toPath 'out'
|
|
1745
|
+
```
|
|
1746
|
+
|
|
1747
|
+
and, inside `helper` under context `Ch`, `selection/member from 'u.email'`
|
|
1748
|
+
plus `write-out/return toPath null`.
|
|
1749
|
+
|
|
1750
|
+
**The stitch exists, and it goes through the callee.** The
|
|
1751
|
+
`production/call-resolved` hop is `kind: 'production'`, so per §13.0 its
|
|
1752
|
+
peer is its *source* — and that source is the callee's function-exit node
|
|
1753
|
+
`(helper, Ch, ⟨return⟩, id)`, which the callee's own `write-out/return` hop
|
|
1754
|
+
independently creates. `peerContext` is byte-equal to the `context` the
|
|
1755
|
+
callee's body was recorded under (asserted). The PoC walks the whole chain:
|
|
1756
|
+
|
|
1757
|
+
```
|
|
1758
|
+
(caller, Ca, a.email) → (helper, Ch, u.email) → (helper, Ch, ⟨return⟩)
|
|
1759
|
+
→ (caller, Ca, out) → (caller, Ca, ⟨return⟩)
|
|
1760
|
+
```
|
|
1761
|
+
|
|
1762
|
+
Four edges, two of them cross-scope, and no step is asserted rather than
|
|
1763
|
+
built.
|
|
1764
|
+
|
|
1765
|
+
> **Correction to §2.2, per this document's own fix-rather-than-diverge
|
|
1766
|
+
> policy.** §2.2 says a `null`-`fromPath` in-half "forms a real edge only
|
|
1767
|
+
> when NO non-null in-half exists at that key — which is precisely the
|
|
1768
|
+
> 'value with no prior aliasing source' case (a literal, or — the one case
|
|
1769
|
+
> that matters for interprocedural stitching — a resolved call's return
|
|
1770
|
+
> value, which is C3's join point…)". That parenthesis names the right case
|
|
1771
|
+
> and the rule then **excludes** it: in the real C3 stream the argument
|
|
1772
|
+
> expression's own `production/ident from 'a.email'` in-half sits at the
|
|
1773
|
+
> *same* join key as the `call-resolved` hop (measured — the PoC asserts
|
|
1774
|
+
> it), so the literal rule demotes `call-resolved` to an annotation. A
|
|
1775
|
+
> `PathStore` built that way leaves the callee's exit node with **zero**
|
|
1776
|
+
> outgoing edges: reconstruction from `out` reports the argument as its
|
|
1777
|
+
> immediate predecessor and the callee body is unreachable. The PoC builds
|
|
1778
|
+
> exactly that store and asserts the dead end, so the defect cannot be
|
|
1779
|
+
> re-argued away.
|
|
1780
|
+
>
|
|
1781
|
+
> **The corrected discriminator is `peerScope`, not `fromPath`.** A null
|
|
1782
|
+
> `fromPath` with a non-null `peerScope` is not source-less; it is
|
|
1783
|
+
> **peer-addressed**. §2.2's annotation rule survives unchanged for the
|
|
1784
|
+
> genuinely source-less case (`peerScope === null`), which is what the
|
|
1785
|
+
> `origin` node kind covers.
|
|
1786
|
+
|
|
1787
|
+
**The one exception, and it is `lossReason`.** §13.6's context-cap
|
|
1788
|
+
degradation hop is also `production/call-resolved` with `fromPath: null`
|
|
1789
|
+
and a non-null `peerScope` — but it names a callee whose body was **never
|
|
1790
|
+
analyzed**, so `(peerScope, peerContext, ⟨return⟩, id)` does not exist in
|
|
1791
|
+
the stream. Treating it as peer-sourced would fabricate an origin node with
|
|
1792
|
+
no predecessors and report a path that begins in the middle of nothing —
|
|
1793
|
+
Decision 5's bug class again. `lossReason === null` is therefore part of
|
|
1794
|
+
rule 2, and a degraded hop falls through to *annotation*, which is exactly
|
|
1795
|
+
the reading §13.6 asked for ("the marker belongs *on* the real
|
|
1796
|
+
`argument → parameter` edge"). Verified against the shipped resolver under
|
|
1797
|
+
`new FieldIdentitySummaryCache(1)`: no exit node is fabricated for the
|
|
1798
|
+
degraded callee, and the `lossReason: 'context-cap-degraded'` marker is
|
|
1799
|
+
present on the real `call-arg-bind` edge. It is never dropped — which is
|
|
1800
|
+
the half §13.6 says matters most.
|
|
1801
|
+
|
|
1802
|
+
> **Disclosed precondition, found by task review, not closed this
|
|
1803
|
+
> increment.** The `lossReason === null` guard correctly distinguishes "the
|
|
1804
|
+
> callee's body was analyzed" from "it was degraded away" WITHIN one fully
|
|
1805
|
+
> recorded analysis run. It does not, on its own, guarantee the callee's
|
|
1806
|
+
> exit hops are actually PRESENT in the stream `path-store.js` was fed —
|
|
1807
|
+
> that additionally requires the stream to be complete for
|
|
1808
|
+
> `(peerScope, peerContext)`. A reachable counter-shape: analyze `callerA`
|
|
1809
|
+
> against a shared `FieldIdentitySummaryCache` with NO recorder attached
|
|
1810
|
+
> (warming the cache with `helper`'s summary), then analyze `callerB`
|
|
1811
|
+
> against that SAME cache with a recorder attached. `helper` is now a cache
|
|
1812
|
+
> HIT for `callerB` — `resolveCallSummary`'s `cache.compute()` never
|
|
1813
|
+
> re-invokes `analyzeFn`, so `helper`'s own body hops never fire a second
|
|
1814
|
+
> time — yet the resolved summary still carries `lossReason: null` (it was
|
|
1815
|
+
> genuinely, precisely resolved; it just wasn't resolved *this run*). Fed
|
|
1816
|
+
> into `path-store.js`, `callerB`'s `call-resolved` hop is peer-sourced at
|
|
1817
|
+
> `(helper, Ch, ⟨return⟩)`, a node the store never otherwise creates —
|
|
1818
|
+
> exactly the fabricated-origin failure mode the `lossReason` guard exists
|
|
1819
|
+
> to prevent, reached by a different door. Not reachable within a SINGLE
|
|
1820
|
+
> fully-recorded run (checked across 6 fixtures: 2-fn, 3-fn/2-site, mutual
|
|
1821
|
+
> recursion, self recursion, mutated-param return, §9.6's own same-context
|
|
1822
|
+
> cache hit — zero orphaned exit nodes in any of them), but directly
|
|
1823
|
+
> reachable through `driver.js`'s own returned-and-reused `cache`, which
|
|
1824
|
+
> `driver.test.js` already exercises in this exact shape. **Left for the
|
|
1825
|
+
> follow-up implementation task (§14.10 item 10):** `path-store.js` must
|
|
1826
|
+
> treat this as a build-time DIAGNOSTIC, not a silent fabrication — a
|
|
1827
|
+
> `return` node with zero in-edges that nonetheless sources a real
|
|
1828
|
+
> cross-scope edge is detectable with the same `inIndex`/`outIndex` the
|
|
1829
|
+
> store already builds, and must be recorded via `diagnostics()`, never
|
|
1830
|
+
> thrown and never dropped, per this document's own established §9 culture
|
|
1831
|
+
> and §14.9's "recorded, never silent" framing.
|
|
1832
|
+
|
|
1833
|
+
### 14.5 The two new `ids.js` functions
|
|
1834
|
+
|
|
1835
|
+
`DESIGN_PATH_PROVENANCE.md` §12 and the C-scoping doc both anticipated a
|
|
1836
|
+
single `pathId`. Two functions are needed, and neither is called `pathId`:
|
|
1837
|
+
|
|
1838
|
+
```js
|
|
1839
|
+
provenanceNodeId({ kind, scope, context, path, siteNodeId, dataElementId },
|
|
1840
|
+
discriminatorParts = []) // -> `pnode:<kind>:<12 hex>`
|
|
1841
|
+
provenanceEdgeId({ fromNodeId, toNodeId, dataElementId,
|
|
1842
|
+
scope, context, siteNodeId,
|
|
1843
|
+
inKind, inSubKind, outKind, outSubKind,
|
|
1844
|
+
widenReasons = [], lossReasons = [] },
|
|
1845
|
+
discriminatorParts = []) // -> `pedge:<12 hex>`
|
|
1846
|
+
```
|
|
1847
|
+
|
|
1848
|
+
Both use `ids.js`'s existing `_hash`/`_canon` helpers unchanged: sha256 over
|
|
1849
|
+
a canonicalized, pipe-joined material string, truncated to `ID_HEX_LEN`,
|
|
1850
|
+
prefixed by the entity kind. Never a counter.
|
|
1851
|
+
|
|
1852
|
+
- **Distinct `pnode:`/`pedge:` prefixes, not `node:`/`edge:`.** A provenance
|
|
1853
|
+
node is not a `DataFlowGraph v1` node; `validate.js` regex-checks the
|
|
1854
|
+
`node:`/`edge:` prefixes for graph entities, and making the two
|
|
1855
|
+
indistinguishable would be a latent contract bug. Sub-project E maps
|
|
1856
|
+
between the two namespaces; it must not confuse them.
|
|
1857
|
+
- **`pathId` is deliberately left unused.** The thing C5 reconstructs *is* a
|
|
1858
|
+
path, and it will plausibly want that name for its own entity. Calling an
|
|
1859
|
+
edge a path now would cost C5 the obvious name.
|
|
1860
|
+
- **The edge discriminator carries the SITE** (`scope`, `context`,
|
|
1861
|
+
`siteNodeId`) as well as both endpoint ids. `fromNodeId`/`toNodeId` already
|
|
1862
|
+
embed each side's own scope/context, but not the CFG node the pair was
|
|
1863
|
+
observed at — and two structurally identical hops at two different program
|
|
1864
|
+
points are two materially different edges (FR-305), each needing its own
|
|
1865
|
+
`line` for display and for §9.2's hop-ordering lever. Omit `siteNodeId` and
|
|
1866
|
+
they silently collide into one edge carrying one arbitrary line.
|
|
1867
|
+
- **It also carries both halves' `kind`/`subKind` and their reason strings.**
|
|
1868
|
+
This is the `flagship-fixture.mjs` lesson applied deliberately: that
|
|
1869
|
+
module's edge ids once collided because `dataElementIds` was left out of
|
|
1870
|
+
the discriminator (see this package's own CLAUDE.md row). Over-specifying a
|
|
1871
|
+
content hash costs nothing; under-specifying it is a silent merge.
|
|
1872
|
+
- **Object arguments, not `ids.js`'s usual positional form.** A deliberate,
|
|
1873
|
+
narrow divergence (`graphId` is the in-file precedent). `provenanceEdgeId`'s
|
|
1874
|
+
discriminator is twelve fields wide, `provenanceNodeId`'s is six, and a
|
|
1875
|
+
positional `discriminatorParts` array is exactly the shape from which a
|
|
1876
|
+
field gets omitted.
|
|
1877
|
+
|
|
1878
|
+
> **Corrected by the final whole-branch review (finding 2).** The
|
|
1879
|
+
> sentence above originally said "`path-store.js` calls each of these
|
|
1880
|
+
> from ONE place" as the justification. `provenanceEdgeId` genuinely has
|
|
1881
|
+
> one call site; `provenanceNodeId` has FIVE (`intern`, `sourcesFor`,
|
|
1882
|
+
> `targetsFor`, the `orphanedPeerSources` check, and `nodeIdFor`). The
|
|
1883
|
+
> object-argument choice is still correct — arguably more so at five call
|
|
1884
|
+
> sites than at one, since a positional array is exactly as easy to get
|
|
1885
|
+
> wrong the second, third, fourth, and fifth time as the first — but the
|
|
1886
|
+
> stated reason was wrong. `scanner/src/lineage/CLAUDE.md`'s own
|
|
1887
|
+
> `path-store.js` row repeated the same error and has been corrected too.
|
|
1888
|
+
- **Not in the discriminator:** `syntacticPath` and `line` (display
|
|
1889
|
+
material — pinned by a test where two hop records differing only in
|
|
1890
|
+
`syntacticPath` collapse to one edge), edge `annotations[]`, and
|
|
1891
|
+
`ambiguousCorrelation` (both are functions of the group and of the
|
|
1892
|
+
endpoints already in the id, so they cannot distinguish two edges).
|
|
1893
|
+
|
|
1894
|
+
The PoC pins idempotence (two independent analysis runs of the same fixture
|
|
1895
|
+
produce identical edge ids; re-delivering a stream changes nothing) and
|
|
1896
|
+
non-collision (every discriminator field, changed alone, moves the id;
|
|
1897
|
+
reason arrays are order-independent sets; 5000 distinct node descriptors
|
|
1898
|
+
produce 5000 distinct ids).
|
|
1899
|
+
|
|
1900
|
+
### 14.6 Deduplication — two boundaries, and they are not interchangeable
|
|
1901
|
+
|
|
1902
|
+
§8 pushes worklist re-emission onto the consumer. The consumer needs **both**
|
|
1903
|
+
of these, and the plan's own framing of them as alternatives is wrong:
|
|
1904
|
+
|
|
1905
|
+
1. **Raw-hop dedup, at ingest.** A `Set` keyed on the 14 fields in a fixed
|
|
1906
|
+
order. This is the volume control: it collapses the re-visit
|
|
1907
|
+
multiplicity §8 describes. Measured on a `while`-loop fixture: 12 records
|
|
1908
|
+
offered, 8 accepted. It is safe precisely because of §8's monotonicity
|
|
1909
|
+
argument — duplicates are exact repeats, never stale facts.
|
|
1910
|
+
The key is built from an **explicit field list**, not `Object.keys(h)`,
|
|
1911
|
+
so a hop with an ABSENT key (§3 warns this is reachable for any emission
|
|
1912
|
+
path bypassing `analyzeFunctionFieldIdentity`'s progressive stamping)
|
|
1913
|
+
is recorded in `diagnostics().malformed` instead of silently hashing to a
|
|
1914
|
+
different key than its fully-stamped twin. C4 is where §3's completeness
|
|
1915
|
+
guarantee becomes checkable rather than merely asserted.
|
|
1916
|
+
2. **Node/edge dedup, at materialization.** Content-hash ids are Map keys,
|
|
1917
|
+
so two structurally identical edges collapse. This is the
|
|
1918
|
+
**correctness-bearing** one: two hop records that are NOT byte-identical
|
|
1919
|
+
can still describe the same logical edge (the PoC pins a pair differing
|
|
1920
|
+
only in `syntacticPath`), and (1) keeps both. (2) collapses them to one.
|
|
1921
|
+
|
|
1922
|
+
Dedup (1) alone leaves duplicate edges. Dedup (2) alone would be correct but
|
|
1923
|
+
would let group membership grow unboundedly on a hot loop, and the per-group
|
|
1924
|
+
cross product is quadratic in group size. Ship both.
|
|
1925
|
+
|
|
1926
|
+
**Where `context`'s memory cost actually lands, unmeasured but named.**
|
|
1927
|
+
`context` (§13.3's `hashState(entryState)`) is `hashState`'s full canonical
|
|
1928
|
+
string — bounded by the entry state's size, not the function's — computed
|
|
1929
|
+
once per analysis run and held by reference on every node/edge record
|
|
1930
|
+
sharing it, so the per-record field cost is one pointer, not N copies of the
|
|
1931
|
+
string. The real, unmeasured cost is in the DERIVED key strings this
|
|
1932
|
+
increment builds from it: dedup (1)'s ingest key and (2)'s node/edge id
|
|
1933
|
+
discriminator each concatenate the full `context` text once per hop/group,
|
|
1934
|
+
and those concatenated strings are retained for the store's lifetime in
|
|
1935
|
+
`_seen`/the group index. This is what C5/Sub-project E should profile at
|
|
1936
|
+
real project scale, not the record fields themselves.
|
|
1937
|
+
|
|
1938
|
+
**Construction is two-phase**: `addHop`/`addHops` accumulate into groups;
|
|
1939
|
+
nodes and edges are materialized lazily on the first read and cached until
|
|
1940
|
+
the next `addHop`. This is what lets an edge's annotation set be complete
|
|
1941
|
+
before its id is computed, and it makes the store order-insensitive — the
|
|
1942
|
+
same hops delivered in any order produce the same DAG.
|
|
1943
|
+
|
|
1944
|
+
**Cycle safety (§9.3).** Construction is one linear pass over the hop stream
|
|
1945
|
+
plus a per-group cross product; it never walks the graph, so it cannot
|
|
1946
|
+
recurse into a cycle. The read API is deliberately **traversal-free** —
|
|
1947
|
+
`nodes()`, `edges()`, `getNode`, `getEdge`, `edgesFrom`, `edgesTo`,
|
|
1948
|
+
`hasEdge`, `nodeIdFor`, `stats`, `diagnostics` are all O(1) or O(degree)
|
|
1949
|
+
index lookups. There is no recursion anywhere in this increment's code, by
|
|
1950
|
+
construction rather than by discipline. Bounded backward reconstruction is
|
|
1951
|
+
C5's job and C5's alone. Proven on a mutual-recursion fixture
|
|
1952
|
+
(`ping`/`pong`/`top`): the store builds 8 nodes and 11 edges from 34 raw
|
|
1953
|
+
records without recursing, and an explicitly budgeted walk *in the test*
|
|
1954
|
+
confirms a genuine cycle really is present — §9.3 is not hypothetical.
|
|
1955
|
+
|
|
1956
|
+
### 14.7 Correlation ambiguity is measured per pairing, not per group
|
|
1957
|
+
|
|
1958
|
+
§9.1 marks an edge `ambiguousCorrelation: true` when, at a group,
|
|
1959
|
+
`distinctInPaths ≥ 2 && distinctOutPaths ≥ 2`. Applied verbatim to a C3
|
|
1960
|
+
stream this is far too coarse, and the measurement is the argument: at the
|
|
1961
|
+
two-function resolved-call fixture it marks **3 of 5** edges — including
|
|
1962
|
+
both genuinely correct call-boundary edges — because the argument's in-half
|
|
1963
|
+
and the return's in-half share one join key by construction (§14.4). A
|
|
1964
|
+
marker that fires on the right answers is not usable input to FR-306's
|
|
1965
|
+
confidence grading.
|
|
1966
|
+
|
|
1967
|
+
**Decided: count only the pairings the store would actually form.** For an
|
|
1968
|
+
edge `(s, o)`, ambiguity is `|{s' : pairable(s', o)}| ≥ 2 && |{o' :
|
|
1969
|
+
pairable(s, o')}| ≥ 2`, where `pairable` is §14.3's peer×peer exclusion.
|
|
1970
|
+
Measured effect, same fixtures: the resolved-call fixture drops from 3
|
|
1971
|
+
marked edges to **1**; a 3-function/2-call-site fixture from 6 to 2; the
|
|
1972
|
+
mutual-recursion fixture from 6 to 2. §9.1's own genuine intraprocedural
|
|
1973
|
+
case (`const x = { a: p.email, b: q.email }`, both carrying the same id) is
|
|
1974
|
+
**unchanged at 4** — the refinement removes only the marks that the call
|
|
1975
|
+
boundary's own structure introduced, not §9.1's real ambiguity. No new hop
|
|
1976
|
+
field, no threading, no `slot`.
|
|
1977
|
+
|
|
1978
|
+
**The one artefact that survives, disclosed rather than pruned.** The
|
|
1979
|
+
remaining marked edge at a resolved call is the **bypass**: `a.email → out`,
|
|
1980
|
+
which skips the callee. It is real data flow (the identity genuinely reaches
|
|
1981
|
+
`out`) but it is not the route the program takes, and it lets a
|
|
1982
|
+
reconstruction report a path that never enters the callee. Pruning it would
|
|
1983
|
+
require a leg-based rule ("in a group containing a peer half, a non-peer ×
|
|
1984
|
+
non-peer pair is not an edge") and that rule was tried and **rejected on a
|
|
1985
|
+
counter-example**: at `const o = { r: helper(a), s: b.email }` the group also
|
|
1986
|
+
contains a legitimate non-peer × non-peer pair (`b.email → o.s`), which the
|
|
1987
|
+
rule deletes. Losing a real edge is a worse failure than keeping a marked
|
|
1988
|
+
extra one — §9.1's own "detect and mark, do not prevent" verdict, reached
|
|
1989
|
+
here for the second time on independent evidence.
|
|
1990
|
+
|
|
1991
|
+
**The cheap closure remains on the table, unchanged from §13.2's own note:**
|
|
1992
|
+
the parameter index is known for free at the bind emission site, so a `slot`
|
|
1993
|
+
field would separate the legs exactly. §9.1's evidence-first policy still
|
|
1994
|
+
applies; this section now supplies part of that evidence.
|
|
1995
|
+
|
|
1996
|
+
### 14.8 §9.5's analysis-level truncation: a reserved out-of-band channel
|
|
1997
|
+
|
|
1998
|
+
**Decided: reserve it in C4, do not invent a hop for it.** An `ITER_BUDGET`
|
|
1999
|
+
break in `analyzeFunctionFieldIdentity` is a whole-analysis-run truncation,
|
|
2000
|
+
and §9.5 says the entire result set for that function must be marked, not
|
|
2001
|
+
individual hops. Representing it as a hop would mean a fourth hop `kind` —
|
|
2002
|
+
which §2.2 explicitly guards against ("a provenance site that is not one of
|
|
2003
|
+
those three is a sign the taxonomy is being extended without the review the
|
|
2004
|
+
taxonomy earned"). So it arrives out of band:
|
|
2005
|
+
|
|
2006
|
+
```js
|
|
2007
|
+
store.markTruncated(scope, context, reason) // e.g. reason: 'iter-budget'
|
|
2008
|
+
```
|
|
2009
|
+
|
|
2010
|
+
Every node and edge in that `(scope, context)` then carries
|
|
2011
|
+
`truncated: true`, and `diagnostics().truncations` lists them. Prototyped
|
|
2012
|
+
and pinned: before the call nothing is marked, after it everything in that
|
|
2013
|
+
scope-context is. **Reserving costs one method and one Map now; retrofitting
|
|
2014
|
+
it in C5 would mean revisiting the node and edge shape after C5 has been
|
|
2015
|
+
built on them.** The *producer* side — engine or driver actually calling it
|
|
2016
|
+
when the budget breaks — is deliberately NOT in C4's scope: it is an
|
|
2017
|
+
`engine.js` change, and C4 changes no existing file.
|
|
2018
|
+
|
|
2019
|
+
### 14.9 What C4 deliberately does not do
|
|
2020
|
+
|
|
2021
|
+
- **No backward walk, no reconstruction, no path budget, no prioritization.**
|
|
2022
|
+
C5's, entirely. C4 ships the structure and the minimum read API needed to
|
|
2023
|
+
prove that structure correct.
|
|
2024
|
+
- **No `DataFlowGraph v1` output.** Sub-project E.
|
|
2025
|
+
- **No FR-306 grade computation.** C6 reads `widenReasons`/`lossReasons`/
|
|
2026
|
+
`ambiguousCorrelation`/`truncated`/**`annotations[]`** off the edges; C4
|
|
2027
|
+
only carries them.
|
|
2028
|
+
|
|
2029
|
+
> **Corrected by the final whole-branch review (finding 5): `annotations[]`
|
|
2030
|
+
> was missing from this list, and its absence would reintroduce exactly
|
|
2031
|
+
> the silence §13.6 exists to prevent.** A §13.6 context-cap-degraded
|
|
2032
|
+
> marker (`lossReason: 'context-cap-degraded'`) is classified as an
|
|
2033
|
+
> ANNOTATION, not a source (correctly, per §14.4's `lossReason` exception
|
|
2034
|
+
> — the peer was never analyzed, so it cannot be peer-sourced) — it
|
|
2035
|
+
> therefore never reaches `edge.lossReasons`, only `edge.annotations[]`.
|
|
2036
|
+
> `C4/Q2c` proves this is exactly where it lives (it finds the marker via
|
|
2037
|
+
> `e.annotations.some(a => a.lossReason === 'context-cap-degraded')`, not
|
|
2038
|
+
> via `e.lossReasons`). A C6 implementer reading only "reads
|
|
2039
|
+
> `widenReasons`/`lossReasons`/…" off the edges would drop the marker
|
|
2040
|
+
> silently — the precise §18.4 failure mode §13.6 was written to close.
|
|
2041
|
+
> C6 must read `annotations[]` too, not only the edge's own top-level
|
|
2042
|
+
> reason arrays.
|
|
2043
|
+
- **No collapsing of repeated library/framework nodes into typed summary
|
|
2044
|
+
hops.** §12 and the C-scoping doc both left this "plausibly D or C4". It is
|
|
2045
|
+
**not** C4: deciding that a node is a library node needs a registry that
|
|
2046
|
+
does not exist yet (Sub-project D). C4 has no way to tell a framework
|
|
2047
|
+
function from an application one, and guessing would be the same class of
|
|
2048
|
+
error as a fabricated endpoint.
|
|
2049
|
+
- **No wiring into `runFieldIdentityAnalysis`.** A driver run emits zero hops
|
|
2050
|
+
today (§14.1). Wiring is a follow-up task's item, and it is a `driver.js`
|
|
2051
|
+
change, not a `path-store.js` one.
|
|
2052
|
+
- **No change to `field-identity.js`** (never), and no change to any existing
|
|
2053
|
+
`src/lineage/*.js` file in the design task itself.
|
|
2054
|
+
|
|
2055
|
+
### 14.10 What the follow-up implementation task must do
|
|
2056
|
+
|
|
2057
|
+
Written the way §10.1/§10.2/§13.7 were, so the next brief needs no
|
|
2058
|
+
re-derivation.
|
|
2059
|
+
|
|
2060
|
+
**`scanner/src/lineage/ids.js`**
|
|
2061
|
+
|
|
2062
|
+
| # | Site | Change |
|
|
2063
|
+
|---|---|---|
|
|
2064
|
+
| 1 | after `edgeId` | add `provenanceNodeId` with §14.5's exact object signature; prefix `pnode:<kind>:`, via the existing `_hash`/`_canon` |
|
|
2065
|
+
| 2 | after it | add `provenanceEdgeId` with §14.5's exact object signature; prefix `pedge:` |
|
|
2066
|
+
| 3 | `test/lineage/ids.test.js` | extend with the discriminator-separation and bulk-non-collision cases the PoC carries (`C4/5`, `C4/5b`) |
|
|
2067
|
+
| 4 | `validate.js` | **no change.** `pnode:`/`pedge:` are not `DataFlowGraph v1` entity kinds and must not be added to its id-prefix regexes. Confirm by running `npm run test:lineage` — the json-schema-parity test must stay green untouched |
|
|
2068
|
+
|
|
2069
|
+
**`scanner/src/lineage/path-store.js` (new)**
|
|
2070
|
+
|
|
2071
|
+
| # | Item | Detail |
|
|
2072
|
+
|---|---|---|
|
|
2073
|
+
| 5 | imports | `ids.js` ONLY. Never `engine.js`/`summaries.js`/`driver.js` (§14.1). Add a test that asserts this by reading the file's own import list, so the boundary is enforced rather than documented |
|
|
2074
|
+
| 6 | `HOP_FIELDS` | the explicit 14-field list from §3 + §13.0, in a fixed order — never `Object.keys(h)` (§14.6) |
|
|
2075
|
+
| 7 | `classifyIn` / `classifyOut` | §14.3's rules verbatim, including `lossReason === null` in the peer-sourced branch (§14.4) and the `unclassified` fallthrough |
|
|
2076
|
+
| 8 | `PathStore` | `addHop(hop) -> boolean`, `addHops(hops) -> number`, `markTruncated(scope, context, reason)`; two-phase build; the traversal-free read API listed in §14.6 |
|
|
2077
|
+
| 9 | edge construction | per-group cross product with the peer×peer exclusion (§14.3) and the per-pairing ambiguity measure (§14.7) |
|
|
2078
|
+
| 10 | `diagnostics()` | `{ malformed, unclassified, truncations, orphanedPeerSources }` — all four are "recorded, never thrown, never dropped". `orphanedPeerSources` is §14.4's disclosed stream-completeness gap: a peer-sourced `call-resolved` hop (`lossReason: null`, non-null `peerScope`) whose named `(peerScope, peerContext, ⟨return⟩, dataElementId)` node has zero real in-edges once the whole stream has been ingested — detectable via the store's own `inIndex`/`outIndex` at build-finalize time, no new input needed. Reachable today via a cache warmed by a no-recorder run and reused by a later recorder-attached run (`driver.js`'s own returned cache, exactly as `driver.test.js` reuses it) — record it, do not fabricate an origin for it and do not drop the edge. |
|
|
2079
|
+
|
|
2080
|
+
**Tests**
|
|
2081
|
+
|
|
2082
|
+
| # | Change |
|
|
2083
|
+
|---|---|
|
|
2084
|
+
| 11 | Re-point `path-store-poc.test.js` at the shipped `path-store.js`/`ids.js`, delete its two local prototype blocks, rename it to `path-store.test.js`, and update the `test:lineage` script in `scanner/package.json` in the SAME commit — C3's item 15 precedent |
|
|
2085
|
+
| 12 | Keep every assertion, and especially keep `C4/Q2b` (the literal-§2.2 store with a dead-end callee exit) — it is the only guard that stops the §14.4 correction being silently undone by a future refactor of `classifyIn` |
|
|
2086
|
+
| 13 | Add a driver-level test only once a hop-emitting driver run is possible (Sub-project D/E). Until then a driver test would assert on an empty stream and be vacuous — see the note at the top of `engine-provenance-interprocedural.test.js`'s own driver test for the same reasoning |
|
|
2087
|
+
|
|
2088
|
+
**Deliberately NOT in the follow-up's scope:** everything in §14.9, plus any
|
|
2089
|
+
change to `engine.js`/`summaries.js` (C4 consumes the C3 stream exactly as
|
|
2090
|
+
shipped — no hop shape change is needed to answer Q1 or Q2).
|
|
2091
|
+
|
|
2092
|
+
### 14.11 Measured numbers
|
|
2093
|
+
|
|
2094
|
+
Every row produced by running the PoC's own fixtures through the prototype
|
|
2095
|
+
store on 2026-08-30. "raw" is records offered, "dedup" is records accepted
|
|
2096
|
+
after §14.6's ingest dedup.
|
|
2097
|
+
|
|
2098
|
+
| fixture | raw | dedup | groups | nodes | edges | cross-scope | ambiguous |
|
|
2099
|
+
|---|---|---|---|---|---|---|---|
|
|
2100
|
+
| `const b = a.email; return b;` | 4 | 4 | 2 | 3 | 2 | 0 | 0 |
|
|
2101
|
+
| §6's own worked example (2 fields, object literal) | 14 | 14 | 6 | 8 | 6 | 0 | 0 |
|
|
2102
|
+
| `while` loop with a re-assigned variable | 12 | 8 | 4 | 4 | 4 | 0 | 0 |
|
|
2103
|
+
| 2-function resolved call | 8 | 8 | 3 | 5 | 5 | 2 | 1 |
|
|
2104
|
+
| 3-function chain, 2 call sites | 20 | 20 | 7 | 11 | 11 | 6 | 2 |
|
|
2105
|
+
| mutual recursion (`ping`/`pong`/`top`) | 34 | 19 | 6 | 8 | 11 | 5 | 2 |
|
|
2106
|
+
| §9.1's cross-join (`{a: p.email, b: q.email}`) | 9 | 8 | 2 | 5 | 6 | 0 | 4 |
|
|
2107
|
+
|
|
2108
|
+
§6's fixture is the one to read closely: its 14 deduplicated records become
|
|
2109
|
+
**8 nodes and 6 edges** — the two field-distinct three-hop paths §6 predicts
|
|
2110
|
+
(`user.email → u.email → o.email → ⟨return⟩` and the same for `.ssn`), with
|
|
2111
|
+
**zero** materialized paths. That is FR-303's compactness requirement,
|
|
2112
|
+
measured rather than claimed.
|
|
2113
|
+
|
|
2114
|
+
---
|
|
2115
|
+
|
|
2116
|
+
## 15. Bounded path reconstruction (Sub-project C, increment 5)
|
|
2117
|
+
|
|
2118
|
+
Added 2026-08-30 by increment C5's design task. Everything in this section
|
|
2119
|
+
is **decided**, not proposed, and every behavioural claim and every number
|
|
2120
|
+
in it was produced by running code in
|
|
2121
|
+
`scanner/test/lineage/path-query-poc.test.js` — a throwaway-named PoC
|
|
2122
|
+
committed alongside this section, which prototypes `path-query.js` and the
|
|
2123
|
+
one new `ids.js` function LOCALLY (shipped source is unmodified by this
|
|
2124
|
+
design task, exactly as C1's, C3's and C4's own design tasks did). §15.10
|
|
2125
|
+
is the follow-up implementation task's file/line checklist.
|
|
2126
|
+
|
|
2127
|
+
§14.9 drew the boundary this section crosses: *"No backward walk, no
|
|
2128
|
+
reconstruction, no path budget, no prioritization. C5's, entirely."* Five
|
|
2129
|
+
questions were open when this increment was scoped, and none is answerable
|
|
2130
|
+
on paper. All five are now answered by execution:
|
|
2131
|
+
|
|
2132
|
+
- **Q3 — what IS a path in the output?** A node id sequence *and* the edge
|
|
2133
|
+
sequence that joins it, with the grading-bearing edge fields carried
|
|
2134
|
+
inline. A bare node sequence is provably insufficient. §15.2.
|
|
2135
|
+
- **Q4 — what stops a backward walk on a genuinely cyclic DAG?** An
|
|
2136
|
+
explicit **per-path** visited set (never a global one), plus three
|
|
2137
|
+
in-walk budgets (`maxExpansions`, `maxDepth`, `maxCandidatePaths`) — see
|
|
2138
|
+
§15.10's own corrected count (Task 2 review finding 5; an earlier draft
|
|
2139
|
+
here said "four," double-counting or mis-scoping against the two
|
|
2140
|
+
post-hoc caps §15.5 adds separately). Proven terminating on `C4/4`'s real
|
|
2141
|
+
mutual-recursion cycle. §15.3.
|
|
2142
|
+
- **Q5 — what does §18.4's "cap alternate paths per source/sink pair" bound
|
|
2143
|
+
when there is no source/sink registry?** The **(terminal node, start
|
|
2144
|
+
node) pair** — the terminal node is the closest thing to a "source" this
|
|
2145
|
+
increment has, and a purely global cap is measurably worse. §15.5.
|
|
2146
|
+
- **Q6 — what is FR-305's "materially different" with today's signals?**
|
|
2147
|
+
The **edge** id sequence, never the node id sequence. Proven against a
|
|
2148
|
+
real fixture where the two disagree. §15.6.
|
|
2149
|
+
- **Q7 — what does §18.4's "prioritize paths that differ in boundary,
|
|
2150
|
+
transformation, or protection state" mean when two of those three do not
|
|
2151
|
+
exist yet?** It asks for a **diverse retained set**, not a top-N by any
|
|
2152
|
+
scalar — so the cap is diversity-first over the signals that do exist,
|
|
2153
|
+
and the two absent dimensions are named, not faked. §15.7.
|
|
2154
|
+
|
|
2155
|
+
### 15.1 `path-query.js`: what it is, and the boundary it inherits
|
|
2156
|
+
|
|
2157
|
+
`path-query.js` is a **pure consumer of a built `PathStore`**. It takes a
|
|
2158
|
+
store and one node id and returns a bounded, ordered, honestly-labelled
|
|
2159
|
+
list of reconstructed paths. It never sees a hop record — C4 already turned
|
|
2160
|
+
those into a DAG — and it inherits §14.1's isolation rule unchanged:
|
|
2161
|
+
|
|
2162
|
+
> **`path-query.js` must NEVER import `engine.js`, `summaries.js`, or
|
|
2163
|
+
> `driver.js`**, and must consume `PathStore` ONLY through its public read
|
|
2164
|
+
> API — never `_groups`/`_build()`/`_peerSourced`/any other `_`-prefixed
|
|
2165
|
+
> field. Its only import is `ids.js`, for `pathId`.
|
|
2166
|
+
|
|
2167
|
+
Same reason as C4's: a real project-wide driver run still emits **zero**
|
|
2168
|
+
hops today (no source registry — Sub-projects D/E), so a query module that
|
|
2169
|
+
could only be exercised through the driver would be untestable by
|
|
2170
|
+
construction. The PoC pins the private-field half of this by string-matching
|
|
2171
|
+
the prototype's own source for `store._`.
|
|
2172
|
+
|
|
2173
|
+
**One entry point, plus two small helpers:**
|
|
2174
|
+
|
|
2175
|
+
```js
|
|
2176
|
+
reconstructPaths(store, startNodeId, opts = {}) // -> ReconstructionResult
|
|
2177
|
+
sinkCandidates(store) // -> node[] (§15.9)
|
|
2178
|
+
isIncompleteAnswer(result) // -> boolean (AC-10)
|
|
2179
|
+
```
|
|
2180
|
+
|
|
2181
|
+
`startNodeId`, deliberately **not** named `sinkNodeId`. There is no sink
|
|
2182
|
+
registry, so calling the parameter a sink would import vocabulary the
|
|
2183
|
+
codebase does not have and would read as a promise this increment cannot
|
|
2184
|
+
keep. Once Sub-project D lands, the caller supplies a registered sink node
|
|
2185
|
+
here and nothing about the signature changes.
|
|
2186
|
+
|
|
2187
|
+
### 15.2 Q3 — what a path IS: nodes AND edges, with the grading material inline
|
|
2188
|
+
|
|
2189
|
+
FR-306 requires that a path's output carry enough to grade each hop
|
|
2190
|
+
(implicit/widened vs. explicit). A bare node-id sequence cannot: every
|
|
2191
|
+
grading signal C4 records — `widenReasons`, `lossReasons`,
|
|
2192
|
+
`ambiguousCorrelation`, `annotations[]`, `crossScope`, `line`, and the four
|
|
2193
|
+
`inKind`/`inSubKind`/`outKind`/`outSubKind` fields — lives on the **edge**,
|
|
2194
|
+
not on either endpoint. And the node sequence is not even a unique key
|
|
2195
|
+
(§15.6). So:
|
|
2196
|
+
|
|
2197
|
+
```js
|
|
2198
|
+
Path = {
|
|
2199
|
+
id, // `ppath:<12 hex>` — see §15.6
|
|
2200
|
+
nodeIds: [...], // SOURCE-FIRST; length = hopCount + 1
|
|
2201
|
+
edgeIds: [...], // SOURCE-FIRST; edgeIds[i] joins nodeIds[i] -> nodeIds[i+1]
|
|
2202
|
+
hops: [...], // one per edge, denormalized (below)
|
|
2203
|
+
hopCount,
|
|
2204
|
+
dataElementId, // singular — see the note below
|
|
2205
|
+
sourceNodeId, sinkNodeId,
|
|
2206
|
+
terminal: { nodeId, reason, kind }, // reason ∈ §15.4's terminal vocabulary
|
|
2207
|
+
complete, // === (terminal.reason === 'origin')
|
|
2208
|
+
crossScopeCount, widenedHopCount, lossHopCount, ambiguousHopCount,
|
|
2209
|
+
analysisTruncated, // §14.8's markTruncated, reaching a consumer at last
|
|
2210
|
+
shape, // §15.7's diversity signature
|
|
2211
|
+
}
|
|
2212
|
+
```
|
|
2213
|
+
|
|
2214
|
+
**Source-first, even though the walk runs sink-first.** A human reads a
|
|
2215
|
+
flow source → sink; the walk is an implementation detail. The PoC asserts
|
|
2216
|
+
the exact node sequence §6 predicts, in that order, and asserts
|
|
2217
|
+
`hops[i].fromNodeId === nodeIds[i] && hops[i].toNodeId === nodeIds[i+1]`
|
|
2218
|
+
for every hop.
|
|
2219
|
+
|
|
2220
|
+
**A hop is a denormalized copy of the edge, not the edge id alone.** Ids
|
|
2221
|
+
alone would be smaller and would still round-trip through `store.getEdge`
|
|
2222
|
+
(the PoC checks that every `edgeIds[i]` does), but a path that cannot be
|
|
2223
|
+
graded without also carrying the store is a poor hand-off to C6, to
|
|
2224
|
+
Sub-project E's graph builder, and to Milestone 3's API. FR-303's
|
|
2225
|
+
compactness constraint governs the **store**, not an on-demand query
|
|
2226
|
+
result — §18.4's own wording is "store a provenance DAG, not a list of
|
|
2227
|
+
every expanded path", and the cap plus the budgets are what keep the
|
|
2228
|
+
materialized list bounded.
|
|
2229
|
+
|
|
2230
|
+
**A path never changes data element, and this needs no filtering.** Every
|
|
2231
|
+
edge `path-store.js` builds joins two nodes whose descriptors both take
|
|
2232
|
+
`dataElementId` from the same hop (§14.3), so a connected walk is
|
|
2233
|
+
field-precise by construction — FR-301's distinctness carried into
|
|
2234
|
+
FR-303's structure for free. Pinned directly (`C5/1b`: every edge in a real
|
|
2235
|
+
two-identity store has `from.dataElementId === e.dataElementId ===
|
|
2236
|
+
to.dataElementId`). A future change that broke this would silently let a
|
|
2237
|
+
reconstruction wander between data elements, so it is asserted rather than
|
|
2238
|
+
assumed.
|
|
2239
|
+
|
|
2240
|
+
### 15.3 Q4 — the backward walk, and how it terminates on a real cycle
|
|
2241
|
+
|
|
2242
|
+
`edgesTo(nodeId)` is the traversal primitive. The walk is an **iterative
|
|
2243
|
+
DFS over an explicit stack — never recursion**, matching this package's own
|
|
2244
|
+
established discipline (`path-store.js` has no recursion at all; C5 has a
|
|
2245
|
+
graph walk and still has none).
|
|
2246
|
+
|
|
2247
|
+
**Termination discipline, in order of what actually does the work:**
|
|
2248
|
+
|
|
2249
|
+
1. **A per-path visited set** — the set of nodes already on the current
|
|
2250
|
+
partial path. An in-edge whose source is already on the path is
|
|
2251
|
+
*clipped* and counted (`cyclesClipped`). This is what makes the walk
|
|
2252
|
+
terminate on a cyclic DAG regardless of budget: every enumerated path is
|
|
2253
|
+
a **simple** path, and a finite graph has finitely many. Measured on
|
|
2254
|
+
`C4/4`'s real 8-node/11-edge mutual-recursion cycle (§15.11's last row):
|
|
2255
|
+
the walk finishes having used **35 expansions** summed across all four
|
|
2256
|
+
sink candidates, with **7 clips**. `C5/3` re-runs it with every budget
|
|
2257
|
+
raised to 10^6 and asserts the expansion count is unchanged and far
|
|
2258
|
+
below the ceiling — the visited set, not a budget, is what stopped it.
|
|
2259
|
+
> **A GLOBAL visited set would be wrong, not merely conservative.** It
|
|
2260
|
+
> would make every node reachable on at most one path, which
|
|
2261
|
+
> deletes exactly the alternate paths FR-305 exists to show.
|
|
2262
|
+
2. **`maxExpansions`** (default 10000) — total in-edges examined. This is
|
|
2263
|
+
the hard, shape-independent termination guarantee, and the only one that
|
|
2264
|
+
bounds *work* rather than *output*. It is the budget the brief's "a cap
|
|
2265
|
+
on total paths explored, not just total nodes visited" asks for.
|
|
2266
|
+
3. **`maxDepth`** (default 64) — hops on a single path.
|
|
2267
|
+
> **Corrected by fix round 1 (finding 5): this is the SECOND most
|
|
2268
|
+
> load-bearing knob, not the most droppable one.** A DFS frame carries a
|
|
2269
|
+
> copy of the path so far, so extending a k-hop path costs O(k) and one
|
|
2270
|
+
> path of depth D costs **O(D²)** — a cost `maxExpansions` cannot see,
|
|
2271
|
+
> because it counts edges examined, not elements copied. Measured
|
|
2272
|
+
> (`C5/3d`) on a hand-built straight 3000-hop chain: the whole walk is
|
|
2273
|
+
> 3000 expansions — 30% of the default expansion budget (10000, corrected
|
|
2274
|
+
> from an earlier "0.03%" here that was wrong by 1000x), comfortably
|
|
2275
|
+
> under that budget so it would never fire — yet it takes ~150 ms unbounded against
|
|
2276
|
+
> ~0 ms at `maxDepth: 8`. It is also the **only** budget whose limit
|
|
2277
|
+
> produces an EMITTED, marked partial rather than an abandoned branch
|
|
2278
|
+
> (§15.4), proven by contrast on the same store.
|
|
2279
|
+
4. **`maxCandidatePaths`** (default 256) — stop enumerating once this many
|
|
2280
|
+
complete-or-partial branches have been collected.
|
|
2281
|
+
> **Corrected by fix round 1 (finding 5). This knob is a TIGHTENING
|
|
2282
|
+
> constant, not an independent safety guarantee, and the earlier text
|
|
2283
|
+
> here ("bounds memory") was wrong as written.** The DFS emits at most
|
|
2284
|
+
> one candidate per *popped* frame, and a frame is only ever pushed by
|
|
2285
|
+
> an expansion, so `enumeratedPathCount ≤ expansionsUsed + 1` holds
|
|
2286
|
+
> **unconditionally** — `maxExpansions` already bounds the candidate
|
|
2287
|
+
> array on its own. `C5/3e` measures that bound across every start node
|
|
2288
|
+
> of three fixtures with this knob raised out of the way. What the knob
|
|
2289
|
+
> genuinely buys is a much tighter *default* (256, against the ~10001
|
|
2290
|
+
> `maxExpansions` alone would permit) and an early exit for a caller who
|
|
2291
|
+
> wants a few paths fast. It is kept for that, stated as that. Dropping
|
|
2292
|
+
> it, and its `'candidate-cap'` truncation reason with it, would also
|
|
2293
|
+
> have been defensible.
|
|
2294
|
+
|
|
2295
|
+
**The depth check runs AFTER the zero-in-edges check, deliberately.** A
|
|
2296
|
+
node with no predecessors is a genuine origin no matter how deep the walk
|
|
2297
|
+
is, so a path that reaches one is `complete: true` even at `maxDepth: 1`
|
|
2298
|
+
(`C5/3b` pins exactly this). Only a branch the *limit* stopped is marked
|
|
2299
|
+
partial.
|
|
2300
|
+
|
|
2301
|
+
**`edgesTo` is sorted by edge id before traversal.** `path-store.js` backs
|
|
2302
|
+
its indexes with `Set`s and the read API carries no inherent order, so an
|
|
2303
|
+
unsorted walk would be insertion-order-dependent. Sorting makes the
|
|
2304
|
+
enumeration order, and therefore every tie-broken output order, stable.
|
|
2305
|
+
|
|
2306
|
+
**Defaults are honestly uncalibrated.** They are two-plus orders of
|
|
2307
|
+
magnitude above what every fixture in the PoC needs (§15.11's largest row
|
|
2308
|
+
uses 35 expansions), but no fixture here is a real project, and no real
|
|
2309
|
+
project can be measured until a driver run emits hops (Sub-projects D/E).
|
|
2310
|
+
They are a starting point to re-measure then, not a tuned result.
|
|
2311
|
+
|
|
2312
|
+
### 15.4 §18.4's load-bearing constraint: five distinguishable answers
|
|
2313
|
+
|
|
2314
|
+
> *"Never translate 'path budget exhausted' into 'no path.'"*
|
|
2315
|
+
|
|
2316
|
+
The result shape makes **five** answers pairwise distinguishable **in the
|
|
2317
|
+
data**, not by convention or by a caller's discipline:
|
|
2318
|
+
|
|
2319
|
+
Every result also carries `startNodeId` and `startNodeKind` (the started-from
|
|
2320
|
+
node's `kind`, or `null` when it is unknown), so a consumer can tell what it
|
|
2321
|
+
asked about without a second `getNode` call.
|
|
2322
|
+
|
|
2323
|
+
| answer | `truncated` | `unknownStartNode` | `noPathReason` | `truncationReasons` | means |
|
|
2324
|
+
|---|---|---|---|---|---|
|
|
2325
|
+
| complete, with paths | `false` | `false` | `null` | `[]` | this really is everything |
|
|
2326
|
+
| genuinely empty | `false` | `false` | `'no-incoming-edges'` | `[]` | we looked exhaustively; nothing flows in |
|
|
2327
|
+
| **recording gap** | `false` | `false` | `'incomplete-record'` | `[]` | nothing flows in *in the recorded stream*, and the store itself knows the stream is incomplete here — **not proof of absence** |
|
|
2328
|
+
| unknown node | `false` | `true` | `null` | `[]` | you asked about a node that is not in this store |
|
|
2329
|
+
| budget-truncated | `true` | `false` | **`null`** | non-empty | the list is short because a limit stopped us |
|
|
2330
|
+
|
|
2331
|
+
`C5/4b` asserts three of these — genuinely-empty, unknown-node and
|
|
2332
|
+
budget-truncated — are pairwise different as **literal JSON**, not merely
|
|
2333
|
+
different in spirit; `C5/4c` adds the recording-gap row against a real
|
|
2334
|
+
cache-warming fixture, and `C5/1` the first. The single most important cell is
|
|
2335
|
+
the bold `null`: **a truncated result can never acquire a `noPathReason`**,
|
|
2336
|
+
because `noPathReason` is only ever computed when `truncated === false`.
|
|
2337
|
+
That is the §18.4 constraint expressed as a code path, not as a comment.
|
|
2338
|
+
|
|
2339
|
+
**Truncation reasons** (result-level, sorted, non-empty iff `truncated`):
|
|
2340
|
+
`'expansion-budget'`, `'candidate-cap'`, `'depth-limit'`,
|
|
2341
|
+
`'per-terminal-cap'`, `'path-cap'`. `truncated` is *also* set whenever
|
|
2342
|
+
`droppedPathCount > 0`, so a cap that silently discarded a path is
|
|
2343
|
+
impossible.
|
|
2344
|
+
|
|
2345
|
+
**Terminal reasons** (per path): `'origin'` (the only one that sets
|
|
2346
|
+
`complete: true`), `'incomplete-record'`, `'cycle'`, `'depth-limit'`.
|
|
2347
|
+
|
|
2348
|
+
- A branch that ends because **every** continuation would revisit a node
|
|
2349
|
+
reports `'cycle'` — never `'origin'`. Presenting a cycle-clip as an
|
|
2350
|
+
origin would be §18.4's failure mode at path granularity. `C5/3c` proves
|
|
2351
|
+
a real cyclic fixture produces both labels, so they are demonstrably
|
|
2352
|
+
different rather than nominally so.
|
|
2353
|
+
- A branch that ends at a node in `diagnostics().orphanedPeerSources`
|
|
2354
|
+
reports `'incomplete-record'`. This is §14.4's disclosed
|
|
2355
|
+
stream-completeness gap reaching a consumer for the first time: C4
|
|
2356
|
+
records it, C5 is the first thing that must not lie about it. `C5/4c`
|
|
2357
|
+
builds the real cache-warmed-without-a-recorder scenario and proves both
|
|
2358
|
+
the zero-path form and the walked-into form.
|
|
2359
|
+
- **`'expansion-budget'` and `'candidate-cap'` are deliberately NOT
|
|
2360
|
+
terminal reasons.** When those trip, in-flight branches are **abandoned,
|
|
2361
|
+
not emitted**: a branch cut at an arbitrary global point has a
|
|
2362
|
+
meaningless prefix, and emitting it would manufacture a "path" the graph
|
|
2363
|
+
does not contain. `'depth-limit'` is different — that branch genuinely
|
|
2364
|
+
reached a stated ceiling — so it *is* emitted, marked partial. `C5/3b`
|
|
2365
|
+
pins that; `C5/3`'s two starved runs pin both of the others
|
|
2366
|
+
(`maxExpansions: 1` and `maxCandidatePaths: 1`), each producing
|
|
2367
|
+
`truncated: true` with `noPathReason: null` and no fabricated partial.
|
|
2368
|
+
|
|
2369
|
+
**Two further incompleteness signals, both distinct from `truncated`:**
|
|
2370
|
+
|
|
2371
|
+
- `analysisTruncated` — true when any node or edge on a returned path
|
|
2372
|
+
carries §14.8's `truncated: true` from `markTruncated(scope, context,
|
|
2373
|
+
reason)`. §14.8 reserved that channel and named no consumer; **C5 is its
|
|
2374
|
+
consumer.** `C5/4d` proves the path is still *returned* (never withheld)
|
|
2375
|
+
and still *labelled*, and that the flag stays separate from C5's own
|
|
2376
|
+
`truncated` so the two causes never merge.
|
|
2377
|
+
- `paths.some(p => !p.complete)` — a returned list every one of whose paths
|
|
2378
|
+
is partial is a very different answer from a list of complete ones.
|
|
2379
|
+
|
|
2380
|
+
`isIncompleteAnswer(result)` is the single derived predicate AC-10's
|
|
2381
|
+
persistent partial-coverage banner should drive off, so no caller
|
|
2382
|
+
re-derives it (and no caller forgets a term):
|
|
2383
|
+
|
|
2384
|
+
```js
|
|
2385
|
+
result.truncated || result.unknownStartNode || result.analysisTruncated
|
|
2386
|
+
|| result.noPathReason === 'incomplete-record'
|
|
2387
|
+
|| result.paths.some((p) => !p.complete)
|
|
2388
|
+
```
|
|
2389
|
+
|
|
2390
|
+
AC-10's *"a zero-flow filter result must say that the scope is
|
|
2391
|
+
incomplete"* then falls straight out: an empty `paths` with
|
|
2392
|
+
`isIncompleteAnswer() === true` is exactly that case, and an empty `paths`
|
|
2393
|
+
with `isIncompleteAnswer() === false` is honestly a real zero.
|
|
2394
|
+
|
|
2395
|
+
### 15.5 Q5 — the alternate-path cap: per (terminal, start) pair, today
|
|
2396
|
+
|
|
2397
|
+
§18.4 says *"cap alternate paths per source/sink pair with an explicit
|
|
2398
|
+
truncation count."* `path-store.js` has no notion of a source or a sink.
|
|
2399
|
+
The naive reading — cap the paths returned by one call, i.e. per *start
|
|
2400
|
+
node* — is strictly coarser than "per pair", and the difference is not
|
|
2401
|
+
academic:
|
|
2402
|
+
|
|
2403
|
+
> **MEASURED (`C5/5f`), on the mutual-recursion fixture.** At the first
|
|
2404
|
+
> sink with two terminals the walk finds **6 paths across 2 distinct
|
|
2405
|
+
> terminals** — 4 complete, 2 cycle-terminated — and the two terminals are
|
|
2406
|
+
> separated by a real, deterministic ranking reason rather than by chance:
|
|
2407
|
+
> every *complete* path terminates at one of them, every
|
|
2408
|
+
> *cycle-terminated* path at the other, and `complete` is `comparePaths`'
|
|
2409
|
+
> **first** key. So a naive global top-N cap at **N = 4** fills entirely
|
|
2410
|
+
> from the complete terminal and covers **1 of 2**; the other terminal is
|
|
2411
|
+
> reported as having *zero* paths. That is §18.4's own failure
|
|
2412
|
+
> mode ("budget exhausted" presented as "no path") reached through the cap
|
|
2413
|
+
> rather than through the walk.
|
|
2414
|
+
>
|
|
2415
|
+
> **Re-anchored by fix round 1 (finding 3).** This measurement was
|
|
2416
|
+
> originally taken on the §9.1 cross-join fixture. The claim was true
|
|
2417
|
+
> there, but it proved nothing: all four of that fixture's paths share an
|
|
2418
|
+
> **identical** `comparePaths` content tuple (`C5/5d` now pins the flat
|
|
2419
|
+
> `[1,1,1,1]` ambiguity vector), so which two survived a naive cap was
|
|
2420
|
+
> decided purely by `pathId`'s hash. A measurement that holds by hash
|
|
2421
|
+
> coincidence is exactly the "prose stronger than the proof" failure this
|
|
2422
|
+
> document exists to prevent.
|
|
2423
|
+
|
|
2424
|
+
**Decided: the cap is applied per TERMINAL first, then globally.**
|
|
2425
|
+
|
|
2426
|
+
- `maxPathsPerTerminal` (default 8) — candidates are grouped by
|
|
2427
|
+
`terminal.nodeId`, ordered within the group (§15.7), and truncated there.
|
|
2428
|
+
Because every call already fixes one start node, "(terminal, start)" *is*
|
|
2429
|
+
a pair, and the terminal node is the closest thing to a source this
|
|
2430
|
+
increment has. This is a genuine per-pair cap today, not a stand-in.
|
|
2431
|
+
- `maxPaths` (default 32) — a global ceiling on the returned list,
|
|
2432
|
+
applied diversity-first (§15.7).
|
|
2433
|
+
- `result.terminals[]` reports, per terminal:
|
|
2434
|
+
`{nodeId, terminalReasons, enumeratedPathCount, keptPathCount,
|
|
2435
|
+
returnedPathCount, droppedPathCount, truncated}`. This is also what
|
|
2436
|
+
FR-305's *"the UI must show a path count"* needs — a count **per
|
|
2437
|
+
source/sink pair**, not one aggregate.
|
|
2438
|
+
|
|
2439
|
+
Two properties of that row are load-bearing, and fix round 1 found both
|
|
2440
|
+
of them wrong in the first draft:
|
|
2441
|
+
|
|
2442
|
+
> **`truncated` is `enumeratedPathCount > returnedPathCount` — measured
|
|
2443
|
+
> AFTER the global cap, never from the per-terminal cap alone (finding 1,
|
|
2444
|
+
> blocking).** The first draft computed it from `maxPathsPerTerminal`
|
|
2445
|
+
> only, *before* the diversity round-robin ran, so a terminal that the
|
|
2446
|
+
> **global** cap starved to zero returned paths still reported
|
|
2447
|
+
> `truncated: false`. Reproduced concretely on the §9.1 cross-join
|
|
2448
|
+
> fixture at `{maxPaths: 1, maxPathsPerTerminal: 8}`: the `p.email`
|
|
2449
|
+
> terminal showed `enumerated=2 kept=2 returned=0 truncated=false` — a
|
|
2450
|
+
> terminal with ZERO returned paths claiming it was not truncated. (That
|
|
2451
|
+
> exact row now reads `enumerated=2 kept=2 returned=0 droppedPathCount=2
|
|
2452
|
+
> truncated=true`, pinned verbatim by `C5/5e`.) That
|
|
2453
|
+
> is §18.4's exact failure mode reproduced at *pair* granularity, inside
|
|
2454
|
+
> the very field this section introduces to satisfy it at *call*
|
|
2455
|
+
> granularity, and it would have been read by precisely the consumers
|
|
2456
|
+
> (C6, Milestone 3's UI) that the field exists for. `droppedPathCount`
|
|
2457
|
+
> makes the count explicit per pair the way `result.droppedPathCount`
|
|
2458
|
+
> does per call, and `C5/5e` now pins BOTH cases — the per-terminal cap
|
|
2459
|
+
> (where the bug structurally cannot fire) and the global cap alone
|
|
2460
|
+
> (where it did) — plus the arithmetic that the per-terminal rows sum to
|
|
2461
|
+
> the per-call totals, so a consumer can never be told two different
|
|
2462
|
+
> stories.
|
|
2463
|
+
>
|
|
2464
|
+
> **`terminalReasons` is a sorted UNION over the terminal's own paths,
|
|
2465
|
+
> never a positional pick (finding 2).** A terminal can genuinely carry
|
|
2466
|
+
> MIXED reasons: on the mutual-recursion fixture at `maxDepth: 3`, one
|
|
2467
|
+
> sink has a terminal reached by both a `'cycle'` clip and a
|
|
2468
|
+
> `'depth-limit'` stop. The first draft's `group[0].terminal.reason`
|
|
2469
|
+
> therefore reported whichever the DFS happened to enumerate first —
|
|
2470
|
+
> the SAME representative-picking bug class C4's own final whole-branch
|
|
2471
|
+
> review found in `path-store.js`'s `origin` branch (`g.annotations[0]`)
|
|
2472
|
+
> and fixed the same way. The singular field is **removed**, not
|
|
2473
|
+
> supplemented, so no consumer can keep reading the order-dependent one;
|
|
2474
|
+
> `C5/5h` asserts its absence as well as the union's correctness.
|
|
2475
|
+
|
|
2476
|
+
**What changes when Sub-project D's registry lands.** Very little, and that
|
|
2477
|
+
is the point. D relabels *which* terminals are registered sources; the
|
|
2478
|
+
grouping key does not change, and neither does the cap's meaning. The one
|
|
2479
|
+
thing D adds is the ability to say "this terminal is not a registered
|
|
2480
|
+
source" — at which point a caller may want to *drop* rather than cap such
|
|
2481
|
+
paths, which is a filter, not a cap, and belongs in D's own increment. The
|
|
2482
|
+
per-call framing is therefore already correct: today's cap does not need to
|
|
2483
|
+
be revisited, only supplemented.
|
|
2484
|
+
|
|
2485
|
+
### 15.6 Q6 — deduplication, and what FR-305 forbids hiding
|
|
2486
|
+
|
|
2487
|
+
> FR-305: *"Deduplication may collapse identical internal segments but
|
|
2488
|
+
> cannot hide materially different transformations or controls."*
|
|
2489
|
+
|
|
2490
|
+
**Decided: a path's identity is its EDGE id sequence, never its node id
|
|
2491
|
+
sequence.**
|
|
2492
|
+
|
|
2493
|
+
```js
|
|
2494
|
+
pathId({ startNodeId, edgeIds }) // -> `ppath:<12 hex>`
|
|
2495
|
+
```
|
|
2496
|
+
|
|
2497
|
+
§14.5 left `pathId` deliberately unclaimed for exactly this entity
|
|
2498
|
+
("`pathId` is deliberately left unused. The thing C5 reconstructs *is* a
|
|
2499
|
+
path, and it will plausibly want that name"). C5 claims it, with a
|
|
2500
|
+
`ppath:` prefix joining the `pnode:`/`pedge:` family — a reconstructed path
|
|
2501
|
+
is not a `DataFlowGraph v1` entity either, so `validate.js` stays untouched
|
|
2502
|
+
(§15.10 item 4). `startNodeId` is in the discriminator even though it is
|
|
2503
|
+
strictly **redundant today** — a path always has at least one hop (a start
|
|
2504
|
+
node with no in-edges yields zero paths, never one empty path, §15.4), so
|
|
2505
|
+
the last edge id already determines it. It is kept because over-specifying
|
|
2506
|
+
a content hash costs nothing while under-specifying one is a silent merge —
|
|
2507
|
+
§14.5's own lesson, applied rather than re-learned — and because it keeps
|
|
2508
|
+
the id well-defined if a later increment ever admits a zero-hop or
|
|
2509
|
+
otherwise edge-less path entity.
|
|
2510
|
+
|
|
2511
|
+
**Why node-keyed dedup would violate FR-305, proven on a real fixture.**
|
|
2512
|
+
`C5/5` builds
|
|
2513
|
+
|
|
2514
|
+
```js
|
|
2515
|
+
function f(user) { let a = user.email; let b = a; b = a; return b; }
|
|
2516
|
+
```
|
|
2517
|
+
|
|
2518
|
+
and measures that the node pair `(a, data:email) -> (b, data:email)` is
|
|
2519
|
+
joined by **two distinct edges** — two assignments at two CFG nodes, which
|
|
2520
|
+
`provenanceEdgeId` keeps apart precisely because §14.5 put `siteNodeId` in
|
|
2521
|
+
the discriminator ("two structurally identical hops at two different
|
|
2522
|
+
program points are two materially different edges (FR-305)"). Reconstruction
|
|
2523
|
+
returns them as two paths with an **identical node sequence** and different
|
|
2524
|
+
`line`s. Collapsing on nodes would hide the differing program point — the
|
|
2525
|
+
one thing a reader would notice, and squarely inside "materially
|
|
2526
|
+
different". Collapsing on edge ids cannot hide anything, because an edge id
|
|
2527
|
+
is a content hash over every grading-bearing field (§14.5): two paths with
|
|
2528
|
+
the same edge sequence are identical hop-for-hop, in kind, sub-kind,
|
|
2529
|
+
reasons, and site.
|
|
2530
|
+
|
|
2531
|
+
**Honest scope, measured not assumed.** Within one `reconstructPaths` call
|
|
2532
|
+
a DFS with a per-path visited set **cannot** emit the same edge sequence
|
|
2533
|
+
twice, so dedup is *not* a volume control here — it is an identity
|
|
2534
|
+
definition (stable across runs and across calls: `C5/5c` proves two
|
|
2535
|
+
independent analysis runs of the same fixture produce the same `ppath:`
|
|
2536
|
+
id) and a safety net. `C5/5b` measures the no-duplicates property across
|
|
2537
|
+
every node of the cyclic fixture rather than asserting it.
|
|
2538
|
+
|
|
2539
|
+
**What is NOT collapsed, and why.** §9.1's cross-join phantoms
|
|
2540
|
+
(`p.email → x.b` where the value came from `q.email`) are genuinely
|
|
2541
|
+
different node sequences and are **kept**, marked
|
|
2542
|
+
`ambiguousCorrelation: true` on the offending hop, and de-prioritized by
|
|
2543
|
+
§15.7's order. That is §9.1's own "detect and mark, do not prevent"
|
|
2544
|
+
verdict carried to the output — and §14.7 reached the same verdict a second
|
|
2545
|
+
time on independent evidence. Silently collapsing them would be exactly the
|
|
2546
|
+
hiding FR-305 forbids; the `ambiguousHopCount` on the path is how a
|
|
2547
|
+
consumer tells them apart. `C5/5d` pins the *keeping* half on §9.1's own
|
|
2548
|
+
fixture (4 distinct routes, none collapsed).
|
|
2549
|
+
|
|
2550
|
+
> **Where the DE-PRIORITIZING half is actually proven, corrected by fix
|
|
2551
|
+
> round 1 (finding 4).** Not on §9.1's fixture: its ambiguity vector is a
|
|
2552
|
+
> flat `[1,1,1,1]`, so every ordering assertion made on it is **vacuous** —
|
|
2553
|
+
> a guarded "unambiguous before ambiguous" check never executes and a
|
|
2554
|
+
> monotonicity loop only ever compares `1 >= 1`. `C5/5d` now pins that
|
|
2555
|
+
> flatness explicitly, so the fixture can never again be mistaken for
|
|
2556
|
+
> ordering evidence, and proves the ordering on §14.7's leg fixture
|
|
2557
|
+
> instead, whose ambiguity genuinely varies (`1` vs `2`) — and where the
|
|
2558
|
+
> more-ambiguous paths are ALSO the boundary-crossing ones, so key 2
|
|
2559
|
+
> (ambiguity) is shown to *override* key 4 (cross-scope) rather than merely
|
|
2560
|
+
> agree with it. `C5/2` is the other genuine reorder (§15.7).
|
|
2561
|
+
|
|
2562
|
+
### 15.7 Q7 — prioritization: diversity first, and what is honestly deferred
|
|
2563
|
+
|
|
2564
|
+
> §18.4: *"Prioritize paths that differ in boundary, transformation, or
|
|
2565
|
+
> protection state."*
|
|
2566
|
+
|
|
2567
|
+
Read carefully, that sentence asks for a **diverse retained set** — show
|
|
2568
|
+
the user paths that differ from each other — not a ranking by any single
|
|
2569
|
+
scalar. Both halves are implemented, and they are different mechanisms.
|
|
2570
|
+
|
|
2571
|
+
**(a) The cap is diversity-first.** Each path carries a `shape` signature
|
|
2572
|
+
built ONLY from signals that exist today:
|
|
2573
|
+
|
|
2574
|
+
```
|
|
2575
|
+
<complete|partial> / <boundary|local> / <widened|explicit> / <lossy|intact> / <ambiguous|correlated>
|
|
2576
|
+
```
|
|
2577
|
+
|
|
2578
|
+
from `terminal.reason`, `crossScopeCount`, `widenReasons`, `lossReasons`
|
|
2579
|
+
and `ambiguousCorrelation` respectively. When `maxPaths` binds, candidates
|
|
2580
|
+
are bucketed by `(terminal.nodeId, shape)` and taken **round-robin** across
|
|
2581
|
+
buckets, so no bucket is crowded out. `C5/5g` proves this on a real
|
|
2582
|
+
fixture (`{ r: helper(a), s: b.email }` — 8 paths from its `⟨return⟩` sink
|
|
2583
|
+
alone, spanning 2 shapes: `complete/boundary/…` and `complete/local/…`, a
|
|
2584
|
+
genuine boundary difference). Capped to exactly the bucket count, the
|
|
2585
|
+
retained set spans **every** shape — which a plain top-N by rank does not
|
|
2586
|
+
guarantee.
|
|
2587
|
+
|
|
2588
|
+
**(b) Within that, a deterministic total order** (`comparePaths`), keys in
|
|
2589
|
+
order:
|
|
2590
|
+
|
|
2591
|
+
1. `complete` first — an incomplete path is not evidence of a full flow.
|
|
2592
|
+
2. fewer `ambiguousHopCount` — §9.1/§14.7's marker, lower confidence.
|
|
2593
|
+
3. fewer `lossHopCount`, then fewer `widenedHopCount` — FR-306's
|
|
2594
|
+
lower-confidence grades, in the order a reader would rank them.
|
|
2595
|
+
4. **more** `crossScopeCount` — §18.4's "boundary" dimension, and this
|
|
2596
|
+
document's own repeated emphasis on interprocedural stitching being the
|
|
2597
|
+
hard, load-bearing case.
|
|
2598
|
+
5. fewer hops — a shorter explanation, all else equal.
|
|
2599
|
+
6. `id` lexicographic — stability, never a tie left to insertion order.
|
|
2600
|
+
|
|
2601
|
+
`C5/2` proves this order does real work rather than being decorative: at
|
|
2602
|
+
the plain 2-function resolved call, the **4-hop through-the-callee chain**
|
|
2603
|
+
(`crossScope 2`, `ambiguous 0`) is ranked **ahead of** the 2-hop bypass
|
|
2604
|
+
(`crossScope 0`, `ambiguous 1`) — the correct answer wins despite being
|
|
2605
|
+
twice as long, because length is the last content key rather than the
|
|
2606
|
+
first.
|
|
2607
|
+
|
|
2608
|
+
**Honestly deferred, and named:**
|
|
2609
|
+
|
|
2610
|
+
- **Transformation kind** — there is no transformation-kind recognition in
|
|
2611
|
+
this codebase at all. **Sub-project D.** Today's nearest signals
|
|
2612
|
+
(`widenReasons`/`lossReasons`) describe *analysis imprecision*, not a
|
|
2613
|
+
transformation the program performs, and using them as a stand-in would
|
|
2614
|
+
be inventing vocabulary that isn't backed by data.
|
|
2615
|
+
- **Protection state** — `protection.js` defines the verdict *model*; no
|
|
2616
|
+
analyzer produces a verdict. **Milestone 2.**
|
|
2617
|
+
|
|
2618
|
+
When either lands, it adds a component to `shape` and a key to
|
|
2619
|
+
`comparePaths`. Neither changes the mechanism, and neither is faked in the
|
|
2620
|
+
meantime.
|
|
2621
|
+
|
|
2622
|
+
### 15.8 What C5 deliberately does not do
|
|
2623
|
+
|
|
2624
|
+
- **No source/sink registry.** `sinkCandidates()` (§15.9) is a structural
|
|
2625
|
+
stand-in and says so. **Sub-project D.**
|
|
2626
|
+
- **No FR-306 grade computation.** C5's path output *carries* every
|
|
2627
|
+
grading input (`widenReasons`, `lossReasons`, `ambiguousCorrelation`,
|
|
2628
|
+
`annotations[]`, `crossScope`, plus the four kind/sub-kind fields) and
|
|
2629
|
+
computes only counts. Turning counts into a grade is **C6**. Note
|
|
2630
|
+
§14.9's own correction: a consumer must read `annotations[]` too, not
|
|
2631
|
+
only the edge's top-level reason arrays — `hops[].annotations` is
|
|
2632
|
+
therefore carried verbatim.
|
|
2633
|
+
- **No `DataFlowGraph v1` output.** Sub-project E.
|
|
2634
|
+
- **No flow-sensitivity filter.** §9.2 offered C5 a lever (require
|
|
2635
|
+
non-decreasing `line`/`nodeId` along a path, or de-prioritize paths that
|
|
2636
|
+
violate it) and explicitly left the call to C5 "made against real
|
|
2637
|
+
measurements". **Declined for now, with the reason stated:** the
|
|
2638
|
+
measurement that would justify it does not exist — no fixture here has a
|
|
2639
|
+
real kill-then-reuse shape at scale, and §9.2's own note that the lever
|
|
2640
|
+
carries "false-negative risk on loops and back-edges" is not something to
|
|
2641
|
+
accept against zero evidence. The material is on the path (`hops[].line`,
|
|
2642
|
+
`hops[].siteNodeId`) for whichever increment does measure it.
|
|
2643
|
+
- **No change to `path-store.js`, and none to `field-identity.js`
|
|
2644
|
+
(never).** §15.9.
|
|
2645
|
+
- **No driver wiring.** A driver run emits zero hops today (§14.1); wiring
|
|
2646
|
+
is Sub-project D/E's, and it is a `driver.js` change, not a
|
|
2647
|
+
`path-query.js` one.
|
|
2648
|
+
|
|
2649
|
+
### 15.9 `path-store.js`'s read API is sufficient — no change, and why
|
|
2650
|
+
|
|
2651
|
+
Checked by building the whole prototype against it. The query itself needs
|
|
2652
|
+
exactly **four** of the ten exported reads:
|
|
2653
|
+
|
|
2654
|
+
| read | used for |
|
|
2655
|
+
|---|---|
|
|
2656
|
+
| `getNode(id)` | start-node existence (the `unknownStartNode` answer), terminal `kind`, and each node's `truncated` flag |
|
|
2657
|
+
| `edgesTo(id)` | **the** traversal primitive — the only walk call, and the only read inside the loop |
|
|
2658
|
+
| `nodes()` | `sinkCandidates()`'s O(N) filter (§15.9's registry stand-in) |
|
|
2659
|
+
| `diagnostics()` | `orphanedPeerSources` → the `'incomplete-record'` signal, read once per call |
|
|
2660
|
+
|
|
2661
|
+
A hop is denormalized straight off the edge objects `edgesTo` already
|
|
2662
|
+
returns, so **`getEdge` is never called by the query** — it is used only by
|
|
2663
|
+
the tests, to prove every emitted `edgeIds[i]` round-trips. `nodeIdFor` is
|
|
2664
|
+
likewise fixtures-only. `edges`, `edgesFrom`, `hasEdge` and `stats` are
|
|
2665
|
+
unused entirely.
|
|
2666
|
+
|
|
2667
|
+
**The one question the brief raised explicitly — is there a cheap way to
|
|
2668
|
+
find every sink-shaped node without a registry? — is answered YES with no
|
|
2669
|
+
API addition.** `store.nodes()` already returns every node with its `kind`,
|
|
2670
|
+
so:
|
|
2671
|
+
|
|
2672
|
+
```js
|
|
2673
|
+
function sinkCandidates(store) {
|
|
2674
|
+
return store.nodes().filter((n) =>
|
|
2675
|
+
n.kind === 'return' || n.kind === 'escape' || n.kind === 'loss');
|
|
2676
|
+
}
|
|
2677
|
+
```
|
|
2678
|
+
|
|
2679
|
+
is an O(N) filter over the public API. It belongs in `path-query.js`, not
|
|
2680
|
+
in `path-store.js`: deciding what counts as a sink is a *query* concern,
|
|
2681
|
+
and the moment D ships a registry this helper is superseded rather than
|
|
2682
|
+
extended. It is named `sinkCandidates`, not `sinks`, for the same reason
|
|
2683
|
+
the entry point's parameter is `startNodeId` — it is a structural filter
|
|
2684
|
+
with no security opinion, and its doc comment says so.
|
|
2685
|
+
|
|
2686
|
+
`escape` and `loss` cannot appear as intermediates: `classifyIn` never
|
|
2687
|
+
produces them as a source, so nothing in the store ever points *out* of
|
|
2688
|
+
one. `C5/2b` asserts `edgesFrom(n).length === 0` for every such node in a
|
|
2689
|
+
real fixture, which is what makes them safe start nodes and impossible
|
|
2690
|
+
mid-path nodes.
|
|
2691
|
+
|
|
2692
|
+
**One observation, deliberately NOT a change request.** `edgesFrom`/
|
|
2693
|
+
`edgesTo` each call `this._build()` twice per invocation, and `nodes()`/
|
|
2694
|
+
`edges()` copy the whole map on every call. At `maxExpansions` scale
|
|
2695
|
+
(10^4 `edgesTo` calls, each an O(1) cached `_build()` plus an O(degree)
|
|
2696
|
+
map) this is immaterial, and §14.6 already names the real profiling target
|
|
2697
|
+
(the derived `context` key strings) for Sub-project E. Recorded so a later
|
|
2698
|
+
reader knows it was looked at and judged, not missed.
|
|
2699
|
+
|
|
2700
|
+
### 15.10 What the follow-up implementation task must do
|
|
2701
|
+
|
|
2702
|
+
Written the way §10.1/§13.7/§14.10 were, so the next brief needs no
|
|
2703
|
+
re-derivation.
|
|
2704
|
+
|
|
2705
|
+
**`scanner/src/lineage/ids.js`**
|
|
2706
|
+
|
|
2707
|
+
| # | Site | Change |
|
|
2708
|
+
|---|---|---|
|
|
2709
|
+
| 1 | after `provenanceEdgeId` | add `pathId({ startNodeId, edgeIds }, discriminatorParts = [])` → `ppath:<12 hex>`, via the existing `_hash`/`_canon`. Object argument, matching the `provenanceNodeId`/`provenanceEdgeId` precedent (§14.5) |
|
|
2710
|
+
| 2 | `test/lineage/ids.test.js` | extend with `pathId` idempotence + non-collision (a changed edge id anywhere in the sequence, and a reordered sequence, must both move the id) |
|
|
2711
|
+
| 3 | — | **no change to `validate.js`.** `ppath:` is not a `DataFlowGraph v1` entity kind. Confirm by running `npm run test:lineage`; `json-schema-parity.test.js` must stay green untouched |
|
|
2712
|
+
|
|
2713
|
+
**`scanner/src/lineage/path-query.js` (new)**
|
|
2714
|
+
|
|
2715
|
+
| # | Item | Detail |
|
|
2716
|
+
|---|---|---|
|
|
2717
|
+
| 4 | imports | `ids.js` ONLY. Never `engine.js`/`summaries.js`/`driver.js`, and never a `_`-prefixed `PathStore` field. Add the same import-list self-check test `path-store.test.js`'s boundary test uses (§14.10 item 5), plus the `store\._` source scan the PoC's `C5/6` already carries |
|
|
2718
|
+
| 5 | `DEFAULTS` | `{ maxPaths: 32, maxPathsPerTerminal: 8, maxCandidatePaths: 256, maxExpansions: 10000, maxDepth: 64 }`, all `opts`-overridable. Document them as uncalibrated (§15.3) |
|
|
2719
|
+
| 6 | `reconstructPaths(store, startNodeId, opts)` | iterative DFS over an explicit stack — **no recursion**. Per-path visited set. `edgesTo` sorted by edge id. Zero-in-edges check BEFORE the depth check (§15.3) |
|
|
2720
|
+
| 7 | terminal classification | `'origin'` / `'incomplete-record'` (node in `diagnostics().orphanedPeerSources`) / `'cycle'` (every continuation clipped) / `'depth-limit'`. `'expansion-budget'`/`'candidate-cap'` are result-level only — those branches are abandoned, never emitted (§15.4) |
|
|
2721
|
+
| 8 | the cap | per `terminal.nodeId` first (`maxPathsPerTerminal`), then a diversity-first round-robin over `(terminal.nodeId, shape)` buckets for `maxPaths` (§15.5/§15.7). `result.terminals[]` per §15.5 |
|
|
2722
|
+
| 9 | the result shape | exactly §15.4's table, plus `startNodeId`/`startNodeKind`/`enumeratedPathCount`/`returnedPathCount`/`droppedPathCount`/`completePathCount`/`cyclesClipped`/`analysisTruncated`/`terminals[]`/`budget.expansionsUsed`. `noPathReason` must be computed ONLY when `truncated === false` — that ordering IS §18.4's constraint, and `terminals[].truncated`/`droppedPathCount` must be computed AFTER the global cap, never from the per-terminal cap alone (§15.5). **`completePathCount` is scoped to the full ENUMERATION (every complete path found before any cap is applied), same as `enumeratedPathCount`/`droppedPathCount` — NOT to `result.paths` after capping.** Final whole-branch review finding 5: it can therefore legitimately exceed `result.paths.length`; a caller wanting "how many complete paths are actually IN this response" must compute `result.paths.filter(p => p.complete).length` itself, never read `completePathCount` for that. |
|
|
2723
|
+
| 10 | `sinkCandidates(store)` | §15.9's filter, with the "not a registry" doc comment |
|
|
2724
|
+
| 11 | `isIncompleteAnswer(result)` | §15.4's five-term predicate, exported so AC-10's banner has one owner |
|
|
2725
|
+
| 11b | `comparePaths(a, b)` | §15.7's total order, **also exported**. It is not merely internal: the tests call it directly to build the naive-global-cap contrast (`C5/5f`), so items 12/13 below are unsatisfiable without it. Added by fix round 1 (finding 6) |
|
|
2726
|
+
|
|
2727
|
+
**Tests**
|
|
2728
|
+
|
|
2729
|
+
| # | Change |
|
|
2730
|
+
|---|---|
|
|
2731
|
+
| 12 | Re-point `path-query-poc.test.js` at the shipped `path-query.js`/`ids.js`, delete its local prototype block, rename it to `path-query.test.js`, and update the `test:lineage` script in `scanner/package.json` in the SAME commit — C3's item 15 / C4's item 11 precedent |
|
|
2732
|
+
| 13 | Keep every assertion, and especially keep `C5/4b` (the three empty-looking results as literal JSON) and `C5/4c` (`incomplete-record`) — together they are the only guard that stops §18.4's constraint being silently undone |
|
|
2733
|
+
| 14 | Keep `C5/M`'s measured-numbers table asserted against §15.11's published rows, so a refactor that changes a published number fails a test rather than leaving this document stale (C4's `C4/1b`/`C4/4` precedent). **The trade-off, stated rather than discovered later:** these numbers are a property of the current parser/IR/engine as much as of `path-query.js`, so an unrelated IR or engine change CAN fail this test without anything being wrong with reconstruction. That is the intended cost — the same one §14.11's own pinned counts carry — and the correct response is to re-measure and update §15.11's table in the same commit, never to relax the assertion. The `C5_PRINT_TABLE` env var prints the freshly measured rows to make that re-measurement one command; it must never be allowed to SKIP the assertion (fix round 1, finding 7) |
|
|
2734
|
+
| 15 | Add a driver-level test only once a hop-emitting driver run is possible (Sub-projects D/E) — until then it would assert on an empty store and be vacuous, same reasoning as §14.10 item 13 |
|
|
2735
|
+
|
|
2736
|
+
**Deliberately NOT in the follow-up's scope:** everything in §15.8, plus
|
|
2737
|
+
any change to `path-store.js`, `engine.js`, `summaries.js` or `driver.js`
|
|
2738
|
+
(C5 consumes the C4 DAG exactly as shipped — no store change is needed to
|
|
2739
|
+
answer Q3-Q7).
|
|
2740
|
+
|
|
2741
|
+
### 15.11 Measured numbers
|
|
2742
|
+
|
|
2743
|
+
Every row produced by running the PoC's own fixtures through the prototype
|
|
2744
|
+
on 2026-08-30 (`C5/M`), with the three OUTPUT caps (`maxPaths`,
|
|
2745
|
+
`maxPathsPerTerminal`, `maxCandidatePaths`) raised to 10^6 and the work
|
|
2746
|
+
budgets left at their defaults, so the numbers describe the graph rather
|
|
2747
|
+
than a cap. Every row asserts `truncated: false`, which is what proves no
|
|
2748
|
+
budget bound it. "sinks" is `sinkCandidates()`'s count; the walk is run once
|
|
2749
|
+
per sink candidate and the remaining columns are summed across them.
|
|
2750
|
+
"expansions" is in-edges examined; "clipped" is per-path visited-set
|
|
2751
|
+
rejections (i.e. cycle encounters).
|
|
2752
|
+
|
|
2753
|
+
| fixture | sinks | paths | complete | partial | max hops | expansions | clipped |
|
|
2754
|
+
|---|---|---|---|---|---|---|---|
|
|
2755
|
+
| `const b = a.email; return b;` | 1 | 1 | 1 | 0 | 2 | 2 | 0 |
|
|
2756
|
+
| §6's worked example (2 fields, object literal) | 2 | 2 | 2 | 0 | 3 | 6 | 0 |
|
|
2757
|
+
| 2-function resolved call | 2 | 3 | 3 | 0 | 4 | 7 | 0 |
|
|
2758
|
+
| §9.1's cross-join (`{a: p.email, b: q.email}`) | 1 | 4 | 4 | 0 | 2 | 6 | 0 |
|
|
2759
|
+
| §14.7's leg counter-example (`{r: helper(a), s: b.email}`) | 3 | 11 | 11 | 0 | 4 | 18 | 0 |
|
|
2760
|
+
| mutual recursion (`ping`/`pong`/`top`) — cyclic | 4 | 13 | 9 | 4 | 6 | 35 | 7 |
|
|
2761
|
+
|
|
2762
|
+
Three rows to read closely:
|
|
2763
|
+
|
|
2764
|
+
- **§6's worked example** reconstructs into exactly the **two field-distinct
|
|
2765
|
+
three-hop paths** §14.11 predicted from its 8 nodes / 6 edges —
|
|
2766
|
+
`user.email → u.email → o.email → ⟨return⟩` and the same for `.ssn`, with
|
|
2767
|
+
zero truncation and zero cross-contamination. §14.11 proved the structure
|
|
2768
|
+
was there; this row is the structure actually being read back out. That
|
|
2769
|
+
is FR-303's *"ordered paths can be reconstructed"* half, measured.
|
|
2770
|
+
- **The 2-function resolved call**'s 3 paths split 1 + 2 across its two
|
|
2771
|
+
sink candidates: `helper`'s own exit node yields the single path
|
|
2772
|
+
`u.email → ⟨return helper⟩`, and `caller`'s exit node yields **two** —
|
|
2773
|
+
the real 4-hop through-the-callee chain
|
|
2774
|
+
(`a.email → u.email → ⟨return helper⟩ → out → ⟨return caller⟩`) and
|
|
2775
|
+
§14.7's disclosed 2-hop **bypass** (`a.email → out → ⟨return caller⟩`),
|
|
2776
|
+
which skips the callee. The bypass is kept, marked
|
|
2777
|
+
`ambiguousCorrelation`, and ranked **last** (`C5/2`) — the shipped design
|
|
2778
|
+
neither deletes it nor lets it outrank the real chain.
|
|
2779
|
+
- **Mutual recursion** is the only row with partial paths: 4 of its 13 are
|
|
2780
|
+
`'cycle'`-terminated. They are labelled, not dropped, and not disguised
|
|
2781
|
+
as origins. 35 expansions and 7 clips on an 8-node/11-edge cyclic graph
|
|
2782
|
+
is the whole termination story, and no budget was involved in it.
|
|
2783
|
+
|
|
2784
|
+
---
|
|
2785
|
+
|
|
2786
|
+
## 16. FR-306 edge grading (Sub-project C, increment 6)
|
|
2787
|
+
|
|
2788
|
+
**Implemented.** Design landed as `dcd06001` (fix round `120fb8c9`, small
|
|
2789
|
+
follow-up `79c01c13`); shipped by Task 2 (`c63c6338`), whole-branch reviewed
|
|
2790
|
+
clean. **This is the final increment of Sub-project C — C1-C6 are all
|
|
2791
|
+
complete as of this commit.** The section below is kept in its original,
|
|
2792
|
+
design-time voice (present/future tense, "committed alongside this
|
|
2793
|
+
section", "the follow-up implementation task must…") as the historical
|
|
2794
|
+
record of what was decided and why — read `flow-grade.js`/`path-query.js`
|
|
2795
|
+
themselves, or `scanner/src/lineage/CLAUDE.md`'s own module-table rows, for
|
|
2796
|
+
the current, as-shipped description. The design-time PoC file this section
|
|
2797
|
+
originally shipped alongside it (`flow-grade-poc.test.js`) no longer
|
|
2798
|
+
exists — Task 2 absorbed everything it proved into the permanent suite
|
|
2799
|
+
(`flow-grade.test.js`) and deleted it, per §16.8 item 11.
|
|
2800
|
+
|
|
2801
|
+
**§16.7 Finding 1, specifically, is now CLOSED, not merely disclosed** —
|
|
2802
|
+
this section's own design task found a real, measured undercount bug in
|
|
2803
|
+
`path-query.js`'s already-shipped `materialize()` (from the already-merged
|
|
2804
|
+
increment C5), initially mis-estimated the cost of fixing it, corrected
|
|
2805
|
+
that estimate after independent verification (twice, by the design's own
|
|
2806
|
+
fix round and a separate scoped re-review) found the fix breaks nothing,
|
|
2807
|
+
and Task 2 shipped the fix (four lines, confined to `materialize()`,
|
|
2808
|
+
verified a fourth time — by the final whole-branch review — to change
|
|
2809
|
+
exactly zero other assertions in the tree). §16.7's own heading below
|
|
2810
|
+
("Findings this increment does NOT fix, named rather than patched") is
|
|
2811
|
+
accurate for Findings 2 and 3, which remain genuinely open and are made
|
|
2812
|
+
binding on Sub-project D — but Finding 1 no longer belongs under that
|
|
2813
|
+
heading in spirit, only in the historical record's own voice; read the
|
|
2814
|
+
**CORRECTED** box inside §16.7 Finding 1 for the full before/after account.
|
|
2815
|
+
|
|
2816
|
+
Added 2026-08-30 by increment C6's design task. Everything in this section
|
|
2817
|
+
is **decided**, not proposed, and every behavioural claim and every number
|
|
2818
|
+
in it was produced by running code in
|
|
2819
|
+
`scanner/test/lineage/flow-grade-poc.test.js` — a throwaway-named PoC
|
|
2820
|
+
committed alongside this section, which prototypes `flow-grade.js` LOCALLY
|
|
2821
|
+
(shipped source under `src/lineage/` is unmodified by this design task,
|
|
2822
|
+
exactly as C1's, C3's, C4's and C5's own design tasks were). §16.8 is the
|
|
2823
|
+
follow-up implementation task's file/line checklist.
|
|
2824
|
+
|
|
2825
|
+
**FR-306, verbatim:** *"Implicit/control-dependent and unknown-field
|
|
2826
|
+
widened flows must be visually distinct and lower-confidence. They may not
|
|
2827
|
+
be displayed as the same evidence grade as an explicit field assignment."*
|
|
2828
|
+
|
|
2829
|
+
> **Fix round 1 (2026-08-30), from this increment's own task review.** One
|
|
2830
|
+
> BLOCKING defect and three refinements were found in §16's first draft and
|
|
2831
|
+
> are corrected in place, each marked where it applies — recorded here
|
|
2832
|
+
> rather than silently rewritten, per this document's own policy (§13's
|
|
2833
|
+
> own fix-round box set the precedent). The BLOCKING one is worth stating
|
|
2834
|
+
> at the top because it is the same failure mode this document exists to
|
|
2835
|
+
> prevent: **§16.7 Finding 1 deferred a fix on a cost estimate that was
|
|
2836
|
+
> never measured, and the estimate was false.** The reviewer ran the
|
|
2837
|
+
> four-line fix and found it breaks nothing; the deferral survived, the
|
|
2838
|
+
> false reason did not, and §16.8 now *schedules* the remediation instead
|
|
2839
|
+
> of recording "`path-query.js`: none". Also corrected: `gradePath` now
|
|
2840
|
+
> returns full `HopGrade` objects rather than bare strings (§16.4);
|
|
2841
|
+
> §16.7 Finding 2 is restated as the §18.4 re-opening it actually is,
|
|
2842
|
+
> with a binding hand-off to Sub-project D's scoping; and §16.10 states
|
|
2843
|
+
> the explicitness-vs-completeness asymmetry as a trade-off.
|
|
2844
|
+
|
|
2845
|
+
§15.8 drew the boundary this section crosses: *"No FR-306 grade
|
|
2846
|
+
computation. C5's path output CARRIES every grading input … and computes
|
|
2847
|
+
only counts. Turning counts into a grade is C6."* Five questions were open
|
|
2848
|
+
when this increment was scoped, and none is answerable on paper. All five
|
|
2849
|
+
are now answered by execution:
|
|
2850
|
+
|
|
2851
|
+
- **Q8 — how many tiers, and separated by what?** Five, plus an
|
|
2852
|
+
empty-input answer: `explicit` > `widened` > `implicit` (reserved) >
|
|
2853
|
+
`severed` > `ambiguous`. §16.2/§16.3.
|
|
2854
|
+
- **Q9 — does crossing a function boundary lower a hop's grade?** **No.**
|
|
2855
|
+
A sound interprocedural stitch grades identically to the same flow
|
|
2856
|
+
inlined, and a cross-scope demotion would have *inverted* the ranking
|
|
2857
|
+
C5's own `comparePaths` already ships. §16.6.
|
|
2858
|
+
- **Q10 — does `annotations[]` factor into the grade?** **Yes, and this is
|
|
2859
|
+
the single most load-bearing decision in the section.** A genuine
|
|
2860
|
+
widening lives ONLY in `annotations[]` on three real, separately-parsed
|
|
2861
|
+
fixtures — not just §13.6's context-cap marker. A grader reading only
|
|
2862
|
+
the edge's top-level arrays grades those flows `explicit`, which is
|
|
2863
|
+
FR-306's own literal prohibition. §16.5.
|
|
2864
|
+
- **Q11 — is a per-path aggregate needed, or is per-hop grading enough?**
|
|
2865
|
+
**Both are needed**, and FR-306's two clauses are why. §16.4.
|
|
2866
|
+
- **Q12 — reuse `protection.js`'s `EVIDENCE_GRADES`?** **No**, and the
|
|
2867
|
+
reason is a correctness hazard rather than taste. §16.2.
|
|
2868
|
+
|
|
2869
|
+
### 16.1 `flow-grade.js`: a new module, not new exports on `path-query.js`
|
|
2870
|
+
|
|
2871
|
+
**Decided:** a new file, `scanner/src/lineage/flow-grade.js`, with **zero
|
|
2872
|
+
imports** — one step stricter than `path-query.js`'s own `['./ids.js']`
|
|
2873
|
+
boundary, and it inherits §14.1/§15.1's rule unchanged (never
|
|
2874
|
+
`engine.js`/`summaries.js`/`driver.js`).
|
|
2875
|
+
|
|
2876
|
+
The obvious alternative — `gradeHop`/`gradePath` exported from
|
|
2877
|
+
`path-query.js` itself, since they consume nothing it does not already
|
|
2878
|
+
produce — was evaluated and rejected on three grounds, one of which is
|
|
2879
|
+
measured:
|
|
2880
|
+
|
|
2881
|
+
- **Grading needs neither a path nor the store.** `gradeHop` returns
|
|
2882
|
+
**byte-identical** results for a raw `PathStore` **edge** and for the
|
|
2883
|
+
`Hop` denormalized from it (`C6/11`, over every edge of the 2-function
|
|
2884
|
+
resolved-call fixture) — because a hop *is* a denormalized copy of its
|
|
2885
|
+
edge (§15.2). So Sub-project E's graph builder and Milestone 3's API can
|
|
2886
|
+
grade an edge without running a DFS, and Milestone 4 can grade a stored
|
|
2887
|
+
edge with no reconstruction at all. Putting the function inside the
|
|
2888
|
+
reconstruction module would make every such consumer import the walk.
|
|
2889
|
+
- **`path-query.js`'s shipped boundary test asserts its import list is
|
|
2890
|
+
EXACTLY `['./ids.js']`.** A separate grading module could not be
|
|
2891
|
+
imported by it without weakening that test, and this increment
|
|
2892
|
+
deliberately does not weaken it.
|
|
2893
|
+
- **C6's implementation then touches zero existing `src/lineage/*.js`
|
|
2894
|
+
files** — the same property C4's and C5's design tasks preserved.
|
|
2895
|
+
|
|
2896
|
+
**Exports** (the exact signatures the follow-up task must ship):
|
|
2897
|
+
|
|
2898
|
+
```js
|
|
2899
|
+
export const FLOW_EVIDENCE_GRADES; // frozen, confidence order
|
|
2900
|
+
export const IMPLICIT_FLOW_REASONS; // frozen, ['control-dependence']
|
|
2901
|
+
export const DEGRADED_LOSS_REASONS; // frozen, ['context-cap-degraded']
|
|
2902
|
+
export function flowGradeRank(grade); // -> 0..5, throws on unrecognized
|
|
2903
|
+
export function aggregateFlowGrades(grades); // -> grade, mirrors aggregateVerdicts
|
|
2904
|
+
export function gradeHop(hop); // -> HopGrade (§16.3)
|
|
2905
|
+
export function gradePath(path); // -> PathGrade (§16.4)
|
|
2906
|
+
```
|
|
2907
|
+
|
|
2908
|
+
`gradeHop` accepts a `path-query.js` `Hop` **or** a `path-store.js` edge;
|
|
2909
|
+
`gradePath` accepts a `path-query.js` `Path`. Neither mutates its input,
|
|
2910
|
+
and neither reads a `_`-prefixed field of anything.
|
|
2911
|
+
|
|
2912
|
+
### 16.2 Q12 — a NEW vocabulary, and why `EVIDENCE_GRADES` is the wrong one
|
|
2913
|
+
|
|
2914
|
+
```js
|
|
2915
|
+
export const FLOW_EVIDENCE_GRADES = Object.freeze([
|
|
2916
|
+
'explicit', 'widened', 'implicit', 'severed', 'ambiguous', 'unassessed',
|
|
2917
|
+
]); // CONFIDENCE order: index 0 is the most confident
|
|
2918
|
+
```
|
|
2919
|
+
|
|
2920
|
+
**`protection.js`'s `EVIDENCE_GRADES` (`['runtime', 'code_and_config',
|
|
2921
|
+
'code', 'config', 'declared', 'manual', 'none']`) is deliberately NOT
|
|
2922
|
+
reused or extended.** It was read in full before this was decided, and the
|
|
2923
|
+
rejection is on substance, not on convenience:
|
|
2924
|
+
|
|
2925
|
+
- **It grades a different axis.** Those values name *where a protection
|
|
2926
|
+
verdict's evidence came from* — observed at runtime, read out of code,
|
|
2927
|
+
read out of configuration, merely declared. Every flow grade in this
|
|
2928
|
+
section comes from **the same** evidence source (static field-identity
|
|
2929
|
+
analysis of code); what varies is how *explicit* the recorded data
|
|
2930
|
+
movement is. Mapping `widened` onto `declared`, or `explicit` onto
|
|
2931
|
+
`code`, would be a category error that silently changes what
|
|
2932
|
+
`protection.js` means.
|
|
2933
|
+
- **Extending it would be a live correctness hazard, not just churn.**
|
|
2934
|
+
`EVIDENCE_GRADES` is consumed by `protection.js`'s own
|
|
2935
|
+
`isValidProtectionDimension` and, through this package's stated "every
|
|
2936
|
+
enum here is a single source of truth" convention, by
|
|
2937
|
+
`dataflow-graph.schema.json` and `validate.js`. Adding `widened` to it
|
|
2938
|
+
would make a flow grade **validate cleanly as a protection evidence
|
|
2939
|
+
grade** on a `DataFlowGraph v1` entity. That is exactly the
|
|
2940
|
+
indistinguishable-namespace bug §14.5 rejected `node:`/`edge:` prefixes
|
|
2941
|
+
for.
|
|
2942
|
+
- **A separate vocabulary touches none of those files this increment**,
|
|
2943
|
+
which is a factor in its favour rather than an afterthought — see
|
|
2944
|
+
§16.10 for what Sub-project E must do when a grade *does* reach a
|
|
2945
|
+
`DataFlowGraph v1` entity.
|
|
2946
|
+
|
|
2947
|
+
`C6/0` asserts the two enums share **no** value, that the ranks are a
|
|
2948
|
+
total order, and — the check that actually stops a future edit going
|
|
2949
|
+
wrong — that the aggregation table (§16.4) is a permutation of the value
|
|
2950
|
+
list, so no grade can be silently missing from either side.
|
|
2951
|
+
|
|
2952
|
+
**What each value means:**
|
|
2953
|
+
|
|
2954
|
+
| grade | meaning | produced by |
|
|
2955
|
+
|---|---|---|
|
|
2956
|
+
| `explicit` | the engine resolved a real, field-precise data movement exactly | a hop with no widen/loss/implicit reason and `ambiguousCorrelation: false` |
|
|
2957
|
+
| `widened` | the movement is real but over-approximated — FR-306's "unknown-field widened" | any `widenReason` (`dynamic-property-key`, `unresolved-call`), top-level **or** annotation-carried |
|
|
2958
|
+
| `implicit` | control-dependent, not a data assignment — FR-306's "implicit/control-dependent" | **RESERVED. Nothing emits it today** (§16.3) |
|
|
2959
|
+
| `severed` | the trail is honestly recorded as stopping here | any `lossReason` (`unsupported-target`, `context-cap-degraded`), top-level **or** annotation-carried |
|
|
2960
|
+
| `ambiguous` | the engine cannot confirm this specific pairing happened at all | `ambiguousCorrelation: true` (§9.1/§14.7) |
|
|
2961
|
+
| `unassessed` | there was nothing to grade | `aggregateFlowGrades([])` only — never a real hop |
|
|
2962
|
+
|
|
2963
|
+
**Why `ambiguous` is the lowest tier**, below both `widened` and
|
|
2964
|
+
`severed`: a widened hop *certainly happened* and is merely imprecise; a
|
|
2965
|
+
severed hop *certainly happened* and its continuation is unrepresented; an
|
|
2966
|
+
ambiguous hop **may never have happened at all** — §14.7's own resolved-call
|
|
2967
|
+
bypass is exactly such an edge, real data flow that is not the route the
|
|
2968
|
+
program takes. This is also the order C5 already shipped: §15.7's
|
|
2969
|
+
`comparePaths` keys are `ambiguousHopCount`, then `lossHopCount`, then
|
|
2970
|
+
`widenedHopCount`, and §15.7's own prose calls the latter two "FR-306's
|
|
2971
|
+
lower-confidence grades, in the order a reader would rank them". C6 adopts
|
|
2972
|
+
that order rather than inventing a competing one.
|
|
2973
|
+
|
|
2974
|
+
**Why `implicit` sits between `widened` and `severed`:** it is FR-306's
|
|
2975
|
+
other named category, so it belongs adjacent to `widened`; and it is
|
|
2976
|
+
weaker evidence than a widened data assignment, because no data movement
|
|
2977
|
+
was observed at all — only an inference from control.
|
|
2978
|
+
|
|
2979
|
+
### 16.3 The per-hop rule
|
|
2980
|
+
|
|
2981
|
+
`gradeHop(hop)` returns an **object**, never a bare string — see §16.5 for
|
|
2982
|
+
why that is structural rather than stylistic:
|
|
2983
|
+
|
|
2984
|
+
```js
|
|
2985
|
+
{
|
|
2986
|
+
grade, // one of FLOW_EVIDENCE_GRADES, never 'unassessed'
|
|
2987
|
+
rank, // flowGradeRank(grade); 0 = most confident
|
|
2988
|
+
factors, // sorted, deduped: 'widen:<r>' | 'loss:<r>' |
|
|
2989
|
+
// 'implicit:<r>' | 'ambiguous-correlation' |
|
|
2990
|
+
// 'analysis-truncated' | 'cross-scope'
|
|
2991
|
+
widenReasons, // sorted UNION of top-level + annotations[]
|
|
2992
|
+
lossReasons, // sorted UNION of top-level + annotations[]
|
|
2993
|
+
implicitReasons, // the IMPLICIT_FLOW_REASONS subset, removed from the two above
|
|
2994
|
+
annotationOnly, // the reasons that would have been INVISIBLE to a
|
|
2995
|
+
// top-level-only reader — see §16.5
|
|
2996
|
+
ambiguousCorrelation, degraded, truncated, crossScope,
|
|
2997
|
+
incomplete, // grade === 'severed' || degraded || truncated
|
|
2998
|
+
}
|
|
2999
|
+
```
|
|
3000
|
+
|
|
3001
|
+
**Precedence, worst wins within one hop** (a hop can carry several
|
|
3002
|
+
signals at once — `C6/2b` measures a real hop with two widen reasons):
|
|
3003
|
+
|
|
3004
|
+
1. `ambiguousCorrelation === true` → `ambiguous`
|
|
3005
|
+
2. any non-implicit `lossReason` → `severed`
|
|
3006
|
+
3. any `IMPLICIT_FLOW_REASONS` reason → `implicit`
|
|
3007
|
+
4. any non-implicit `widenReason` → `widened`
|
|
3008
|
+
5. otherwise → `explicit`
|
|
3009
|
+
|
|
3010
|
+
**`implicit` is reserved and currently unreachable, deliberately.**
|
|
3011
|
+
§10.2's own verdict for `if` is explicit: *"the engine models no implicit
|
|
3012
|
+
flow today, so there is nothing to emit; do not invent one."* The tier
|
|
3013
|
+
exists because FR-306 names it first, and it is kept-but-hand-tested on
|
|
3014
|
+
exactly §14.2's `origin`-node-kind precedent (*"a real, honestly-disclosed
|
|
3015
|
+
gap, but not a dead branch: it is the exact shape a Sub-project D … will
|
|
3016
|
+
produce"*). Its trigger is a reason string in the exported
|
|
3017
|
+
`IMPLICIT_FLOW_REASONS` set (today `['control-dependence']`), which is
|
|
3018
|
+
also **removed** from `widenReasons` so it can never double-count.
|
|
3019
|
+
`C6/10` proves both halves: no hop of five real fixtures grades
|
|
3020
|
+
`implicit`, and a hand-built control-dependence hop does.
|
|
3021
|
+
|
|
3022
|
+
> **The subtraction applies to the WIDEN side ONLY** (fix round 1,
|
|
3023
|
+
> nitpick 8). Subtracting `IMPLICIT_FLOW_REASONS` from `lossReasons` too
|
|
3024
|
+
> would mean that if a future increment ever emitted
|
|
3025
|
+
> `lossReason: 'control-dependence'`, that hop would be silently
|
|
3026
|
+
> **upgraded** from `severed` to the *more confident* `implicit` tier —
|
|
3027
|
+
> a grade moving in the optimistic direction as a side effect of a reason
|
|
3028
|
+
> string being added somewhere else. Unreachable today (no loss reason is
|
|
3029
|
+
> in the set), and now unreachable by construction rather than by luck.
|
|
3030
|
+
> A control-dependent hop that is also a recorded dead end grades
|
|
3031
|
+
> `severed`, which is the safe direction.
|
|
3032
|
+
|
|
3033
|
+
**`crossScope`, `originated` and `truncated` are never grade inputs.**
|
|
3034
|
+
`crossScope` is §16.6; `truncated` (§14.8's `markTruncated`) is an
|
|
3035
|
+
analysis-run fact, not a statement about this hop's explicitness, so it is
|
|
3036
|
+
a flag and a factor only — `C6/9` marks a clean fixture truncated and
|
|
3037
|
+
asserts the grade stays `explicit` while `truncated`/`incomplete` flip to
|
|
3038
|
+
`true`.
|
|
3039
|
+
|
|
3040
|
+
### 16.4 Q11 — the per-path aggregate is worst-wins, and per-hop alone is NOT enough
|
|
3041
|
+
|
|
3042
|
+
**Decided: both, and FR-306's two clauses are the reason.** The sentence
|
|
3043
|
+
has two halves and they are different requirements:
|
|
3044
|
+
|
|
3045
|
+
- *"must be visually distinct"* — a **per-hop** claim. A UI marks the
|
|
3046
|
+
widened hop, not the whole path; if the whole path were graded down
|
|
3047
|
+
uniformly, the reader could not see which step is the weak one.
|
|
3048
|
+
`gradePath` therefore returns `hops[]` and `worstHopIndex`.
|
|
3049
|
+
- *"may not be displayed as the same evidence grade as an explicit field
|
|
3050
|
+
assignment"* — a claim about **the evidence grade a flow is displayed
|
|
3051
|
+
at**, i.e. one scalar per path. With per-hop grading alone there is no
|
|
3052
|
+
such scalar, so nothing prevents a UI from displaying a path containing
|
|
3053
|
+
a widened hop as an explicit flow.
|
|
3054
|
+
|
|
3055
|
+
**The aggregate is the WORST grade among the hops**, mirroring
|
|
3056
|
+
`protection.js`'s `aggregateVerdicts()` risk-precedence reduction — the
|
|
3057
|
+
established precedent in this exact package — via a private precedence
|
|
3058
|
+
table that is the reverse of the confidence order, with `unassessed` last
|
|
3059
|
+
so it survives only when it is alone:
|
|
3060
|
+
|
|
3061
|
+
```js
|
|
3062
|
+
const _PRECEDENCE = ['ambiguous', 'severed', 'implicit', 'widened', 'explicit', 'unassessed'];
|
|
3063
|
+
```
|
|
3064
|
+
|
|
3065
|
+
`aggregateFlowGrades` copies `aggregateVerdicts`' contract verbatim,
|
|
3066
|
+
including its refusals: an empty array is `'unassessed'`, and an
|
|
3067
|
+
**unrecognized grade throws** rather than silently sorting last (`C6/7c`
|
|
3068
|
+
— a typo, or a `protection.js` value handed in by mistake, must not
|
|
3069
|
+
quietly rank as safest).
|
|
3070
|
+
|
|
3071
|
+
**Every other reduction was executed, not argued** (`C6/7`, on
|
|
3072
|
+
`const a = user.email; const b = mystery(a); return b;` — hop grades
|
|
3073
|
+
`['explicit', 'widened', 'explicit']`): first-wins, last-wins, best-wins
|
|
3074
|
+
and majority-wins all report `explicit` for a path that provably contains
|
|
3075
|
+
a widened hop. Only worst-wins satisfies FR-306. `C6/7b` additionally
|
|
3076
|
+
proves the reduction is **order-independent** across all 5×5 tier pairs —
|
|
3077
|
+
an order-dependent aggregate would be the representative-picking bug class
|
|
3078
|
+
C4 found in `path-store.js`'s `origin` branch and C5 found in
|
|
3079
|
+
`terminals[].terminalReasons`, for the third time.
|
|
3080
|
+
|
|
3081
|
+
**`gradePath` returns the FULL `HopGrade` objects, not bare grade
|
|
3082
|
+
strings** (fix round 1, finding 3). The first draft returned
|
|
3083
|
+
`hopGrades: string[]` plus a path-level `factors` union, which loses
|
|
3084
|
+
per-hop *cause*: a caller rendering FR-306's "visually distinct" half
|
|
3085
|
+
would have had to re-invoke `gradeHop` on every hop to learn *which*
|
|
3086
|
+
factor drove *that* hop's grade, re-deriving what `gradePath` had just
|
|
3087
|
+
computed and thrown away. Settled now rather than after a follow-up task
|
|
3088
|
+
has implemented against the narrower shape.
|
|
3089
|
+
|
|
3090
|
+
> **The trade-off, stated rather than left implicit.** A `PathGrade` now
|
|
3091
|
+
> carries N objects instead of N strings, which is strictly more memory
|
|
3092
|
+
> per graded path. This is the same trade §15.2 already made deliberately
|
|
3093
|
+
> one layer down — carrying a denormalized copy of each edge on the path
|
|
3094
|
+
> rather than an edge id — for the same reason: *"a path that cannot be
|
|
3095
|
+
> graded without also carrying the store is a poor hand-off."* A path
|
|
3096
|
+
> grade that cannot be **rendered** without re-grading every hop is the
|
|
3097
|
+
> same mistake one layer up. The rejected middle option, a parallel
|
|
3098
|
+
> `hopFactors[]` array alongside `hopGrades[]`, is exactly the
|
|
3099
|
+
> redundant-fields-drift shape §3 rejected `widened` for. A caller wanting
|
|
3100
|
+
> only the strings writes `g.hops.map((h) => h.grade)`.
|
|
3101
|
+
|
|
3102
|
+
> **`rank` is NOT a monotone image of `_PRECEDENCE`, and no consumer
|
|
3103
|
+
> should derive one from the other** (fix round 1, nitpick 7). For the
|
|
3104
|
+
> five real grades they are exact reverses, but `'unassessed'` is last in
|
|
3105
|
+
> **both** — so `flowGradeRank('unassessed')` is 5 (least confident) while
|
|
3106
|
+
> its aggregation precedence is also 5 (loses to everything). That is
|
|
3107
|
+
> deliberate, and it is the same asymmetry `protection.js` carries between
|
|
3108
|
+
> `EVIDENCE_GRADES` and its own `_PRECEDENCE`. It is unreachable through a
|
|
3109
|
+
> real `Path` today — `path-query.js` guarantees `hopCount >= 1` (§15.6),
|
|
3110
|
+
> so `gradePath` never sees an empty hop list — but a consumer that
|
|
3111
|
+
> computed one table from the other would get `'unassessed'` wrong the
|
|
3112
|
+
> first time it *was* reachable.
|
|
3113
|
+
|
|
3114
|
+
`gradePath` also **recomputes** its counts from `gradeHop` and
|
|
3115
|
+
deliberately does **not** read the Path's own
|
|
3116
|
+
`widenedHopCount`/`lossHopCount`/`ambiguousHopCount` — see §16.7 Finding 1.
|
|
3117
|
+
|
|
3118
|
+
### 16.5 Q10 — `annotations[]` is folded into the grade AND flagged separately
|
|
3119
|
+
|
|
3120
|
+
§14.9's own corrected note warned that a C6 implementer reading only
|
|
3121
|
+
`widenReasons`/`lossReasons` off the edges would silently drop §13.6's
|
|
3122
|
+
context-cap-degraded marker. **Measured, the problem is larger than that
|
|
3123
|
+
warning states**, and it is what forced this decision:
|
|
3124
|
+
|
|
3125
|
+
> **`C6/5` — a genuine `widenReason: 'unresolved-call'` lives ONLY in
|
|
3126
|
+
> `annotations[]`, with the edge's own `widenReasons` EMPTY, on three
|
|
3127
|
+
> real, separately-parsed fixtures:**
|
|
3128
|
+
>
|
|
3129
|
+
> ```js
|
|
3130
|
+
> function f(user) { sink(mystery(user.email)); } // member -> call-arg
|
|
3131
|
+
> function f(user) { const o = { a: mystery(user.email) }; return o; } // member -> assign
|
|
3132
|
+
> function f(user, c) { const o = c ? mystery(user.email) : user.email; return o; }
|
|
3133
|
+
> ```
|
|
3134
|
+
>
|
|
3135
|
+
> In each, the widening is produced by an expression-internal construct
|
|
3136
|
+
> whose hop §2.2 classifies as an **annotation** (null `fromPath`, null
|
|
3137
|
+
> `peerScope`), so `path-store.js` never folds its reason into
|
|
3138
|
+
> `edge.widenReasons` — that array is built from the *edge-forming* halves
|
|
3139
|
+
> (`s.hop.widenReason`, `o.hop.widenReason`) in every case that occurs
|
|
3140
|
+
> here. **One narrow exception, for accuracy** (fix round 1, nitpick 6):
|
|
3141
|
+
> `path-store.js`'s `origin` branch *does* union the annotations' own
|
|
3142
|
+
> reasons into `originWidenReasons`/`originLossReasons`, so in that ONE
|
|
3143
|
+
> case an annotation's reason reaches `edge.widenReasons` after all. It is
|
|
3144
|
+
> immaterial today — §14.2 discloses `origin` as hand-fixture-only, and no
|
|
3145
|
+
> real-parser fixture reaches it — but the union in §16.5's rule is what
|
|
3146
|
+
> makes the grade correct under *both* branches rather than only the
|
|
3147
|
+
> common one. The PoC runs the naive
|
|
3148
|
+
> top-level-only grader against these hops and measures it returning
|
|
3149
|
+
> **`explicit`** — FR-306's literal prohibition, reached by reading the
|
|
3150
|
+
> field the requirement's own material appears to live in. This is not
|
|
3151
|
+
> §13.6's one exotic marker; it is an ordinary shape of ordinary code.
|
|
3152
|
+
|
|
3153
|
+
**Decided, and the two halves are not alternatives:**
|
|
3154
|
+
|
|
3155
|
+
1. **Fold annotation-carried reasons into the grade.** `widenReasons` and
|
|
3156
|
+
`lossReasons` on a `HopGrade` are the sorted UNION of the hop's own
|
|
3157
|
+
top-level arrays and every `annotations[].widenReason` / `.lossReason`.
|
|
3158
|
+
An annotation's reason is the same *kind of fact* as a top-level one —
|
|
3159
|
+
it is on a different field only because §2.2 classified that half as an
|
|
3160
|
+
annotation, which is a statement about edge formation, not about
|
|
3161
|
+
evidence quality.
|
|
3162
|
+
2. **AND surface it separately, by cause.** `degraded: true` is raised
|
|
3163
|
+
whenever a `DEGRADED_LOSS_REASONS` value is present, the specific
|
|
3164
|
+
reason is named in `factors`, and `annotationOnly[]` names exactly
|
|
3165
|
+
which inputs a top-level-only reader would have missed. §18.4 requires
|
|
3166
|
+
the *cause* be visible, not merely a tier: "context budget exhausted"
|
|
3167
|
+
must stay distinguishable from "the target was unrepresentable", and
|
|
3168
|
+
both must stay distinguishable from "no flow".
|
|
3169
|
+
|
|
3170
|
+
For §13.6's marker specifically (`C6/6`): the degraded `call-arg-bind`
|
|
3171
|
+
edge has `lossReasons: []` and `widenReasons: []` at top level, the naive
|
|
3172
|
+
grader calls it `explicit`, and `gradeHop` returns
|
|
3173
|
+
`grade: 'severed', degraded: true, annotationOnly: ['loss:context-cap-degraded']`.
|
|
3174
|
+
Grading it `severed` is the reading §13.6 itself asked for — the marker
|
|
3175
|
+
belongs *on* the real argument→parameter edge, saying the data was bound
|
|
3176
|
+
into a callee whose downstream is unrepresented.
|
|
3177
|
+
|
|
3178
|
+
**Why `gradeHop` returns an object rather than a string.** The brief for
|
|
3179
|
+
this increment asked that an annotation-only marker never be "silently
|
|
3180
|
+
invisible to a consumer reading only the grade". The structural answer is
|
|
3181
|
+
that **there is no way to read only the grade**: the return value is an
|
|
3182
|
+
object whose `grade` is one field among `factors`, `annotationOnly`,
|
|
3183
|
+
`degraded`, `truncated` and `incomplete`. A consumer that wants the
|
|
3184
|
+
scalar must reach through an object that already handed it the
|
|
3185
|
+
disclosure.
|
|
3186
|
+
|
|
3187
|
+
### 16.6 Q9 — crossing a function boundary does NOT lower the grade
|
|
3188
|
+
|
|
3189
|
+
**Decided: `crossScope` is a factor, never a demotion.** Three
|
|
3190
|
+
independent reasons, two of them measured:
|
|
3191
|
+
|
|
3192
|
+
- **FR-306 names two lower-confidence categories, and this is neither.**
|
|
3193
|
+
Implicit/control-dependent, and unknown-field widened. A function
|
|
3194
|
+
boundary is not an imprecision.
|
|
3195
|
+
- **The stitch is proven sound, not merely plausible.** §14.3's Q1 proof:
|
|
3196
|
+
the node id computed from `(peerScope, peerContext, toPath, id)` is
|
|
3197
|
+
**byte-identical** to the node id the callee independently created from
|
|
3198
|
+
its own hop. There is no over-approximation to grade down.
|
|
3199
|
+
- **A cross-scope demotion would report code structure, not evidence
|
|
3200
|
+
quality.** `C6/4` grades the same flow twice — inlined
|
|
3201
|
+
(`const b = user.email; return b;`) and factored into two functions
|
|
3202
|
+
(`helper(u){return u.email}` / `caller(a){const out = helper(a); …}`) —
|
|
3203
|
+
and measures **the same grade** (`explicit`, rank 0, hop grades
|
|
3204
|
+
`['explicit','explicit']`). Under a demotion, well-factored code would
|
|
3205
|
+
systematically grade below the identical inlined flow.
|
|
3206
|
+
- **And it would invert C5's own ranking.** `C6/4b`: at the 2-function
|
|
3207
|
+
resolved call, the real through-the-callee chain (`crossScopeCount: 2`)
|
|
3208
|
+
grades `explicit` while §14.7's disclosed bypass (`crossScopeCount: 0`)
|
|
3209
|
+
grades `ambiguous` — the correct path outranks the artefact. §15.7's
|
|
3210
|
+
`comparePaths` already ranks **more** `crossScopeCount` as **better**
|
|
3211
|
+
(key 4); a cross-scope demotion would have put C6's grade in direct
|
|
3212
|
+
contradiction with C5's shipped order.
|
|
3213
|
+
|
|
3214
|
+
The boundary crossing is still disclosed — `crossScope: true` on the
|
|
3215
|
+
`HopGrade` and `'cross-scope'` in `factors` — so a UI can render it
|
|
3216
|
+
distinctly without it being a confidence claim.
|
|
3217
|
+
|
|
3218
|
+
### 16.7 Findings this increment does NOT fix, named rather than patched
|
|
3219
|
+
|
|
3220
|
+
**Finding 1 (Minor, soundness-unaffected; SCHEDULED, not merely
|
|
3221
|
+
disclosed) — `path-query.js`'s `Path.widenedHopCount` / `lossHopCount` /
|
|
3222
|
+
`shape` under-report annotation-carried reasons.** `materialize()`
|
|
3223
|
+
computes them as `hops.filter((h) => h.widenReasons.length > 0).length`
|
|
3224
|
+
etc., i.e. from the two edge-forming halves only — the exact blind spot
|
|
3225
|
+
§16.5 measures. It is the same document-vs-code gap in miniature:
|
|
3226
|
+
§14.9's own correction and §15.8 both state the rule (*"a consumer must
|
|
3227
|
+
read `annotations[]` too, not only the edge's top-level reason arrays"*),
|
|
3228
|
+
and `materialize()` is precisely such a consumer, written in the same
|
|
3229
|
+
increment that wrote both sentences.
|
|
3230
|
+
|
|
3231
|
+
> **CORRECTED 2026-08-30 by this increment's own fix round 1 (finding 1,
|
|
3232
|
+
> BLOCKING). The first draft of this paragraph deferred the fix and gave a
|
|
3233
|
+
> FALSE reason for deferring** — it claimed closing it "changes
|
|
3234
|
+
> `comparePaths`' ordering, §15.7's `shape` bucketing for the diversity
|
|
3235
|
+
> cap, and §15.11's published measured table." **Measured, all three are
|
|
3236
|
+
> false**, and the measurement is the argument:
|
|
3237
|
+
>
|
|
3238
|
+
> - The whole fix is four lines inside `materialize()` (§16.8's
|
|
3239
|
+
> `path-query.js` row carries them verbatim). Applied to the shipped
|
|
3240
|
+
> file, `npm run test:lineage` ran **328/329**, and the ONE failure was
|
|
3241
|
+
> this document's own PoC test asserting the defect's *presence* — which
|
|
3242
|
+
> is a test that is supposed to fail once the defect is gone, and has
|
|
3243
|
+
> since been flipped to assert the fixed behaviour instead (`C6/5b`).
|
|
3244
|
+
> **Zero** other tests moved.
|
|
3245
|
+
> - **§15.11's table is not a function of these fields at all.** Its
|
|
3246
|
+
> columns are sinks / paths / complete / partial / max hops / expansions
|
|
3247
|
+
> / clipped; none is derived from `widenedHopCount`, `lossHopCount` or
|
|
3248
|
+
> `shape`. This is not an inspection claim — `C5/M` pins that table and
|
|
3249
|
+
> passed unchanged under the patched build.
|
|
3250
|
+
> - The `comparePaths` / diversity-bucket risk is **unexercised by any
|
|
3251
|
+
> fixture in the tree**: every annotation-carrying fixture's terminal
|
|
3252
|
+
> yields exactly one path, so there is nothing to reorder and nothing to
|
|
3253
|
+
> bucket. (§15.11's own runs also raise the output caps out of the way,
|
|
3254
|
+
> so `shape` cannot bind there even in principle.)
|
|
3255
|
+
>
|
|
3256
|
+
> The **deferral itself stands** — a design task changes no shipped
|
|
3257
|
+
> `src/lineage/*.js` file, and C6's grade functions never depended on the
|
|
3258
|
+
> buggy field — but it is now scheduled as its own item (§16.8's
|
|
3259
|
+
> `path-query.js` row) rather than dismissed with "`path-query.js`: none",
|
|
3260
|
+
> which is what the false cost estimate had produced.
|
|
3261
|
+
|
|
3262
|
+
**The fix belongs in `materialize()` and nowhere else.** It must NOT be
|
|
3263
|
+
pushed down into `path-store.js`'s `edge.widenReasons` / `edge.lossReasons`:
|
|
3264
|
+
those two arrays are part of `provenanceEdgeId`'s discriminator (§14.5),
|
|
3265
|
+
so widening them would move every `pedge:` id and, through them, every
|
|
3266
|
+
`ppath:` id (§15.6) — a re-hash of the entire DAG to correct a display
|
|
3267
|
+
count. `C6/5b` (widen) and `C6/5c` (loss) prototype the corrected
|
|
3268
|
+
computation locally and assert the target behaviour; both pass against the
|
|
3269
|
+
current tree **and** against the patched build, so neither blocks the fix
|
|
3270
|
+
nor goes vacuous after it.
|
|
3271
|
+
|
|
3272
|
+
**Finding 2 (§18.4's own load-bearing constraint, re-opened at the query
|
|
3273
|
+
boundary) — a §13.6-degraded binding edge is unreachable from every
|
|
3274
|
+
structural sink candidate.** `C6/6b` measures it: the degraded
|
|
3275
|
+
`call-arg-bind` edge's target is an ordinary `path` node (the callee's
|
|
3276
|
+
parameter) with **zero** outgoing edges, because the callee's body was
|
|
3277
|
+
never analyzed. It is therefore not a `sinkCandidates()` result
|
|
3278
|
+
(`return`/`escape`/`loss` only) and not on any path leading to one, so a
|
|
3279
|
+
sink-rooted reconstruction surfaces **no** path carrying the marker — the
|
|
3280
|
+
§16.9 table's last row shows the whole degraded fixture yielding only
|
|
3281
|
+
`explicit`/`ambiguous` path grades.
|
|
3282
|
+
|
|
3283
|
+
This is **not** a scoping footnote, and the first draft of this paragraph
|
|
3284
|
+
understated it. §13.6 exists because a B6 cap degradation was *"completely
|
|
3285
|
+
silent"* — *"'context budget exhausted' is indistinguishable from 'no flow
|
|
3286
|
+
crosses this call'"*, §18.4's single most load-bearing constraint. C3
|
|
3287
|
+
closed that at the hop layer, C4 proved the marker survives onto a real
|
|
3288
|
+
edge (`C4/Q2c`), C5 built the query — and at the query boundary the
|
|
3289
|
+
silence **returns**: a caller who asks the only question the API makes
|
|
3290
|
+
easy (walk back from the sink candidates) is told about zero degraded
|
|
3291
|
+
flows, with nothing anywhere in the answer saying a degradation happened.
|
|
3292
|
+
The marker is intact in the store and graded correctly the moment anything
|
|
3293
|
+
looks at it (`C6/6`); what is missing is that nothing *asks*.
|
|
3294
|
+
|
|
3295
|
+
**Not C6's to fix** — grading is handed a hop and grades it; the gap is in
|
|
3296
|
+
which start nodes a caller is pointed at. The fix is either a
|
|
3297
|
+
`sinkCandidates()` change (a *query* concern, §15.9, which already frames
|
|
3298
|
+
that helper as a structural stand-in to be superseded) or Sub-project D's
|
|
3299
|
+
registry deciding a degraded dead end is a reportable endpoint. **Binding
|
|
3300
|
+
on whoever scopes Sub-project D:** carry this forward as an explicit
|
|
3301
|
+
scoping item, not as a note inherited from here. The candidate shape, so D
|
|
3302
|
+
does not re-derive it: a `path` node with zero out-edges whose in-edges
|
|
3303
|
+
carry a `context-cap-degraded` annotation is exactly a
|
|
3304
|
+
*truncation-terminal*, detectable with the `outIndex` the store already
|
|
3305
|
+
builds, and it is a sink in every sense that matters to §18.4 even though
|
|
3306
|
+
it is a sink in none of the three senses §14.2's node-kind table records.
|
|
3307
|
+
|
|
3308
|
+
**Finding 3 (Minor, tier-unaffected) — `factors` inherits
|
|
3309
|
+
`DESIGN_INTRAPROCEDURAL.md`'s round-6 Finding 3 mislabel.** `step()`'s
|
|
3310
|
+
`assign`/`return` sites stamp a hardcoded `'unresolved-call'` whenever
|
|
3311
|
+
they forward a bare `widened` flag (§10.1's own 2026-08-30 note), so a
|
|
3312
|
+
widening actually caused by a dynamic property key can be *named*
|
|
3313
|
+
`widen:unresolved-call` in a grade's `factors`. The **grade** is
|
|
3314
|
+
unaffected — both are widenings and both select the `widened` tier — and
|
|
3315
|
+
`C6/2b` shows the read side does carry both reasons where it knows them.
|
|
3316
|
+
Closing it needs `resolveExprIdentities` to thread a real reason string
|
|
3317
|
+
through its return value, which has been out of scope since C2.
|
|
3318
|
+
|
|
3319
|
+
### 16.8 What the follow-up implementation task must do
|
|
3320
|
+
|
|
3321
|
+
Written the way §10.1/§13.7/§14.10/§15.10 were, so the next brief needs no
|
|
3322
|
+
re-derivation.
|
|
3323
|
+
|
|
3324
|
+
**`scanner/src/lineage/flow-grade.js` (new)**
|
|
3325
|
+
|
|
3326
|
+
| # | Item | Detail |
|
|
3327
|
+
|---|---|---|
|
|
3328
|
+
| 1 | the whole module | Lift the local prototype block at the top of `test/lineage/flow-grade-poc.test.js` — it is written to be lifted (no test-only code inside it), but **"verbatim" is not literally executable: every declaration in it is a bare `const`/`function`, so the follow-up must add `export` to each of the seven public ones** (fix round 1, nitpick 5), and must NOT export `_PRECEDENCE` (item 3). `_sortedUnion` stays private too. **Zero imports** — add the same import-list self-check test `path-store.test.js`/`path-query.test.js` already carry, asserting the specifier list is EXACTLY `[]` |
|
|
3329
|
+
| 2 | `FLOW_EVIDENCE_GRADES` | §16.2's frozen array, in CONFIDENCE order. Export `IMPLICIT_FLOW_REASONS` and `DEGRADED_LOSS_REASONS` too — a consumer must be able to test membership without re-typing a literal |
|
|
3330
|
+
| 3 | `_PRECEDENCE` | §16.4's private table. Keep it private, exactly as `protection.js` keeps its own; the parity check in `C6/0` is what stops it drifting from the value list |
|
|
3331
|
+
| 4 | `flowGradeRank` / `aggregateFlowGrades` | throw on an unrecognized grade, `'unassessed'` for empty — `aggregateVerdicts`' contract verbatim |
|
|
3332
|
+
| 5 | `gradeHop` | §16.3's precedence and §16.5's UNION. It must accept a raw `PathStore` edge as well as a `Hop`; `C6/11` is the guard |
|
|
3333
|
+
| 6 | `gradePath` | §16.4's worst-wins, `hops[]` (the **full `HopGrade` objects**, not bare strings — fix round 1, finding 3), `worstHopIndex`, and counts **recomputed from `gradeHop`** — never read off the Path (§16.7 Finding 1) |
|
|
3334
|
+
|
|
3335
|
+
**`scanner/src/lineage/path-query.js` — one small, separately-committable
|
|
3336
|
+
remediation, distinct from the `flow-grade.js` work above.** Added by fix
|
|
3337
|
+
round 1 (finding 1), replacing this table's original "`path-query.js`:
|
|
3338
|
+
none", which rested on a cost estimate §16.7 Finding 1 has since measured
|
|
3339
|
+
to be false.
|
|
3340
|
+
|
|
3341
|
+
| # | Site | Change |
|
|
3342
|
+
|---|---|---|
|
|
3343
|
+
| 7 | `materialize()`, the `widenedHopCount`/`lossHopCount` lines (today `path-query.js:108-109`) | Make the two counts annotation-aware, closing §16.7 Finding 1: <br>`const _annWiden = (h) => h.widenReasons.length > 0 \|\| (h.annotations ?? []).some((a) => a.widenReason != null);`<br>`const _annLoss = (h) => h.lossReasons.length > 0 \|\| (h.annotations ?? []).some((a) => a.lossReason != null);`<br>then `hops.filter(_annWiden)` / `hops.filter(_annLoss)`. §15.7's `shape` is derived from the same two filters and is therefore fixed by the same change — do **not** leave `shape` reading the old predicate. **Do NOT push this into `path-store.js`**: `edge.widenReasons`/`edge.lossReasons` are in `provenanceEdgeId`'s discriminator (§14.5), so changing them moves every `pedge:`/`ppath:` id |
|
|
3344
|
+
| 7b | verification | Measured on the shipped tree during fix round 1: the patch above takes `test:lineage` to **330/330** with no other assertion moved, and §15.11's `C5/M` table passes untouched. `C6/5b`/`C6/5c` already assert the post-fix behaviour, so they are the regression guard and need no edit when it lands. Re-measure §16.9's own table in the same commit only if a number there moves — it did not under the round-1 patch |
|
|
3345
|
+
| 7c | **not** in scope | Do **not** attach a `grade` to `Path` — it would change the shape `C5/4b`'s literal-JSON guard is pinned against, and it would force `path-query.js` to import a second module in violation of its own boundary test. A consumer calls `gradePath(path)` |
|
|
3346
|
+
|
|
3347
|
+
**No change to any other file.**
|
|
3348
|
+
|
|
3349
|
+
| # | File | Change |
|
|
3350
|
+
|---|---|---|
|
|
3351
|
+
| 8 | `path-store.js`, `engine.js`, `summaries.js`, `driver.js`, `field-identity.js` | **none** (`field-identity.js`: never) |
|
|
3352
|
+
| 9 | `schema.js` / `dataflow-graph.schema.json` / `validate.js` | **none this increment.** A flow grade is not a `DataFlowGraph v1` entity field yet. Confirm by running `npm run test:lineage` — `json-schema-parity.test.js` must stay green untouched. **Binding on Sub-project E:** the moment a flow grade is written onto a `DataFlowGraph v1` edge, this package's "every enum here is a single source of truth" convention applies and all three files must gain it in the same commit |
|
|
3353
|
+
| 10 | `protection.js` | **none.** §16.2 |
|
|
3354
|
+
|
|
3355
|
+
**Tests**
|
|
3356
|
+
|
|
3357
|
+
| # | Change |
|
|
3358
|
+
|---|---|
|
|
3359
|
+
| 11 | Re-point `flow-grade-poc.test.js` at the shipped `flow-grade.js`, delete its local prototype block, rename it to `flow-grade.test.js`, and update the `test:lineage` script in `scanner/package.json` in the SAME commit — C3's item 15 / C4's item 11 / C5's item 12 precedent |
|
|
3360
|
+
| 12 | Keep every assertion, and especially keep **`C6/5`** (the three annotation-only widening fixtures with the naive grader executed alongside) and **`C6/6`** (§13.6's marker) — together they are the only guard that stops §16.5's union being silently narrowed back to the top-level arrays, which would restore the exact FR-306 violation this increment exists to close |
|
|
3361
|
+
| 12b | **`C6/5b`/`C6/5c` outlive their prototype.** They currently assert item 7's target behaviour against the file-local `withAnnotationAwareCounts` helper. When item 7 lands in `materialize()`, delete that helper and re-point both tests at the Path's OWN `widenedHopCount`/`lossHopCount`/`shape`, keeping the assertions identical (`1`, `1`, `'widened'`, `'lossy'`). Drop the two forward-compatible `<=` guards at the same time — they exist only to let these tests pass on both sides of the fix, and once it has landed a `<=` where an `===` belongs is a weaker test than the tree deserves |
|
|
3362
|
+
| 13 | Keep `C6/12`'s pinned hop count and §16.9's table asserted, per §15.10 item 14's stated trade-off: an unrelated IR/engine change CAN move these numbers, and the correct response is to re-measure and update §16.9 in the same commit, never to relax the assertion |
|
|
3363
|
+
| 14 | Add a driver-level test only once a hop-emitting driver run is possible (Sub-projects D/E) — until then it would grade an empty store and be vacuous, same reasoning as §14.10 item 13 / §15.10 item 15 |
|
|
3364
|
+
|
|
3365
|
+
### 16.9 Measured numbers
|
|
3366
|
+
|
|
3367
|
+
Every row produced by running the PoC's own fixtures on 2026-08-30, with
|
|
3368
|
+
the reconstruction budgets left at their `DEFAULTS`. "paths" is summed
|
|
3369
|
+
across every `sinkCandidates()` start node; "hops" is the total hop count
|
|
3370
|
+
across those paths.
|
|
3371
|
+
|
|
3372
|
+
| fixture | paths | hops | hop grades | path grades | annotation-only reasons |
|
|
3373
|
+
|---|---|---|---|---|---|
|
|
3374
|
+
| `const b = user.email; return b;` | 1 | 2 | explicit:2 | explicit:1 | — |
|
|
3375
|
+
| unresolved call `mystery(user.email)` | 1 | 2 | widened:1 explicit:1 | widened:1 | — |
|
|
3376
|
+
| dynamic-key write `bag[k] = user.email` | 1 | 2 | widened:1 explicit:1 | widened:1 | — |
|
|
3377
|
+
| dynamic-key read `user[k]` | 1 | 2 | widened:1 explicit:1 | widened:1 | — |
|
|
3378
|
+
| §9.1 cross-join `{a: p.email, b: q.email}` | 4 | 8 | ambiguous:4 explicit:4 | ambiguous:4 | — |
|
|
3379
|
+
| 2-function resolved call | 3 | 8 | explicit:7 ambiguous:1 | explicit:2 ambiguous:1 | — |
|
|
3380
|
+
| bare-call arg `sink(mystery(user.email))` | 2 | 2 | widened:1 explicit:1 | widened:1 explicit:1 | `widen:unresolved-call` |
|
|
3381
|
+
| object prop `{a: mystery(user.email)}` | 2 | 3 | explicit:2 widened:1 | explicit:1 widened:1 | `widen:unresolved-call` |
|
|
3382
|
+
| ternary `c ? mystery(user.email) : user.email` | 2 | 3 | explicit:2 widened:1 | explicit:1 widened:1 | `widen:unresolved-call` |
|
|
3383
|
+
| unsupported target `({a: obj.z} = user)` | 1 | 1 | severed:1 | severed:1 | — |
|
|
3384
|
+
| mixed clean + widened | 1 | 3 | explicit:2 widened:1 | widened:1 | — |
|
|
3385
|
+
| §13.6 context-cap degraded (cap 1) | 6 | 25 | explicit:22 ambiguous:3 | explicit:3 ambiguous:3 | — |
|
|
3386
|
+
|
|
3387
|
+
Three rows to read closely:
|
|
3388
|
+
|
|
3389
|
+
- **The three annotation-only rows** are §16.5's whole argument. Each has
|
|
3390
|
+
a hop the naive top-level-only grader calls `explicit` and this design
|
|
3391
|
+
calls `widened`, and the `annotationOnly` column names the input that
|
|
3392
|
+
made the difference. `bare-call arg` is the sharpest: two `escape`
|
|
3393
|
+
sinks, two one-hop paths, and only one of them is widened — so the
|
|
3394
|
+
distinction is not an artefact of the fixture having one path.
|
|
3395
|
+
- **The 2-function resolved call** is §16.6's: 7 of its 8 hops grade
|
|
3396
|
+
`explicit` despite three of them crossing a function boundary, and the
|
|
3397
|
+
single `ambiguous` hop is §14.7's disclosed bypass, not the stitch.
|
|
3398
|
+
- **§13.6's degraded fixture** shows **no** `severed` and **no** degraded
|
|
3399
|
+
path grade — that is §16.7 Finding 2, measured. The marker is graded
|
|
3400
|
+
correctly (`C6/6`); it is simply not reachable from any sink candidate.
|
|
3401
|
+
|
|
3402
|
+
`C6/12` additionally sweeps **28 real hops across 10 fixtures** and asserts
|
|
3403
|
+
the closed-set property FR-306 literally demands, in both directions: a
|
|
3404
|
+
hop carrying any widen/loss/implicit/ambiguity signal — top-level **or**
|
|
3405
|
+
annotation-only — is never graded `explicit`, and a hop carrying none is
|
|
3406
|
+
never graded lower.
|
|
3407
|
+
|
|
3408
|
+
### 16.10 What C6 deliberately does not do
|
|
3409
|
+
|
|
3410
|
+
- **No transformation-kind or protection component in the grade.**
|
|
3411
|
+
§15.7's honest deferral stands: transformation-kind recognition does not
|
|
3412
|
+
exist (Sub-project D) and no analyzer produces a protection verdict
|
|
3413
|
+
(Milestone 2). When either lands it adds a *separate* dimension
|
|
3414
|
+
alongside this one, not a value inside `FLOW_EVIDENCE_GRADES` — a flow's
|
|
3415
|
+
explicitness and its protection state are orthogonal, exactly as
|
|
3416
|
+
`protection.js` keeps verdict and evidence grade orthogonal.
|
|
3417
|
+
- **The single grade scalar conflates explicitness with one KIND of
|
|
3418
|
+
completeness, and deliberately not with another. Stated as a trade-off,
|
|
3419
|
+
not left implicit** (fix round 1, finding 4). `severed` — a per-hop
|
|
3420
|
+
`lossReason` — is folded INTO the grade, while `truncated` (§14.8's
|
|
3421
|
+
analysis-run `markTruncated`) and `complete: false` (a partial path) are
|
|
3422
|
+
kept OUT of it, as flags. The line is *what the fact is about*: a
|
|
3423
|
+
`lossReason` is a statement about **this hop** ("the data went somewhere
|
|
3424
|
+
this analysis cannot represent"), which is the same subject FR-306's
|
|
3425
|
+
other tiers speak about, so it belongs on the same axis; an
|
|
3426
|
+
`ITER_BUDGET` break or a depth-limited walk is a statement about **the
|
|
3427
|
+
run**, and folding it in would make one function's budget silently
|
|
3428
|
+
re-grade every hop in it, including hops that are perfectly explicit
|
|
3429
|
+
(`C6/9` measures exactly that not happening). The cost of the asymmetry,
|
|
3430
|
+
disclosed: a reader who takes `grade` alone as "how much do I trust this
|
|
3431
|
+
flow" gets the loss half and misses the truncation half — which is why
|
|
3432
|
+
`incomplete` folds all three and why §16.5 makes the return value an
|
|
3433
|
+
object rather than a scalar. A future increment that finds the line
|
|
3434
|
+
drawn in the wrong place should move `severed` OUT to a flag (making the
|
|
3435
|
+
grade purely an explicitness axis), never move `truncated` IN.
|
|
3436
|
+
- **No implicit/control-dependence ANALYSIS.** The `implicit` tier is
|
|
3437
|
+
reserved (§16.3). Producing the reason that fills it is an `engine.js`
|
|
3438
|
+
change at §10.2's `if` row and is not in Sub-project C's scope.
|
|
3439
|
+
- **No grade attached to `Path` or to a `DataFlowGraph v1` edge.**
|
|
3440
|
+
§16.8 items 7 and 9.
|
|
3441
|
+
- **No UI or visual grammar.** FR-306's "visually distinct" half is
|
|
3442
|
+
satisfied here by making the distinction *available and unmissable* per
|
|
3443
|
+
hop; rendering it is Milestone 3's.
|
|
3444
|
+
- **No change to `path-query.js`'s counts *in this design task*** — but the
|
|
3445
|
+
change is **scheduled**, not declined: §16.8 item 7 carries the exact
|
|
3446
|
+
four-line fix, and §16.7 Finding 1 carries the measurement that the
|
|
3447
|
+
first draft's cost estimate for it was wrong. A design task changes no
|
|
3448
|
+
shipped `src/lineage/*.js` file; that is the only reason it is not done
|
|
3449
|
+
here.
|
|
3450
|
+
- **No change to `field-identity.js`** (never), and no change to any
|
|
3451
|
+
existing `src/lineage/*.js` file in the design task itself.
|