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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +2 -2
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/{271.index.js → 2271.index.js} +2 -2
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +5 -5
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +5 -5
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +2 -2
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +3 -3
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +560 -189
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +12 -5
  72. package/src/dataflow/CLAUDE.md +1 -1
  73. package/src/dataflow/catalog.js +42 -0
  74. package/src/dataflow/orm-write-catalog.js +175 -0
  75. package/src/engine.js +92 -1
  76. package/src/ir/CLAUDE.md +1 -0
  77. package/src/ir/chrome-probe.mjs +150 -0
  78. package/src/ir/parser-js.js +94 -7
  79. package/src/lineage/CLAUDE.md +1203 -0
  80. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  81. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  82. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  83. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  84. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  85. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  86. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  87. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  88. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  89. package/src/lineage/classification.js +56 -0
  90. package/src/lineage/coverage.js +658 -0
  91. package/src/lineage/cross-repo-link.js +107 -0
  92. package/src/lineage/dataflow-graph.schema.json +184 -0
  93. package/src/lineage/decision-story.js +206 -0
  94. package/src/lineage/drift-policy.js +279 -0
  95. package/src/lineage/driver.js +135 -0
  96. package/src/lineage/engine.js +992 -0
  97. package/src/lineage/export-briefing.js +628 -0
  98. package/src/lineage/export-csv.js +62 -0
  99. package/src/lineage/export-json.js +238 -0
  100. package/src/lineage/export-privacy.js +258 -0
  101. package/src/lineage/federation-loader.js +111 -0
  102. package/src/lineage/field-identity.js +78 -0
  103. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  104. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  105. package/src/lineage/flow-grade.js +221 -0
  106. package/src/lineage/governance-edit.js +169 -0
  107. package/src/lineage/graph-builder.js +1114 -0
  108. package/src/lineage/graph-diff.js +431 -0
  109. package/src/lineage/graph-snapshot.js +180 -0
  110. package/src/lineage/handling-analyzer.js +168 -0
  111. package/src/lineage/ids.js +349 -0
  112. package/src/lineage/impact-assessment.js +76 -0
  113. package/src/lineage/impact-engine.js +268 -0
  114. package/src/lineage/index.js +281 -0
  115. package/src/lineage/language-coverage-tiers.js +58 -0
  116. package/src/lineage/obligation-mapping.js +126 -0
  117. package/src/lineage/obligation-predicates.js +235 -0
  118. package/src/lineage/observation-adapters.js +282 -0
  119. package/src/lineage/observation-correlation.js +622 -0
  120. package/src/lineage/observation-store.js +497 -0
  121. package/src/lineage/path-query.js +410 -0
  122. package/src/lineage/path-store.js +400 -0
  123. package/src/lineage/protection.js +53 -0
  124. package/src/lineage/recipient-profile.js +192 -0
  125. package/src/lineage/recipient-registry.js +394 -0
  126. package/src/lineage/redact-graph.js +224 -0
  127. package/src/lineage/remediation.js +417 -0
  128. package/src/lineage/resolve-destination.js +91 -0
  129. package/src/lineage/runtime-observation.js +464 -0
  130. package/src/lineage/scenario-diff.js +84 -0
  131. package/src/lineage/scenario-engine.js +251 -0
  132. package/src/lineage/scenario.js +101 -0
  133. package/src/lineage/schema.js +167 -0
  134. package/src/lineage/sink-registry.js +427 -0
  135. package/src/lineage/source-registry.js +357 -0
  136. package/src/lineage/source-seeding.js +212 -0
  137. package/src/lineage/summaries.js +590 -0
  138. package/src/lineage/transform-catalog.js +397 -0
  139. package/src/lineage/transit-protection.js +150 -0
  140. package/src/lineage/validate.js +285 -0
  141. package/src/mcp/CLAUDE.md +7 -1
  142. package/src/mcp/dataflow-tools.js +160 -0
  143. package/src/mcp/server.js +1 -1
  144. package/src/mcp/tools.js +2 -1
  145. package/src/pipeline/scan-health.js +19 -1
  146. package/src/posture/CLAUDE.md +19 -0
  147. package/src/posture/artifact-registry.js +52 -0
  148. package/src/posture/auditor-walkthrough.js +76 -0
  149. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  150. package/src/posture/obligation-evidence-pack.js +202 -0
  151. package/src/posture/remediation-ledger.js +337 -0
  152. package/src/server/CLAUDE.md +47 -0
  153. package/src/server/graph-loader.js +141 -0
  154. package/src/server/http-server.js +325 -0
  155. package/src/server/routes.js +129 -0
  156. package/src/server/security.js +111 -0
  157. package/src/server/static-assets.js +139 -0
  158. package/dist/11.index.js +0 -353
  159. package/dist/259.index.js +0 -975
  160. package/dist/317.index.js +0 -300
  161. package/dist/609.index.js +0 -741
  162. package/dist/838.index.js +0 -152
