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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +779 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +2 -2
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/{271.index.js → 2271.index.js} +2 -2
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +379 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +5 -5
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +5 -5
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +2 -2
  35. package/dist/5051.index.js +841 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +3 -3
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +560 -189
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/dist/frontend/index.html +21 -0
  72. package/dist/frontend/src/app.js +176 -0
  73. package/dist/frontend/src/components/evidence-inspector.js +141 -0
  74. package/dist/frontend/src/components/filter-rail.js +119 -0
  75. package/dist/frontend/src/components/query-bar.js +126 -0
  76. package/dist/frontend/src/data/flagship-graph.js +1460 -0
  77. package/dist/frontend/src/export-entry.js +36 -0
  78. package/dist/frontend/src/lib/api-client.js +92 -0
  79. package/dist/frontend/src/lib/contrast.js +34 -0
  80. package/dist/frontend/src/lib/dom.js +24 -0
  81. package/dist/frontend/src/lib/escape-html.js +16 -0
  82. package/dist/frontend/src/lib/flow-path.js +40 -0
  83. package/dist/frontend/src/lib/focus-controls.js +149 -0
  84. package/dist/frontend/src/lib/protection-visual.js +46 -0
  85. package/dist/frontend/src/lib/query-language.js +240 -0
  86. package/dist/frontend/src/lib/row-filters.js +43 -0
  87. package/dist/frontend/src/lib/state.js +84 -0
  88. package/dist/frontend/src/main.js +83 -0
  89. package/dist/frontend/src/shell.js +184 -0
  90. package/dist/frontend/src/views/architecture-view.js +798 -0
  91. package/dist/frontend/src/views/inventory-view.js +292 -0
  92. package/dist/frontend/src/views/privacy-view.js +172 -0
  93. package/dist/frontend/src/views/trace-view.js +206 -0
  94. package/dist/frontend/styles/architecture-view.css +93 -0
  95. package/dist/frontend/styles/filter-rail.css +34 -0
  96. package/dist/frontend/styles/inspector.css +69 -0
  97. package/dist/frontend/styles/inventory-view.css +74 -0
  98. package/dist/frontend/styles/privacy-view.css +86 -0
  99. package/dist/frontend/styles/query-bar.css +107 -0
  100. package/dist/frontend/styles/shell.css +155 -0
  101. package/dist/frontend/styles/tokens.css +128 -0
  102. package/dist/frontend/styles/trace-view.css +95 -0
  103. package/package.json +13 -6
  104. package/src/dataflow/CLAUDE.md +1 -1
  105. package/src/dataflow/catalog.js +42 -0
  106. package/src/dataflow/orm-write-catalog.js +175 -0
  107. package/src/engine.js +92 -1
  108. package/src/ir/CLAUDE.md +1 -0
  109. package/src/ir/chrome-probe.mjs +150 -0
  110. package/src/ir/parser-js.js +94 -7
  111. package/src/lineage/CLAUDE.md +1203 -0
  112. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  113. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  114. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  115. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  116. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  117. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  118. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  119. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  120. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  121. package/src/lineage/classification.js +56 -0
  122. package/src/lineage/coverage.js +658 -0
  123. package/src/lineage/cross-repo-link.js +107 -0
  124. package/src/lineage/dataflow-graph.schema.json +184 -0
  125. package/src/lineage/decision-story.js +206 -0
  126. package/src/lineage/drift-policy.js +279 -0
  127. package/src/lineage/driver.js +135 -0
  128. package/src/lineage/engine.js +992 -0
  129. package/src/lineage/export-briefing.js +628 -0
  130. package/src/lineage/export-csv.js +62 -0
  131. package/src/lineage/export-json.js +238 -0
  132. package/src/lineage/export-privacy.js +258 -0
  133. package/src/lineage/federation-loader.js +111 -0
  134. package/src/lineage/field-identity.js +78 -0
  135. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  136. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  137. package/src/lineage/flow-grade.js +221 -0
  138. package/src/lineage/governance-edit.js +169 -0
  139. package/src/lineage/graph-builder.js +1114 -0
  140. package/src/lineage/graph-diff.js +431 -0
  141. package/src/lineage/graph-snapshot.js +180 -0
  142. package/src/lineage/handling-analyzer.js +168 -0
  143. package/src/lineage/ids.js +349 -0
  144. package/src/lineage/impact-assessment.js +76 -0
  145. package/src/lineage/impact-engine.js +268 -0
  146. package/src/lineage/index.js +281 -0
  147. package/src/lineage/language-coverage-tiers.js +58 -0
  148. package/src/lineage/obligation-mapping.js +126 -0
  149. package/src/lineage/obligation-predicates.js +235 -0
  150. package/src/lineage/observation-adapters.js +282 -0
  151. package/src/lineage/observation-correlation.js +622 -0
  152. package/src/lineage/observation-store.js +497 -0
  153. package/src/lineage/path-query.js +410 -0
  154. package/src/lineage/path-store.js +400 -0
  155. package/src/lineage/protection.js +53 -0
  156. package/src/lineage/recipient-profile.js +192 -0
  157. package/src/lineage/recipient-registry.js +394 -0
  158. package/src/lineage/redact-graph.js +224 -0
  159. package/src/lineage/remediation.js +417 -0
  160. package/src/lineage/resolve-destination.js +91 -0
  161. package/src/lineage/runtime-observation.js +464 -0
  162. package/src/lineage/scenario-diff.js +84 -0
  163. package/src/lineage/scenario-engine.js +251 -0
  164. package/src/lineage/scenario.js +101 -0
  165. package/src/lineage/schema.js +167 -0
  166. package/src/lineage/sink-registry.js +427 -0
  167. package/src/lineage/source-registry.js +357 -0
  168. package/src/lineage/source-seeding.js +212 -0
  169. package/src/lineage/summaries.js +590 -0
  170. package/src/lineage/transform-catalog.js +397 -0
  171. package/src/lineage/transit-protection.js +150 -0
  172. package/src/lineage/validate.js +285 -0
  173. package/src/mcp/CLAUDE.md +7 -1
  174. package/src/mcp/dataflow-tools.js +160 -0
  175. package/src/mcp/server.js +1 -1
  176. package/src/mcp/tools.js +2 -1
  177. package/src/pipeline/scan-health.js +19 -1
  178. package/src/posture/CLAUDE.md +19 -0
  179. package/src/posture/artifact-registry.js +52 -0
  180. package/src/posture/auditor-walkthrough.js +76 -0
  181. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  182. package/src/posture/obligation-evidence-pack.js +202 -0
  183. package/src/posture/remediation-ledger.js +337 -0
  184. package/src/server/CLAUDE.md +47 -0
  185. package/src/server/graph-loader.js +141 -0
  186. package/src/server/http-server.js +325 -0
  187. package/src/server/routes.js +129 -0
  188. package/src/server/security.js +111 -0
  189. package/src/server/static-assets.js +144 -0
  190. package/src/shared/frontend-root.js +52 -0
  191. package/dist/11.index.js +0 -353
  192. package/dist/259.index.js +0 -975
  193. package/dist/317.index.js +0 -300
  194. package/dist/609.index.js +0 -741
  195. package/dist/838.index.js +0 -152