@@ -0,0 +1,268 @@
1
+ // impact-engine.js — M5 deliverable #4 (FR-507): the pure read/
2
+ // aggregate computation behind "assess impact" from a compromised
3
+ // node/edge/flow/data element. Reuses the already-shipped, already-
4
+ // tested BFS traversal in frontend/src/lib/focus-controls.js — the
5
+ // established scanner/src/ -> frontend/src/ cross-import precedent
6
+ // (export-privacy.js's own computePrivacyViewModel import). No
7
+ // mutation, no hypothesis, no re-run of the taint/path pipeline — a
8
+ // pure filter/aggregate over the graph's own already-computed fields.
9
+
10
+ import { showAllPaths } from '../../../frontend/src/lib/focus-controls.js';
11
+ import { computeGraphDigest } from './export-json.js';
12
+ import { impactAssessmentId } from './ids.js';
13
+ import { IMPACT_VERSION } from './impact-assessment.js';
14
+
15
+ // dataElement's real stable-id prefix is 'data:' (see ids.js's own
16
+ // dataElementId(), which returns `data:${hash}` — every real
17
+ // DataFlowGraph v1 document's dataElements[] carries this prefix, not
18
+ // 'de:'). node/edge/flow below match ids.js's nodeId/edgeId/flowId
19
+ // prefixes exactly for the identical reason.
20
+ const _KIND_PREFIXES = Object.freeze({ node: 'node:', edge: 'edge:', flow: 'flow:', dataElement: 'data:' });
21
+
22
+ function _resolveTargetKind(targetId) {
23
+ if (typeof targetId !== 'string') return null;
24
+ for (const [kind, prefix] of Object.entries(_KIND_PREFIXES)) {
25
+ if (targetId.startsWith(prefix)) return kind;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ // The seed set for showAllPaths, node targets only — a node target
31
+ // seeds itself; edge/flow/dataElement targets never reach this
32
+ // function any more (final-review I1 fix). See
33
+ // _flowRestrictedAffectedSet's header comment for why: compromising a
34
+ // NODE genuinely puts everything reachable from it in the blast
35
+ // radius, but compromising one EDGE/FLOW/data-element does not — the
36
+ // affected record still NAMES that edge/flow's own two endpoint nodes
37
+ // (you need them to know what the compromised channel actually
38
+ // touches), it just never treats either endpoint as ITSELF fully
39
+ // compromised for the purpose of finding everything ELSE reachable
40
+ // from it via unrelated edges — that further step is exactly the
41
+ // over-inclusion class the original dataElement fix removed.
42
+ function _seedNodeIds(graph, targetId, targetKind) {
43
+ if (targetKind === 'node') return [targetId];
44
+ return [];
45
+ }
46
+
47
+ // Generalizes the direct flow-based trace to all three "named
48
+ // relationship" target kinds (dataElement/edge/flow) — each is
49
+ // restricted to the REAL flows that carry it, never the topology-wide
50
+ // showAllPaths BFS below (that stays node-only). A compromised
51
+ // flow/edge/data-element never sweeps in an unrelated sibling flow
52
+ // that merely shares a node — the exact carrier path is already on
53
+ // each flow's own record (source/sink/edgeIds/dataElementIds), so
54
+ // approximating with topology reachability here would discard real
55
+ // data, not fill a gap (reproduced live by this task's own review on
56
+ // a branching-topology graph: one source, three sinks, one flow each
57
+ // carrying a distinct data element — a `flow:`/`edge:` target for the
58
+ // sink-A flow incorrectly swept in sinks B and C, and their unrelated
59
+ // data classes/recipients with them). `matchesFlow(flow) -> boolean`
60
+ // selects which flows carry the target. Also computes dataClasses
61
+ // directly from the SAME matched-flow set (closes a related leak: the
62
+ // edge-membership-based _affectedDataClasses below could pull in an
63
+ // unrelated flow that merely shares one edge with the target flow but
64
+ // carries a different data element — unreachable on any real
65
+ // graph-builder output today since ids.edgeId's own discriminator
66
+ // includes the data element id, but this closes the gap for a future
67
+ // builder or an externally-supplied graph). Never adds a dangling
68
+ // edge id that doesn't resolve to a real edge in graph.edges. Node
69
+ // targets keep the showAllPaths BFS above — for those, "everything
70
+ // topologically reachable from the compromised node's own endpoints"
71
+ // IS the intended pessimistic scope:'possible' semantics.
72
+ function _flowRestrictedAffectedSet(graph, matchesFlow) {
73
+ const nodeIds = new Set();
74
+ const edgeIds = new Set();
75
+ const dataClasses = new Set();
76
+ for (const f of graph.flows ?? []) {
77
+ if (!matchesFlow(f)) continue;
78
+ nodeIds.add(f.source);
79
+ nodeIds.add(f.sink);
80
+ for (const deId of f.dataElementIds ?? []) {
81
+ const de = (graph.dataElements ?? []).find((d) => d.id === deId);
82
+ for (const c of de?.dataClasses ?? []) dataClasses.add(c);
83
+ }
84
+ for (const eId of f.edgeIds ?? []) {
85
+ const edge = (graph.edges ?? []).find((e) => e.id === eId);
86
+ if (!edge) continue; // never surface a dangling/unresolved edge id
87
+ edgeIds.add(eId);
88
+ nodeIds.add(edge.from);
89
+ nodeIds.add(edge.to);
90
+ }
91
+ }
92
+ return { nodeIds, edgeIds, dataClasses };
93
+ }
94
+
95
+ // Edge-membership-based aggregation — kept ONLY as the `node` target's
96
+ // fallback (a topology-reachability target has no single "the matched
97
+ // flows" set to read dataClasses off directly, unlike the three named-
98
+ // relationship kinds above, which use _flowRestrictedAffectedSet's own
99
+ // dataClasses instead).
100
+ function _affectedDataClasses(graph, affectedEdgeIds) {
101
+ const classes = new Set();
102
+ for (const f of graph.flows ?? []) {
103
+ if (!(f.edgeIds ?? []).some((id) => affectedEdgeIds.has(id))) continue;
104
+ for (const deId of f.dataElementIds ?? []) {
105
+ const de = (graph.dataElements ?? []).find((d) => d.id === deId);
106
+ for (const c of de?.dataClasses ?? []) classes.add(c);
107
+ }
108
+ }
109
+ return [...classes].sort();
110
+ }
111
+
112
+ function _affectedRecipientProfileIds(graph, affectedNodeIds) {
113
+ return (graph.recipientProfiles ?? [])
114
+ .filter((rp) => (rp.contributingGraphIds ?? []).some((id) => affectedNodeIds.has(id)))
115
+ .map((rp) => rp.id)
116
+ .sort();
117
+ }
118
+
119
+ // Whole-graph, not scoped to the affected subgraph — no node carries a
120
+ // language field to filter by, so a per-language coverage gap is
121
+ // reported as a real, honest limitation on any assessment computed
122
+ // over this graph, not narrowed to the exact affected nodes. See this
123
+ // sub-project's own implementation plan for the full disclosed
124
+ // reasoning. Complemented, not replaced, by _affectedCoverageLimitations
125
+ // below — that function reports the real per-entity coverageStatus gaps
126
+ // among the entities THIS assessment actually affects, which this
127
+ // whole-graph language disclosure cannot express.
128
+ function _coverageLimitations(graph) {
129
+ return (graph.coverage?.languages ?? [])
130
+ .filter((l) => l.tier && l.tier !== 'full')
131
+ .map((l) => `${l.language}: coverage tier '${l.tier}'${typeof l.irTaintRecallPct === 'number' ? ` (${l.irTaintRecallPct}% measured recall)` : ''}`);
132
+ }
133
+
134
+ // Per-entity coverage gap among the entities THIS assessment actually
135
+ // affects, using the real, required `coverageStatus` field every
136
+ // node/edge/flow already carries (`modeled|partial|candidate|
137
+ // unsupported|manual` — populated by graph-builder.js, already
138
+ // consumed by export-csv.js/graph-diff.js). Complements (never
139
+ // replaces) _coverageLimitations' whole-graph language disclosure
140
+ // above — both are real, complementary limitations of the same
141
+ // assessment.
142
+ function _affectedCoverageLimitations(graph, affectedNodeIds, affectedEdgeIds) {
143
+ const limitations = [];
144
+ const nonModeledNodes = (graph.nodes ?? []).filter((n) => affectedNodeIds.has(n.id) && n.coverageStatus && n.coverageStatus !== 'modeled');
145
+ const nonModeledEdges = (graph.edges ?? []).filter((e) => affectedEdgeIds.has(e.id) && e.coverageStatus && e.coverageStatus !== 'modeled');
146
+ if (nonModeledNodes.length) {
147
+ limitations.push(`${nonModeledNodes.length} of ${affectedNodeIds.size} affected node(s) have less-than-modeled coverage (e.g. ${nonModeledNodes[0].id}: coverage tier '${nonModeledNodes[0].coverageStatus}')`);
148
+ }
149
+ if (nonModeledEdges.length) {
150
+ limitations.push(`${nonModeledEdges.length} of ${affectedEdgeIds.size} affected edge(s) have less-than-modeled coverage (e.g. ${nonModeledEdges[0].id}: coverage tier '${nonModeledEdges[0].coverageStatus}')`);
151
+ }
152
+ return limitations;
153
+ }
154
+
155
+ /**
156
+ * Compute an ImpactAssessment for `targetId` over `graph`. Throws when
157
+ * `graph` is structurally malformed (missing `nodes`/`edges` arrays —
158
+ * see `loadSignedGraph`'s own "signature-only, no schema validation"
159
+ * contract for why a signed-but-malformed graph can reach here) with a
160
+ * message prefixed `computeImpactAssessment: malformed graph — `, so a
161
+ * caller can distinguish that from the other thrown case: `targetId`
162
+ * has no recognized canonical-id prefix (a genuine caller error, not a
163
+ * missing-entity case). A well-formed targetId that does not exist in
164
+ * the graph degrades honestly to empty affected-* arrays, never an
165
+ * error — mirrors applyScenario's own skip-not-throw contract for a
166
+ * stale/missing target.
167
+ *
168
+ * `node` targets report a deliberately pessimistic "everything
169
+ * topologically reachable" blast radius (`traceKind:
170
+ * 'topology_reachable'`) — compromising a node genuinely puts
171
+ * everything it can reach in the blast radius. `edge`/`flow`/
172
+ * `dataElement` targets report only the flows/nodes/edges that
173
+ * actually carry that specific edge/flow/data element (`traceKind:
174
+ * 'flow_restricted'`) — the record still names that edge/flow's own
175
+ * two endpoint nodes (you need them to know what the channel actually
176
+ * touches), it just never treats either endpoint as itself fully
177
+ * compromised for finding everything ELSE reachable from it via
178
+ * unrelated edges. The two target-kind families answer genuinely
179
+ * different questions, both honestly disclosed via `traceKind` rather
180
+ * than silently conflated under one `scope` value.
181
+ */
182
+ export function computeImpactAssessment(graph, targetId, opts = {}) {
183
+ if (!Array.isArray(graph?.nodes) || !Array.isArray(graph?.edges)) {
184
+ throw new Error('computeImpactAssessment: malformed graph — graph.nodes and graph.edges must both be arrays');
185
+ }
186
+
187
+ const targetKind = _resolveTargetKind(targetId);
188
+ if (!targetKind) {
189
+ throw new Error(`computeImpactAssessment: targetId "${targetId}" has no recognized prefix (expected one of node:/edge:/flow:/data:)`);
190
+ }
191
+
192
+ // showAllPaths (frontend/src/lib/focus-controls.js) unconditionally
193
+ // seeds its own result with the start id itself, even when that id
194
+ // does not exist in the graph at all (bfsDirection's `nodeIds = new
195
+ // Set([startId])`) — so a seed must be filtered against the graph's
196
+ // real node ids first, or a well-formed-but-nonexistent targetId
197
+ // would surface as a phantom single-node "affected" set instead of
198
+ // degrading honestly to empty arrays. The same filter is applied to
199
+ // _flowRestrictedAffectedSet's own node ids below, for the identical
200
+ // reason.
201
+ const realNodeIds = new Set((graph.nodes ?? []).map((n) => n.id));
202
+ const affectedNodeIds = new Set();
203
+ const affectedEdgeIds = new Set();
204
+ let affectedDataClasses;
205
+
206
+ if (targetKind === 'node') {
207
+ const seedNodeIds = _seedNodeIds(graph, targetId, targetKind).filter((id) => realNodeIds.has(id));
208
+ for (const seedId of seedNodeIds) {
209
+ const { nodeIds, edgeIds } = showAllPaths(graph, seedId);
210
+ for (const id of nodeIds) affectedNodeIds.add(id);
211
+ for (const id of edgeIds) affectedEdgeIds.add(id);
212
+ }
213
+ affectedDataClasses = _affectedDataClasses(graph, affectedEdgeIds);
214
+ } else {
215
+ // dataElement/edge/flow — direct flow-based trace via
216
+ // _flowRestrictedAffectedSet, never showAllPaths. See that
217
+ // function's own header comment for why.
218
+ const matchesFlow = targetKind === 'dataElement'
219
+ ? (f) => f.dataElementIds?.includes(targetId)
220
+ : targetKind === 'edge'
221
+ ? (f) => f.edgeIds?.includes(targetId)
222
+ : (f) => f.id === targetId; // flow
223
+ const { nodeIds, edgeIds, dataClasses } = _flowRestrictedAffectedSet(graph, matchesFlow);
224
+ for (const id of nodeIds) if (realNodeIds.has(id)) affectedNodeIds.add(id);
225
+ for (const id of edgeIds) affectedEdgeIds.add(id);
226
+ // N3 (final-review re-review): an edge: target that exists in
227
+ // graph.edges but appears in NO flow's own edgeIds (real for the
228
+ // real graph — an edge with zero flows using it is a legitimate
229
+ // graph shape, e.g. a dead-end connection no data currently
230
+ // traverses) must never come back fully empty — "this edge is
231
+ // compromised" means, at minimum, that edge and its own two
232
+ // endpoints are affected, flow or no flow. A nonexistent edge id
233
+ // still correctly degrades to fully empty (realNodeIds/the edge
234
+ // lookup below both filter it out).
235
+ if (targetKind === 'edge') {
236
+ const edge = (graph.edges ?? []).find((e) => e.id === targetId);
237
+ if (edge) {
238
+ affectedEdgeIds.add(edge.id);
239
+ if (realNodeIds.has(edge.from)) affectedNodeIds.add(edge.from);
240
+ if (realNodeIds.has(edge.to)) affectedNodeIds.add(edge.to);
241
+ }
242
+ }
243
+ affectedDataClasses = [...dataClasses].sort();
244
+ }
245
+
246
+ const graphDigest = computeGraphDigest(graph);
247
+ const generatedAt = opts.generatedAt ?? new Date().toISOString();
248
+
249
+ return {
250
+ id: impactAssessmentId({ graphId: graph.graphId, graphDigest, targetId }, [generatedAt]),
251
+ version: IMPACT_VERSION,
252
+ graphId: graph.graphId,
253
+ graphDigest,
254
+ targetId,
255
+ targetKind,
256
+ scope: 'possible',
257
+ traceKind: targetKind === 'node' ? 'topology_reachable' : 'flow_restricted',
258
+ affectedNodeIds: [...affectedNodeIds].sort(),
259
+ affectedEdgeIds: [...affectedEdgeIds].sort(),
260
+ affectedDataClasses,
261
+ affectedRecipientProfileIds: _affectedRecipientProfileIds(graph, affectedNodeIds),
262
+ coverageLimitations: [
263
+ ..._coverageLimitations(graph),
264
+ ..._affectedCoverageLimitations(graph, affectedNodeIds, affectedEdgeIds),
265
+ ],
266
+ generatedAt,
267
+ };
268
+ }
@@ -0,0 +1,281 @@
1
+ //
2
+ // index.js — Sub-project E, increment 5 (E5). The scan-facing entry point
3
+ // for the Data Flow Explorer lineage engine. This is the ONLY file under
4
+ // src/lineage/ that engine.js/bin/agentic-security.js import — every other
5
+ // module in this package stays isolated per its own established reuse
6
+ // boundary (see src/lineage/CLAUDE.md's header).
7
+ //
8
+ // Mirrors runFullScan's own `_deepEnabled` block's CONTRACT (opt-in,
9
+ // best-effort, every outcome returned as a structured status a caller folds
10
+ // into scanHealth) — NOT `dataflow/index.js`'s `AGENTIC_SECURITY_PRIVACY_DEEP`
11
+ // block, whose bare `catch {}` silently swallows failure with no scanHealth
12
+ // signal at all (measured and disclosed in
13
+ // docs/superpowers/plans/2026-08-31-data-flow-explorer-m1-subproject-e5-scoping.md
14
+ // §1 — DESIGN_GRAPH_BUILDER.md §9.5 item 1's own wording describes the
15
+ // LATTER mechanism, not the former, despite naming the former by name).
16
+ //
17
+ // Unlike privacy-taint, lineage analysis has NO degraded/non-IR-backed mode:
18
+ // `buildGraphWithCoverage(callGraph, opts)` requires a real callGraph with
19
+ // real CFGs, and there is nothing meaningful to fall back to. A missing or
20
+ // malformed callGraph is reported as `not_available`, never attempted as a
21
+ // degraded run.
22
+
23
+ import * as fs from 'node:fs';
24
+ import { buildGraphWithCoverage } from './coverage.js';
25
+ import { scanTransitEvidence } from './transit-protection.js';
26
+ // Milestone 2, Sub-project G, increment 1 (FR-408/AC-09): loaded ONCE, here
27
+ // — mirroring `scanTransitEvidence`'s own single-computation discipline one
28
+ // line above — never re-loaded at a lower layer (`coverage.js`/
29
+ // `graph-builder.js` both only ever consume the already-loaded object).
30
+ import { loadPrivacySinkPolicy } from '../dataflow/privacy-sink-policy.js';
31
+ // Deliverable #10 (DFG-020, graph-derived DPIA/RoPA migration): loaded
32
+ // ONCE, here — mirroring `loadPrivacySinkPolicy`'s own single-computation
33
+ // discipline one block below. Unlike that policy load, no existence-gating
34
+ // is needed: `loadPrivacyGovernanceConfig` already has its own honest empty
35
+ // default ({byClass: {}, default: {}} — never throws), and
36
+ // `governanceRecordFor` already resolves an empty config to MANUAL_REQUIRED
37
+ // for every field, which is the correct, honest answer when no
38
+ // .agentic-security/privacy-governance.json exists on disk.
39
+ import { loadPrivacyGovernanceConfig } from '../dataflow/privacy-governance.js';
40
+ import { statePath } from '../posture/state-dir.js';
41
+ // Milestone 4, FR-506 (Third-Party and Cross-Border Intelligence): loaded
42
+ // ONCE, here — mirroring `loadPrivacySinkPolicy`'s own single-computation
43
+ // discipline above. `loadRecipientConfig` already has the SAME "never
44
+ // throws, missing file degrades to {recipients: {}}" contract
45
+ // `loadPrivacyGovernanceConfig` has, so — like that config, unlike
46
+ // `privacySinkPolicy` — no existence-gating is needed on the CALL itself;
47
+ // only the PATH resolution mirrors `privacySinkPolicy`'s own precedent,
48
+ // since `loadRecipientConfig` (unlike `loadPrivacyGovernanceConfig`) takes
49
+ // a literal file path, not a scanRoot.
50
+ import { loadRecipientConfig, RECIPIENT_CONFIG_FILENAME } from './recipient-registry.js';
51
+ // M5 deliverable #7 (FR-505/AC-29, Runtime-Corroborated Digital Twin, "7b"):
52
+ // loaded ONCE, here — mirroring `loadPrivacySinkPolicy`'s own
53
+ // single-computation discipline above, including the SAME explicit
54
+ // `fs.existsSync` gate and the SAME reason: `loadObservations` returns the
55
+ // identical empty array whether the store directory is missing or
56
+ // present-and-empty, and those are two DIFFERENT answers under AC-29
57
+ // clause 2 — see the function body below for the full reasoning.
58
+ import { loadObservations } from './observation-store.js';
59
+ // M5 deliverable #8 (FR-304 "declared" half): loaded ONCE, here —
60
+ // mirroring `privacySinkPolicy`'s own existence-gated, single-computation
61
+ // discipline below (never `recipientConfig`'s unconditional-call one — a
62
+ // missing cross-repo-links.json here means "no links declared", a real,
63
+ // distinguishable-from-empty state worth keeping honest the same way
64
+ // `privacy-policy.json`'s absence is, per this deliverable's own scoping
65
+ // doc). `validateCrossRepoLink` is imported directly (not a separate
66
+ // loader module) — see `_loadCrossRepoLinkRecords` below for why this
67
+ // small, local, tolerant reader lives here rather than in
68
+ // `cross-repo-link.js` (which must stay a PURE, zero-fs-access module,
69
+ // mirroring `scenario.js`'s own boundary) or `federation-loader.js`
70
+ // (which owns only the REMOTE side).
71
+ import { validateCrossRepoLink, CROSS_REPO_LINKS_FILENAME } from './cross-repo-link.js';
72
+
73
+ // A small, LOCAL, tolerant loader for the operator-declared
74
+ // cross-repo-links.json config file — mirrors `loadRecipientConfig`'s own
75
+ // fail-closed, skip-the-whole-entry-on-any-defect discipline
76
+ // (recipient-registry.js), but kept local to this file rather than
77
+ // exported from `cross-repo-link.js`/`federation-loader.js` (see the
78
+ // import comment above for the full reasoning). Never throws; a missing
79
+ // file is never reached here at all (the caller already gated on
80
+ // `fs.existsSync`); a malformed file or a malformed individual link
81
+ // degrades to an empty/partial array with a console warning naming the
82
+ // count skipped, mirroring `loadRecipientConfig`'s own per-entry
83
+ // discipline.
84
+ function _loadCrossRepoLinkRecords(filePath) {
85
+ let raw;
86
+ try {
87
+ raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
88
+ } catch (e) {
89
+ console.error(`agentic-security: bad JSON in cross-repo links file (${filePath}) — falling back to no declared links (${e.message})`);
90
+ return [];
91
+ }
92
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Array.isArray(raw.links)) {
93
+ console.error(`agentic-security: cross-repo links file ${filePath} has no "links" array — falling back to no declared links (expected {"links": [...]})`);
94
+ return [];
95
+ }
96
+ const records = [];
97
+ let skipped = 0;
98
+ for (const record of raw.links) {
99
+ const { valid } = validateCrossRepoLink(record);
100
+ if (!valid) { skipped += 1; continue; }
101
+ records.push(record);
102
+ }
103
+ if (skipped > 0) {
104
+ console.error(`agentic-security: skipped ${skipped} malformed cross-repo-link entr${skipped === 1 ? 'y' : 'ies'} in ${filePath} (each must be a valid CrossRepoLink-shaped object)`);
105
+ }
106
+ return records;
107
+ }
108
+
109
+ /**
110
+ * @param {{functions: Map}} callGraph a real callGraph — the same shape
111
+ * `buildProjectIR`/`buildProjectIRAsync` produce (`_sharedIR.callGraph`
112
+ * in `runFullScan`).
113
+ * @param {object} [opts]
114
+ * @param {string} [opts.repository] threaded straight to `buildGraphWithCoverage`.
115
+ * @param {string} [opts.scanRoot] the real scan root path — distinct from
116
+ * `opts.repository`, which by the time it reaches this function is only a
117
+ * basename (see `engine.js`'s own call site). Used ONLY to load the
118
+ * operator's privacy sink policy (Milestone 2, Sub-project G, increment
119
+ * 1, FR-408/AC-09) — never threaded to `buildGraphWithCoverage` itself,
120
+ * which never reads the filesystem. See the function body for why
121
+ * existence is checked explicitly rather than inferred from
122
+ * `loadPrivacySinkPolicy`'s own return value.
123
+ * @param {string} [opts.environment] optional deployment-environment
124
+ * override for policy evaluation's environment-scoped rules; threaded to
125
+ * `buildGraphWithCoverage`'s `opts.environment`, which falls back to
126
+ * `AGENTIC_SECURITY_ENVIRONMENT` at the point the verdict is computed
127
+ * (`graph-builder.js`), mirroring `dataflow/privacy-taint.js`'s own
128
+ * precedent.
129
+ * @param {boolean} [opts.deterministic] when true, `generatedAt` is left
130
+ * `undefined` so `buildDataFlowGraph`'s own fixed-literal default applies
131
+ * — the literal itself lives in exactly one place, `graph-builder.js`.
132
+ * @param {Record<string,object>} [opts.perFile] threaded to the coverage
133
+ * ledger's `languages[]` computation.
134
+ * @param {Array<object>} [opts.parseFailures] threaded to the coverage
135
+ * ledger's `parseFailures`/`languages[].filesExpected` computation.
136
+ * @param {Record<string,string>} [opts.fileContents] `{path: rawSourceString}`
137
+ * — threaded to `transit-protection.js`'s `scanTransitEvidence` (Milestone 2,
138
+ * Sub-project B, increment 1). As of increment 2, `scanTransitEvidence` is
139
+ * called EXACTLY ONCE, here, per `buildLineageGraph` call — its result
140
+ * (a `Map<file, findings[]>`) feeds BOTH the returned `transitEvidence`
141
+ * field below AND `buildGraphWithCoverage`'s own `opts.transitEvidenceByFile`
142
+ * (the same `Map` reference, never recomputed), which is what actually
143
+ * drives `edge.protection.transit` verdicts inside `graph-builder.js`. See
144
+ * `DESIGN_TRANSIT_PROTECTION.md` §6 for why this single-computation
145
+ * discipline is load-bearing (a second call inside `coverage.js`'s own
146
+ * default hook would double-scan every file). `graph` is NOT
147
+ * byte-identical to omitting `opts.fileContents` anymore — a network
148
+ * sink's `edge.protection.transit` can now genuinely change with the
149
+ * evidence supplied.
150
+ * Milestone 4, FR-506: the operator's recipient config
151
+ * (`.agentic-security/recipient-profiles.json`, resolved against
152
+ * `opts.scanRoot`) is loaded exactly once here and threaded to
153
+ * `buildGraphWithCoverage`'s `opts.recipientConfig`, which drives the
154
+ * default `opts.buildRecipientProfile` hook — no separate `opts` field
155
+ * needed, unlike `privacySinkPolicy`, since `loadRecipientConfig` already
156
+ * degrades a missing/malformed file gracefully on its own.
157
+ * M5 deliverable #7 (FR-505/AC-29): the operator's runtime-observation
158
+ * store (`.agentic-security/runtime-observations/`, resolved against
159
+ * `opts.scanRoot`) is loaded exactly once here, existence-gated exactly
160
+ * like `privacySinkPolicy` above, and threaded to `buildGraphWithCoverage`'s
161
+ * `opts.runtimeObservations`.
162
+ * @param {string} [opts.observationWindowStart] optional ISO-8601 lower
163
+ * bound for runtime-observation correlation, threaded to
164
+ * `buildGraphWithCoverage`'s `opts.observationWindowStart`.
165
+ * @param {string} [opts.observationWindowEnd] optional ISO-8601 upper
166
+ * bound for runtime-observation correlation, threaded to
167
+ * `buildGraphWithCoverage`'s `opts.observationWindowEnd`.
168
+ * @returns {{status: 'not_available'|'complete'|'failed', graph: object|null, transitEvidence: Map<string,object[]>, failure: string|null, elapsedMs: number}}
169
+ * `status` is never `'not_requested'` — that decision belongs to the
170
+ * CALLER (whether to call this function at all), not to this function's
171
+ * own return value. `transitEvidence` is a `Map<file, findings[]>` — see
172
+ * `DESIGN_TRANSIT_PROTECTION.md` §3 for why a `Map`, not a plain object.
173
+ * It is a real, populated result of running `scanCryptoProtocol` over
174
+ * `opts.fileContents` (empty when omitted). As of increment 2, this same
175
+ * `Map` IS also joined to specific graph edges — via
176
+ * `buildGraphWithCoverage`'s `opts.transitEvidenceByFile`, consulted by
177
+ * `resolveTransitProtectionForSite` for `external-api` sink sites — but
178
+ * this returned field itself stays the raw, ungrouped `Map<file,
179
+ * findings[]>`, not a per-edge join result.
180
+ */
181
+ export function buildLineageGraph(callGraph, opts = {}) {
182
+ const t0 = Date.now();
183
+ if (!callGraph || typeof callGraph.functions?.values !== 'function') {
184
+ return { status: 'not_available', graph: null, transitEvidence: new Map(), failure: null, elapsedMs: Date.now() - t0 };
185
+ }
186
+ try {
187
+ // Milestone 2, Sub-project B, increment 2 (DESIGN_TRANSIT_PROTECTION.md
188
+ // §6, item 4): `scanTransitEvidence` runs EXACTLY ONCE per
189
+ // `buildLineageGraph` call, here — the same `Map` reference feeds both
190
+ // this function's own `transitEvidence` return field AND
191
+ // `buildGraphWithCoverage`'s `opts.transitEvidenceByFile`, which is what
192
+ // `coverage.js`'s default `resolveTransitProtection` hook actually
193
+ // consults. `coverage.js` never calls `scanTransitEvidence`/
194
+ // `scanCryptoProtocol` itself — it only reads this pre-computed Map —
195
+ // so no file is ever scanned twice.
196
+ const transitEvidence = scanTransitEvidence(opts.fileContents ?? {});
197
+ // Milestone 2, Sub-project G, increment 1 (FR-408/AC-09): load the
198
+ // operator's privacy sink policy exactly once, here. Existence is
199
+ // checked EXPLICITLY (never inferred from `loadPrivacySinkPolicy`'s own
200
+ // return value alone) because that function deliberately returns the
201
+ // SAME empty `{allow: []}` shape whether the policy file is missing,
202
+ // malformed, or genuinely present with an empty `allow` array —
203
+ // collapsing three states `graph-builder.js`'s own policy-verdict logic
204
+ // needs to keep apart. A MISSING policy must read
205
+ // `flow.policyVerdict: 'not_evaluated'` (nothing was actually
206
+ // evaluated — privacy-sink-policy.js's own header: "nothing changes for
207
+ // a repo with no policy file"); a PRESENT-but-empty policy (an
208
+ // operator's deliberate "nothing is permitted yet" `{"allow": []}`)
209
+ // must read `'prohibited'` — the deny-by-default stance that same
210
+ // header establishes once a policy is genuinely in play.
211
+ // `privacySinkPolicy` therefore stays `undefined` (never coerced to
212
+ // `{allow: []}`) unless the file genuinely exists on disk — this is
213
+ // what lets `graph-builder.js`'s `opts.privacySinkPolicy != null` gate
214
+ // make that distinction at all.
215
+ const _policyFile = opts.scanRoot ? statePath(opts.scanRoot, 'privacy-policy.json') : null;
216
+ const privacySinkPolicy = _policyFile && fs.existsSync(_policyFile)
217
+ ? loadPrivacySinkPolicy(opts.scanRoot)
218
+ : undefined;
219
+ // Deliverable #10 (DFG-020): the operator's privacy governance config,
220
+ // loaded exactly once, here — mirroring privacySinkPolicy's own
221
+ // single-load precedent immediately above it. See this file's own
222
+ // import comment for why no existence-gating is needed here, unlike
223
+ // privacySinkPolicy.
224
+ const privacyGovernanceConfig = loadPrivacyGovernanceConfig(opts.scanRoot);
225
+ // Milestone 4, FR-506: the operator's recipient config, loaded exactly
226
+ // once, here — mirroring `privacySinkPolicy`'s own path-resolution step
227
+ // (gated on `opts.scanRoot`, since `loadRecipientConfig` takes a literal
228
+ // file path rather than a scanRoot) but, per this file's own import
229
+ // comment above, calling the loader UNCONDITIONALLY once the path is
230
+ // resolved — `loadRecipientConfig` already degrades a missing/malformed
231
+ // file to `{recipients: {}}` on its own, the same honest-empty-default
232
+ // contract `loadPrivacyGovernanceConfig` has.
233
+ const recipientConfigPath = opts.scanRoot ? statePath(opts.scanRoot, RECIPIENT_CONFIG_FILENAME) : null;
234
+ const recipientConfig = loadRecipientConfig(recipientConfigPath);
235
+ // M5 deliverable #7 (FR-505/AC-29): the operator's runtime-observation
236
+ // store, loaded exactly ONCE here — the same single-computation discipline
237
+ // scanTransitEvidence and loadPrivacySinkPolicy already follow. Existence
238
+ // is checked EXPLICITLY, exactly like privacySinkPolicy and for the
239
+ // identical reason: `loadObservations` returns the same empty array whether
240
+ // the store directory is missing or present-and-empty, and those are two
241
+ // DIFFERENT answers under AC-29 clause 2. A MISSING store must leave
242
+ // `graph.runtimeCorroboration` absent (`not_evaluated` — nothing was
243
+ // consulted); a PRESENT-but-empty store must produce a real correlation
244
+ // result whose every flow reads `not_observed_in_window` (a store WAS
245
+ // consulted and the window genuinely contained nothing). PRD line 2098:
246
+ // absence of observation is never non-occurrence.
247
+ const _observationsDir = opts.scanRoot ? statePath(opts.scanRoot, 'runtime-observations') : null;
248
+ const runtimeObservations = _observationsDir && fs.existsSync(_observationsDir)
249
+ ? loadObservations(opts.scanRoot)
250
+ : undefined;
251
+ // M5 deliverable #8 (FR-304 "declared" half): the operator's declared
252
+ // cross-repo links, loaded exactly once here — the same
253
+ // single-computation discipline every other config load in this
254
+ // function follows. Existence is checked EXPLICITLY, exactly like
255
+ // `privacySinkPolicy` above.
256
+ const _crossRepoLinksFile = opts.scanRoot ? statePath(opts.scanRoot, CROSS_REPO_LINKS_FILENAME) : null;
257
+ const crossRepoLinkRecords = _crossRepoLinksFile && fs.existsSync(_crossRepoLinksFile)
258
+ ? _loadCrossRepoLinkRecords(_crossRepoLinksFile)
259
+ : undefined;
260
+ const built = buildGraphWithCoverage(callGraph, {
261
+ repository: opts.repository,
262
+ generatedAt: opts.deterministic ? undefined : new Date().toISOString(),
263
+ perFile: opts.perFile,
264
+ parseFailures: opts.parseFailures,
265
+ transitEvidenceByFile: transitEvidence,
266
+ privacySinkPolicy,
267
+ privacyGovernanceConfig,
268
+ environment: opts.environment,
269
+ recipientConfig,
270
+ runtimeObservations,
271
+ crossRepoLinkRecords,
272
+ observationWindowStart: opts.observationWindowStart,
273
+ observationWindowEnd: opts.observationWindowEnd,
274
+ });
275
+ return { status: 'complete', graph: built.graph, transitEvidence, failure: null, elapsedMs: Date.now() - t0 };
276
+ } catch (e) {
277
+ // Best-effort (DESIGN_GRAPH_BUILDER.md §9.5 item 1): recorded, never
278
+ // swallowed. The caller (runFullScan) folds `failure` into scanHealth.
279
+ return { status: 'failed', graph: null, transitEvidence: new Map(), failure: String((e && e.message) || e), elapsedMs: Date.now() - t0 };
280
+ }
281
+ }
@@ -0,0 +1,58 @@
1
+ // language-coverage-tiers.js — Milestone 5, language coverage-tier
2
+ // disclosure. A small, curated, static table answering "how much can this
3
+ // codebase's Data Flow Explorer actually see for language X today" —
4
+ // PRD §22.1's own explicitly-sanctioned alternative to claiming a language
5
+ // is fully "supported" before it clears §22.3's 85% field-to-sink-recall
6
+ // bar (as of docs/METRICS.md, measured 2026-08-19, NONE of the 9
7
+ // lineage-wired languages do — best is python at 66%).
8
+ //
9
+ // Every number here is copied VERBATIM from docs/METRICS.md's own
10
+ // currently-committed table (bench/layer-recall's IR-TAINT column — the
11
+ // closest existing proxy for the PRD's own field-to-sink recall definition,
12
+ // not a byte-identical measurement of it). This module does not re-measure
13
+ // anything; re-run bench/layer-recall and update BOTH docs/METRICS.md and
14
+ // this table together if the numbers ever change, or this file goes stale
15
+ // silently. Zero imports — a pure, static data module, mirroring
16
+ // flow-grade.js's own "zero imports" precedent for a self-contained
17
+ // vocabulary/data table.
18
+ //
19
+ // The 9 keys below are languageForFile's own normalized vocabulary
20
+ // (coverage.js) — js/python/java/csharp/kotlin/go/php/ruby/cpp — the exact
21
+ // languages with real IR-to-lineage wiring today. The 4 pattern-only keys
22
+ // (rust/solidity/swift/dart) have ZERO lineage/taint wiring: they exist
23
+ // only as tree-sitter grammar loads feeding sast/tree-sitter-sinks.js's
24
+ // pattern matching, never scanner/src/lineage/ or scanner/src/dataflow/ —
25
+ // confirmed by the M5 top-level scoping doc's own investigation. No
26
+ // lineage engine ever runs against them, so they carry no recall number at
27
+ // all (irTaintRecallPct: null) — never a fabricated 0%, which would read
28
+ // as "measured and found to be zero" rather than "never measured, because
29
+ // nothing here can produce a lineage finding for this language yet."
30
+
31
+ export const LANGUAGE_COVERAGE_TIERS = Object.freeze([
32
+ Object.freeze({ language: 'python', tier: 'partial', irTaintRecallPct: 66, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
33
+ Object.freeze({ language: 'go', tier: 'partial', irTaintRecallPct: 59, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
34
+ Object.freeze({ language: 'js', tier: 'partial', irTaintRecallPct: 58, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
35
+ Object.freeze({ language: 'csharp', tier: 'partial', irTaintRecallPct: 57, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
36
+ Object.freeze({ language: 'ruby', tier: 'partial', irTaintRecallPct: 55, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
37
+ Object.freeze({ language: 'java', tier: 'partial', irTaintRecallPct: 52, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
38
+ Object.freeze({ language: 'php', tier: 'partial', irTaintRecallPct: 52, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
39
+ Object.freeze({ language: 'kotlin', tier: 'partial', irTaintRecallPct: 48, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
40
+ Object.freeze({ language: 'cpp', tier: 'partial', irTaintRecallPct: 18, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
41
+ Object.freeze({ language: 'rust', tier: 'pattern-only', irTaintRecallPct: null, measuredAt: null, source: null }),
42
+ Object.freeze({ language: 'solidity', tier: 'pattern-only', irTaintRecallPct: null, measuredAt: null, source: null }),
43
+ Object.freeze({ language: 'swift', tier: 'pattern-only', irTaintRecallPct: null, measuredAt: null, source: null }),
44
+ Object.freeze({ language: 'dart', tier: 'pattern-only', irTaintRecallPct: null, measuredAt: null, source: null }),
45
+ ]);
46
+
47
+ const _byLanguage = new Map(LANGUAGE_COVERAGE_TIERS.map((e) => [e.language, e]));
48
+
49
+ /**
50
+ * `coverageTierForLanguage(language) -> entry | null`. Never fabricates —
51
+ * returns `null` for any language string not in the curated table above
52
+ * (including coverage.js's own `'unknown'` fallback), so a caller must
53
+ * decide its own honest default (coverage.js's ledger uses `'unknown'`).
54
+ */
55
+ export function coverageTierForLanguage(language) {
56
+ if (typeof language !== 'string' || language.length === 0) return null;
57
+ return _byLanguage.get(language) ?? null;
58
+ }