@@ -0,0 +1,431 @@
1
+ // graph-diff.js — M4 deliverable #9 (FR-503 §14, DFG-022), sub-project
2
+ // 8b, Task 1: the GraphDiff extension contract + computeGraphDiff + honest
3
+ // change-cause classification.
4
+ //
5
+ // Mirrors graph-snapshot.js's/obligation-mapping.js's/decision-story.js's
6
+ // own shape exactly: a record is explicitly NOT a DataFlowGraph v1 entity
7
+ // (§10.10 — "associated with, but not required inside, the immutable base
8
+ // graph"), never added to dataflow-graph.schema.json, never routed
9
+ // through validate.js's validateGraph(). See ids.js's diffId() for the id
10
+ // scheme.
11
+ //
12
+ // This module ONLY computes a diff between two already-persisted
13
+ // GraphSnapshot records and classifies why each removed flow disappeared,
14
+ // as far as real, checkable signals allow. It does NOT decide what a
15
+ // caller should DO with a GraphDiff (no CLI, no drift-policy matching —
16
+ // see this sub-project's own task brief: those are Tasks 2/3).
17
+ //
18
+ // ── Design judgment calls made in this file (disclosed, not hidden) ──
19
+ //
20
+ // 1. WATCHED_FLOW_FIELDS includes `governanceRefs`, not just the four
21
+ // scalar fields the task brief names literally
22
+ // (protectionSummary/policyVerdict/handling/coverageStatus). Read
23
+ // directly against dataflow-graph.schema.json's own flow $def (§145 of
24
+ // that file) and graph-builder.js's real flow-construction loop:
25
+ // `flow.governanceRefs` is a real, populated field (coverage.js's
26
+ // `resolveGovernanceRefs` default, keyed by
27
+ // dataflow/privacy-governance.js's own GOVERNANCE_FIELDS —
28
+ // purpose/lawfulBasis/subject/retention/residency/recipient/transfer/
29
+ // minimization/consent/access/deletion), never a fabricated shape. A
30
+ // "retention/deletion or lawful-basis change" — AC-27's own bullet
31
+ // list — can ONLY show up as a governanceRefs difference, since none
32
+ // of the other four watched fields carry that information at all. The
33
+ // diff compares it value-by-value over GOVERNANCE_FIELDS (never a
34
+ // bare object-identity/JSON-string check), emitting one changed-field
35
+ // entry per differing governance field (`governanceRefs.<field>`), so
36
+ // a caller learns WHICH governance fact changed, not just "something
37
+ // in governance changed."
38
+ //
39
+ // 2. Non-flow removed entities (nodes/edges/dataElements) always get
40
+ // causeClassification: 'application_change'. The task brief's Global
41
+ // Constraint says the coverage-regression check applies "per removed
42
+ // flow entry ONLY" — read literally, that leaves open what a removed
43
+ // node/edge/dataElement gets. The same reasoning the brief gives for
44
+ // added/changed entries applies here too ("no other real signal
45
+ // exists... do not invent one") — a removed node/edge/dataElement has
46
+ // no flow-level protection/policy verdict to have regressed, and this
47
+ // module has no coverage-completeness signal scoped to a bare node or
48
+ // edge (coverage.js's own ledger is source/sink/language-level, not
49
+ // node/edge-level) — so 'application_change' is the only honest
50
+ // default for those three entity kinds.
51
+ //
52
+ // 3. Change-cause classification for flows.changed entries is always
53
+ // 'application_change', per the brief's own literal instruction — a
54
+ // CHANGED flow (same id, different watched field) can never be a
55
+ // coverage regression by definition: coverage regression means the
56
+ // engine stopped seeing something, and a flow whose id is still
57
+ // present in both snapshots was, by construction, seen in both.
58
+ //
59
+ // 4. Reidentification (fix round 1, Important 1). flowId's own
60
+ // discriminator (graph-builder.js's `ids.flowId(src.id, snk.id,
61
+ // [de.id], [p.shape, g.grade, sortedT.join(',')])` call site) includes
62
+ // `evidenceGrade`/`transformationIds` — neither is an application-level
63
+ // fact; `evidenceGrade` in particular is the analyzer's own confidence
64
+ // in the flow. When either changes between two scans of literally the
65
+ // same real-world (source, sink, dataElementIds) flow — the commonest
66
+ // cause is a refactor the analyzer resolves less/more precisely — the
67
+ // flow mints a NEW flowId, and a naive id-set diff would report it as
68
+ // an unrelated remove+add pair: a fabricated `firstSeen` on the added
69
+ // entry, and a `removed` entry reading as "this flow went away" when it
70
+ // did not. `_flowCorrelationKey` (below) correlates a removed/added
71
+ // pair by REAL application identity — (source, sink, sorted
72
+ // dataElementIds) — instead, and computeGraphDiff marks a correlated
73
+ // pair `causeClassification: 'reidentified'` with a pointer
74
+ // (`reidentifiedFrom`/`reidentifiedTo`) at its counterpart, rather than
75
+ // silently treating them as independent. This never substitutes for
76
+ // flowId identity anywhere else in this file — it exists ONLY to
77
+ // detect this one reidentification case.
78
+ //
79
+ // A direct, disclosed consequence: `WATCHED_FLOW_FIELDS`'s `handling`
80
+ // entry is near-unreachable in the `changed.flows` bucket for the same
81
+ // root cause — a `handling` change almost always also changes
82
+ // `transformationIds`, which is itself part of flowId's own
83
+ // discriminator, so it manifests as a reidentification (remove+add,
84
+ // correlated) rather than surviving as a same-id `changed.flows` entry.
85
+ // This is a real, disclosed limitation of the current flowId shape, not
86
+ // a bug in this file.
87
+
88
+ import { snapshotsComparable } from './graph-snapshot.js';
89
+ import { diffId } from './ids.js';
90
+ import { GOVERNANCE_FIELDS } from '../dataflow/privacy-governance.js';
91
+
92
+ const DIFF_VERSION = '1.0.0';
93
+
94
+ const ENTITY_ARRAYS = Object.freeze(['nodes', 'edges', 'dataElements', 'flows']);
95
+
96
+ // See judgment call #1 above for why `governanceRefs` is included
97
+ // alongside the brief's own literal four.
98
+ export const WATCHED_FLOW_FIELDS = Object.freeze([
99
+ 'protectionSummary', 'policyVerdict', 'handling', 'coverageStatus', 'governanceRefs',
100
+ ]);
101
+
102
+ function _isNonEmptyString(v) { return typeof v === 'string' && v.length > 0; }
103
+ function _isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
104
+
105
+ function _idMap(arr) {
106
+ const m = new Map();
107
+ for (const e of arr ?? []) {
108
+ if (e && typeof e.id === 'string') m.set(e.id, e);
109
+ }
110
+ return m;
111
+ }
112
+
113
+ /** Sorted id-set diff between two id->entity Maps. Deterministic order. */
114
+ function _idSetDiff(beforeMap, afterMap) {
115
+ const added = [];
116
+ const removed = [];
117
+ for (const id of afterMap.keys()) if (!beforeMap.has(id)) added.push(id);
118
+ for (const id of beforeMap.keys()) if (!afterMap.has(id)) removed.push(id);
119
+ added.sort();
120
+ removed.sort();
121
+ return { added, removed };
122
+ }
123
+
124
+ // Correlates a flow across a commit boundary by APPLICATION identity
125
+ // (source, sink, dataElementIds) — deliberately NOT by flowId, since
126
+ // flowId's own discriminator includes evidenceGrade/transformationIds
127
+ // (engine-confidence/shape signals, not application facts — see
128
+ // graph-builder.js's ids.flowId call site), so the SAME real-world flow
129
+ // can legitimately mint two different ids across two scans. Never a
130
+ // substitute for flowId identity elsewhere in this file — used ONLY to
131
+ // detect this one specific reidentification case.
132
+ function _flowCorrelationKey(flow) {
133
+ const des = Array.isArray(flow.dataElementIds) ? [...flow.dataElementIds].sort() : [];
134
+ return `${flow.source}|${flow.sink}|${des.join(',')}`;
135
+ }
136
+
137
+ function _addedEntry(id, afterSnapshot) {
138
+ return {
139
+ id,
140
+ causeClassification: 'application_change',
141
+ firstSeen: { commit: afterSnapshot.commit, capturedAt: afterSnapshot.capturedAt },
142
+ };
143
+ }
144
+
145
+ function _removedEntry(id, beforeSnapshot, causeClassification, extra = {}) {
146
+ return {
147
+ id,
148
+ causeClassification,
149
+ lastSeen: { commit: beforeSnapshot.commit, capturedAt: beforeSnapshot.capturedAt },
150
+ ...extra,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Diffs one flow's WATCHED_FLOW_FIELDS between two snapshots of the SAME
156
+ * flow id. Returns an array of {field, before, after} — empty when
157
+ * nothing watched differs. Never a bare "something changed" — every
158
+ * entry names the exact field and its real before/after value.
159
+ */
160
+ function _diffFlowFields(before, after) {
161
+ const changes = [];
162
+ for (const field of WATCHED_FLOW_FIELDS) {
163
+ if (field === 'governanceRefs') {
164
+ const b = before.governanceRefs ?? {};
165
+ const a = after.governanceRefs ?? {};
166
+ for (const gField of GOVERNANCE_FIELDS) {
167
+ const bv = b[gField] ?? null;
168
+ const av = a[gField] ?? null;
169
+ if (JSON.stringify(bv) !== JSON.stringify(av)) {
170
+ changes.push({ field: `governanceRefs.${gField}`, before: bv, after: av });
171
+ }
172
+ }
173
+ continue;
174
+ }
175
+ const bv = before[field] ?? null;
176
+ const av = after[field] ?? null;
177
+ if (bv !== av) changes.push({ field, before: bv, after: av });
178
+ }
179
+ return changes;
180
+ }
181
+
182
+ /**
183
+ * Real, checkable coverage-completeness signals — read directly against
184
+ * coverage.js's own buildCoverageLedger output shape: `sources.matched`,
185
+ * `sinks.callStatementSites`, `languages[].filesAnalyzed`. Returns an
186
+ * array of reason strings naming exactly which completeness field
187
+ * regressed (empty when none did — never a guess in the regression
188
+ * direction).
189
+ */
190
+ function _coverageRegressionReasons(coverageBefore, coverageAfter) {
191
+ const b = coverageBefore ?? {};
192
+ const a = coverageAfter ?? {};
193
+ const reasons = [];
194
+
195
+ const beforeMatched = b.sources?.matched ?? 0;
196
+ const afterMatched = a.sources?.matched ?? 0;
197
+ if (afterMatched < beforeMatched) {
198
+ reasons.push(`sources.matched decreased (${beforeMatched} -> ${afterMatched})`);
199
+ }
200
+
201
+ const beforeSites = b.sinks?.callStatementSites ?? 0;
202
+ const afterSites = a.sinks?.callStatementSites ?? 0;
203
+ if (afterSites < beforeSites) {
204
+ reasons.push(`sinks.callStatementSites decreased (${beforeSites} -> ${afterSites})`);
205
+ }
206
+
207
+ const beforeLangs = new Map((b.languages ?? []).map((l) => [l.language, l.filesAnalyzed]));
208
+ for (const l of a.languages ?? []) {
209
+ const prevAnalyzed = beforeLangs.get(l.language);
210
+ if (prevAnalyzed != null && l.filesAnalyzed < prevAnalyzed) {
211
+ reasons.push(`languages.${l.language}.filesAnalyzed decreased (${prevAnalyzed} -> ${l.filesAnalyzed})`);
212
+ }
213
+ }
214
+
215
+ return reasons;
216
+ }
217
+
218
+ /**
219
+ * Structural validation only — mirrors graph-snapshot.js's own
220
+ * validateGraphSnapshot {valid, errors} shape and "never throws"
221
+ * contract. Checks the record's own top-level shape plus each
222
+ * added/removed/changed entry's own minimal shape; never cross-references
223
+ * a real graph (this module's validator, like every §10.10 extension
224
+ * contract's own validator, is a pure shape check).
225
+ */
226
+ export function validateGraphDiff(record) {
227
+ const errors = [];
228
+ const err = (p, message) => errors.push({ path: p, message });
229
+
230
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
231
+ err('$', 'GraphDiff record must be an object');
232
+ return { valid: false, errors };
233
+ }
234
+
235
+ if (!_isNonEmptyString(record.id) || !record.id.startsWith('diff:')) {
236
+ err('$.id', 'id is required and must start with "diff:"');
237
+ }
238
+ if (!_isNonEmptyString(record.version)) err('$.version', 'version is required');
239
+ if (!_isNonEmptyString(record.beforeSnapshotId)) err('$.beforeSnapshotId', 'beforeSnapshotId is required');
240
+ if (!_isNonEmptyString(record.afterSnapshotId)) err('$.afterSnapshotId', 'afterSnapshotId is required');
241
+ if (!_isNonEmptyString(record.generatedAt)) err('$.generatedAt', 'generatedAt is required');
242
+
243
+ if (!_isPlainObject(record.comparability)) {
244
+ err('$.comparability', 'comparability is required and must be an object');
245
+ } else {
246
+ if (typeof record.comparability.comparable !== 'boolean') {
247
+ err('$.comparability.comparable', 'comparability.comparable must be a boolean');
248
+ }
249
+ if (!Array.isArray(record.comparability.reasons)) {
250
+ err('$.comparability.reasons', 'comparability.reasons must be an array');
251
+ }
252
+ }
253
+
254
+ const _checkEntryArray = (bucketPath, arr, requireFirstSeen, requireLastSeen) => {
255
+ if (!Array.isArray(arr)) {
256
+ err(bucketPath, `${bucketPath} must be an array`);
257
+ return;
258
+ }
259
+ arr.forEach((entry, i) => {
260
+ const p = `${bucketPath}[${i}]`;
261
+ if (!_isPlainObject(entry)) { err(p, `${p} must be an object`); return; }
262
+ if (!_isNonEmptyString(entry.id)) err(`${p}.id`, `${p}.id is required`);
263
+ if (!_isNonEmptyString(entry.causeClassification)) err(`${p}.causeClassification`, `${p}.causeClassification is required`);
264
+ if (requireFirstSeen && !_isPlainObject(entry.firstSeen)) err(`${p}.firstSeen`, `${p}.firstSeen is required and must be an object`);
265
+ if (requireLastSeen && !_isPlainObject(entry.lastSeen)) err(`${p}.lastSeen`, `${p}.lastSeen is required and must be an object`);
266
+ });
267
+ };
268
+
269
+ if (!_isPlainObject(record.added)) {
270
+ err('$.added', 'added is required and must be an object');
271
+ } else {
272
+ for (const key of ENTITY_ARRAYS) _checkEntryArray(`$.added.${key}`, record.added[key], true, false);
273
+ }
274
+
275
+ if (!_isPlainObject(record.removed)) {
276
+ err('$.removed', 'removed is required and must be an object');
277
+ } else {
278
+ for (const key of ENTITY_ARRAYS) _checkEntryArray(`$.removed.${key}`, record.removed[key], false, true);
279
+ }
280
+
281
+ if (!_isPlainObject(record.changed)) {
282
+ err('$.changed', 'changed is required and must be an object');
283
+ } else if (!Array.isArray(record.changed.flows)) {
284
+ err('$.changed.flows', 'changed.flows must be an array');
285
+ } else {
286
+ record.changed.flows.forEach((entry, i) => {
287
+ const p = `$.changed.flows[${i}]`;
288
+ if (!_isPlainObject(entry)) { err(p, `${p} must be an object`); return; }
289
+ if (!_isNonEmptyString(entry.id)) err(`${p}.id`, `${p}.id is required`);
290
+ if (!_isNonEmptyString(entry.causeClassification)) err(`${p}.causeClassification`, `${p}.causeClassification is required`);
291
+ if (!Array.isArray(entry.changes) || entry.changes.length === 0) {
292
+ err(`${p}.changes`, `${p}.changes must be a non-empty array naming which field(s) changed`);
293
+ }
294
+ });
295
+ }
296
+
297
+ return { valid: errors.length === 0, errors };
298
+ }
299
+
300
+ /**
301
+ * Diffs two GraphSnapshot records. Refuses (throws, naming the real
302
+ * reasons) on an incomparable pair — never silently diffs across one.
303
+ *
304
+ * added/removed: an ID-set diff across nodes/edges/dataElements/flows.
305
+ * changed: flows ONLY — every flow id present in both snapshots whose
306
+ * WATCHED_FLOW_FIELDS differ.
307
+ *
308
+ * Change-cause classification: every added/changed entry gets
309
+ * 'application_change' (no other real signal exists for those — see this
310
+ * file's own header). Every removed FLOW entry gets
311
+ * 'possible_coverage_regression' when the AFTER snapshot's real coverage
312
+ * is measurably lower on a real completeness field, otherwise
313
+ * 'application_change'; every removed node/edge/dataElement entry gets
314
+ * 'application_change' unconditionally (see judgment call #2 above).
315
+ */
316
+ export function computeGraphDiff(snapshotBefore, snapshotAfter, opts = {}) {
317
+ const { comparable, reasons } = snapshotsComparable(snapshotBefore, snapshotAfter);
318
+ if (!comparable) {
319
+ throw new Error(`computeGraphDiff: snapshots are not comparable — ${reasons.join('; ')}`);
320
+ }
321
+
322
+ const graphBefore = snapshotBefore.graph ?? {};
323
+ const graphAfter = snapshotAfter.graph ?? {};
324
+
325
+ const coverageRegressionReasons = _coverageRegressionReasons(snapshotBefore.coverage, snapshotAfter.coverage);
326
+ const flowRemovalCause = coverageRegressionReasons.length ? 'possible_coverage_regression' : 'application_change';
327
+
328
+ const added = {};
329
+ const removed = {};
330
+ const flowMaps = {};
331
+
332
+ for (const key of ENTITY_ARRAYS) {
333
+ const beforeMap = _idMap(graphBefore[key]);
334
+ const afterMap = _idMap(graphAfter[key]);
335
+ if (key === 'flows') {
336
+ flowMaps.before = beforeMap;
337
+ flowMaps.after = afterMap;
338
+ }
339
+
340
+ const { added: addedIds, removed: removedIds } = _idSetDiff(beforeMap, afterMap);
341
+
342
+ // Reidentification pairing (see judgment call #4 in this file's own
343
+ // header for the full reasoning): group removed/added flow ids by
344
+ // _flowCorrelationKey, pair them 1:1 (sorted by id, index-aligned)
345
+ // within each matching key. A pair is a re-identification, not a real
346
+ // remove+add. Any surplus on either side (uncommon — would need >1
347
+ // flow sharing the same correlation key on one side) falls through to
348
+ // the existing add/remove logic unchanged.
349
+ const reidentifiedFrom = new Map(); // addedId -> removedId
350
+ const reidentifiedTo = new Map(); // removedId -> addedId
351
+ if (key === 'flows') {
352
+ const removedByKey = new Map();
353
+ for (const id of removedIds) {
354
+ const f = beforeMap.get(id);
355
+ const k = _flowCorrelationKey(f);
356
+ if (!removedByKey.has(k)) removedByKey.set(k, []);
357
+ removedByKey.get(k).push(id);
358
+ }
359
+ const addedByKey = new Map();
360
+ for (const id of addedIds) {
361
+ const f = afterMap.get(id);
362
+ const k = _flowCorrelationKey(f);
363
+ if (!addedByKey.has(k)) addedByKey.set(k, []);
364
+ addedByKey.get(k).push(id);
365
+ }
366
+ for (const [k, removedGroup] of removedByKey) {
367
+ const addedGroup = addedByKey.get(k);
368
+ if (!addedGroup) continue;
369
+ removedGroup.sort();
370
+ addedGroup.sort();
371
+ const n = Math.min(removedGroup.length, addedGroup.length);
372
+ for (let i = 0; i < n; i++) {
373
+ reidentifiedTo.set(removedGroup[i], addedGroup[i]);
374
+ reidentifiedFrom.set(addedGroup[i], removedGroup[i]);
375
+ }
376
+ }
377
+ }
378
+
379
+ added[key] = addedIds.map((id) => {
380
+ const entry = _addedEntry(id, snapshotAfter);
381
+ if (key === 'flows' && reidentifiedFrom.has(id)) {
382
+ entry.causeClassification = 'reidentified';
383
+ entry.reidentifiedFrom = reidentifiedFrom.get(id);
384
+ }
385
+ return entry;
386
+ });
387
+
388
+ if (key === 'flows') {
389
+ removed[key] = removedIds.map((id) => {
390
+ const extra = coverageRegressionReasons.length ? { coverageRegressionReasons } : {};
391
+ const entry = _removedEntry(id, snapshotBefore, flowRemovalCause, extra);
392
+ if (reidentifiedTo.has(id)) {
393
+ entry.causeClassification = 'reidentified';
394
+ entry.reidentifiedTo = reidentifiedTo.get(id);
395
+ }
396
+ return entry;
397
+ });
398
+ } else {
399
+ removed[key] = removedIds.map((id) => _removedEntry(id, snapshotBefore, 'application_change'));
400
+ }
401
+ }
402
+
403
+ const changedFlows = [];
404
+ for (const [id, beforeFlow] of flowMaps.before) {
405
+ const afterFlow = flowMaps.after.get(id);
406
+ if (!afterFlow) continue; // removed — already handled above
407
+ const changes = _diffFlowFields(beforeFlow, afterFlow);
408
+ if (changes.length > 0) {
409
+ changedFlows.push({ id, causeClassification: 'application_change', changes });
410
+ }
411
+ }
412
+ changedFlows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
413
+
414
+ const record = {
415
+ id: diffId({ beforeSnapshotId: snapshotBefore.id, afterSnapshotId: snapshotAfter.id }),
416
+ version: DIFF_VERSION,
417
+ beforeSnapshotId: snapshotBefore.id,
418
+ afterSnapshotId: snapshotAfter.id,
419
+ comparability: { comparable: true, reasons: [] },
420
+ added,
421
+ removed,
422
+ changed: { flows: changedFlows },
423
+ generatedAt: opts.generatedAt ?? new Date().toISOString(),
424
+ };
425
+
426
+ const { valid, errors } = validateGraphDiff(record);
427
+ if (!valid) {
428
+ throw new Error(`computeGraphDiff: internal error — produced an invalid GraphDiff: ${JSON.stringify(errors)}`);
429
+ }
430
+ return record;
431
+ }
@@ -0,0 +1,180 @@
1
+ // graph-snapshot.js — M4 deliverable #8 (FR-503 §14, DFG-022): the
2
+ // GraphSnapshot extension contract + persist/load history + a
3
+ // comparability check.
4
+ //
5
+ // Mirrors obligation-mapping.js/decision-story.js's own contract shape
6
+ // exactly: a record is explicitly NOT a DataFlowGraph v1 entity (§10.10
7
+ // — "associated with, but not required inside, the immutable base
8
+ // graph"), never added to dataflow-graph.schema.json, never routed
9
+ // through validate.js.
10
+ //
11
+ // Persistence mirrors posture/sbom-diff.js's own proven architecture —
12
+ // persist-by-git-commit, most-recent-by-mtime lookup — the one real,
13
+ // already-shipped precedent in this codebase for "compare two scans."
14
+ // Not reused code (sbom-diff.js operates on flat SBOM components, not
15
+ // graph entities) but the same architecture, deliberately.
16
+ //
17
+ // A real, load-bearing gap found during scoping: engine.js's own call
18
+ // site to buildLineageGraph never passes opts.commit, so every real
19
+ // graph's own graphId today embeds the literal string 'uncommitted'
20
+ // regardless of the repo's real git state. Snapshot keying CANNOT read
21
+ // graph.graphId for the commit — it resolves the real git HEAD
22
+ // independently, exactly like sbom-diff.js's own _gitHead helper.
23
+
24
+ import * as fs from 'node:fs';
25
+ import * as path from 'node:path';
26
+ import * as crypto from 'node:crypto';
27
+ import { execFileSync } from 'node:child_process';
28
+ import { statePath, stateWritesEnabled } from '../posture/state-dir.js';
29
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
30
+ import { snapshotId } from './ids.js';
31
+
32
+ const SNAPSHOT_VERSION = '1.0.0';
33
+ const HISTORY_DIR = 'lineage-snapshots';
34
+
35
+ function _historyDir(scanRoot) {
36
+ return statePath(scanRoot, HISTORY_DIR);
37
+ }
38
+
39
+ // scanRoot is the SCANNED project's repository, not this project's own
40
+ // checkout — hardened per the same FR-PROV-024 precedent sbom-diff.js's
41
+ // own _gitHead already established. rev-parse HEAD touches neither the
42
+ // working tree nor the index, so this is read-only.
43
+ function _gitHead(scanRoot) {
44
+ try {
45
+ return execFileSync('git', hardenGitArgs(['rev-parse', 'HEAD']), {
46
+ cwd: scanRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: hardenGitEnv(),
47
+ }).trim();
48
+ } catch { return null; }
49
+ }
50
+
51
+ function _isNonEmptyString(v) { return typeof v === 'string' && v.length > 0; }
52
+ function _isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
53
+
54
+ /**
55
+ * Structural validation only — mirrors
56
+ * obligation-mapping.js#validateObligationMapping's own {valid, errors}
57
+ * shape and "never throws" contract.
58
+ */
59
+ export function validateGraphSnapshot(record) {
60
+ const errors = [];
61
+ const err = (p, message) => errors.push({ path: p, message });
62
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
63
+ err('$', 'GraphSnapshot record must be an object');
64
+ return { valid: false, errors };
65
+ }
66
+ if (!_isNonEmptyString(record.id) || !record.id.startsWith('snapshot:')) {
67
+ err('$.id', 'id is required and must start with "snapshot:"');
68
+ }
69
+ if (!_isNonEmptyString(record.version)) err('$.version', 'version is required');
70
+ if (!_isNonEmptyString(record.graphId)) err('$.graphId', 'graphId is required');
71
+ if (!_isNonEmptyString(record.schemaVersion)) err('$.schemaVersion', 'schemaVersion is required');
72
+ if (!_isNonEmptyString(record.commit)) err('$.commit', 'commit is required');
73
+ if (!_isNonEmptyString(record.capturedAt)) err('$.capturedAt', 'capturedAt is required');
74
+ if (!_isPlainObject(record.coverage)) err('$.coverage', 'coverage is required and must be an object');
75
+ if (!_isPlainObject(record.graph)) err('$.graph', 'graph is required and must be an object');
76
+ return { valid: errors.length === 0, errors };
77
+ }
78
+
79
+ /**
80
+ * Build `graph` as a GraphSnapshot record, keyed by the REAL current git
81
+ * HEAD of `scanRoot` (never `graph.graphId`'s own commit component —
82
+ * see this file's own header). Falls back to a content hash of the
83
+ * graph when no git repo is present, matching sbom-diff.js's own
84
+ * precedent. opts.capturedAt overrides wall-clock time (deterministic
85
+ * test fixtures, mirroring every other M4 exporter's own opts.generatedAt
86
+ * convention). Zero disk I/O — validates and returns the snapshot,
87
+ * never writes it.
88
+ */
89
+ export function buildGraphSnapshot(graph, scanRoot, opts = {}) {
90
+ const commit = _gitHead(scanRoot) || crypto.createHash('sha256').update(JSON.stringify(graph)).digest('hex').slice(0, 12);
91
+ const capturedAt = opts.capturedAt ?? new Date().toISOString();
92
+ const snapshot = {
93
+ id: snapshotId({ graphId: graph.graphId, commit, capturedAt }),
94
+ version: SNAPSHOT_VERSION,
95
+ graphId: graph.graphId,
96
+ schemaVersion: graph.schemaVersion,
97
+ commit,
98
+ capturedAt,
99
+ coverage: graph.coverage ?? {},
100
+ graph,
101
+ };
102
+ const { valid, errors } = validateGraphSnapshot(snapshot);
103
+ if (!valid) {
104
+ throw new Error(`buildGraphSnapshot: internal error — produced an invalid GraphSnapshot: ${JSON.stringify(errors)}`);
105
+ }
106
+ return snapshot;
107
+ }
108
+
109
+ /**
110
+ * Persist `graph` as a GraphSnapshot, keyed by the REAL current git
111
+ * HEAD of `scanRoot` (never `graph.graphId`'s own commit component —
112
+ * see this file's own header). Falls back to a content hash of the
113
+ * graph when no git repo is present, matching sbom-diff.js's own
114
+ * precedent. opts.capturedAt overrides wall-clock time (deterministic
115
+ * test fixtures, mirroring every other M4 exporter's own opts.generatedAt
116
+ * convention).
117
+ */
118
+ export function persistGraphSnapshot(graph, scanRoot, opts = {}) {
119
+ const snapshot = buildGraphSnapshot(graph, scanRoot, opts);
120
+ if (stateWritesEnabled()) {
121
+ const dir = _historyDir(scanRoot);
122
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
123
+ try { fs.writeFileSync(path.join(dir, `${snapshot.commit}.json`), JSON.stringify(snapshot, null, 2)); } catch {}
124
+ }
125
+ return snapshot;
126
+ }
127
+
128
+ /** All persisted snapshots for scanRoot, newest first by mtime. Never
129
+ * throws — an empty/missing history directory returns []. */
130
+ export function loadSnapshots(scanRoot) {
131
+ const dir = _historyDir(scanRoot);
132
+ if (!fs.existsSync(dir)) return [];
133
+ let files;
134
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch { return []; }
135
+ const withMtime = files.map((f) => {
136
+ const full = path.join(dir, f);
137
+ let mtimeMs = 0;
138
+ try { mtimeMs = fs.statSync(full).mtimeMs; } catch {}
139
+ return { full, mtimeMs };
140
+ });
141
+ withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
142
+ const out = [];
143
+ for (const { full } of withMtime) {
144
+ try { out.push(JSON.parse(fs.readFileSync(full, 'utf8'))); } catch {}
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /** One snapshot by its commit key, or null. Never throws. */
150
+ export function loadSnapshot(scanRoot, commitKey) {
151
+ const dir = _historyDir(scanRoot);
152
+ const full = path.join(dir, `${commitKey}.json`);
153
+ if (!fs.existsSync(full)) return null;
154
+ try { return JSON.parse(fs.readFileSync(full, 'utf8')); } catch { return null; }
155
+ }
156
+
157
+ /** The newest persisted snapshot that is NOT excludeCommitKey — the
158
+ * default "compare against" target. Null if none exists. */
159
+ export function mostRecentPriorSnapshot(scanRoot, excludeCommitKey) {
160
+ const all = loadSnapshots(scanRoot);
161
+ return all.find((s) => s.commit !== excludeCommitKey) ?? null;
162
+ }
163
+
164
+ /**
165
+ * Are two snapshots validly diffable? Only ever reports true on a REAL,
166
+ * checkable signal — today, schemaVersion equality. The graphId
167
+ * configHash gap (see this sub-project's own scoping doc) is a real,
168
+ * disclosed limitation: two snapshots with the same schemaVersion but a
169
+ * genuinely different analyzer/config are reported comparable, since no
170
+ * real signal exists yet to detect that difference — never silently
171
+ * papered over, but not fabricated either.
172
+ */
173
+ export function snapshotsComparable(a, b) {
174
+ if (!a || !b) return { comparable: false, reasons: ['one or both snapshots are missing'] };
175
+ const reasons = [];
176
+ if (a.schemaVersion !== b.schemaVersion) {
177
+ reasons.push(`schemaVersion differs (${a.schemaVersion} vs ${b.schemaVersion})`);
178
+ }
179
+ return { comparable: reasons.length === 0, reasons };
180
+ }