@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,292 @@
1
+ // Inventory View: PRD §7.6's 11 required inventory categories. Split into
2
+ // pure computeInventoryViewModel() (each per-category compute function
3
+ // producing {columns, rows}) and thin renderInventoryView() (DOM-building
4
+ // via el()/clear()), mirroring every other view in src/views/.
5
+
6
+ import { worstVerdict } from '../lib/protection-visual.js';
7
+ import { AI_SUBTYPES } from '../lib/flow-path.js';
8
+ import { INVENTORY_TABLES } from '../lib/state.js';
9
+ import { el, clear } from '../lib/dom.js';
10
+ import { matchesFilters } from '../lib/row-filters.js';
11
+
12
+ const TABLE_LABELS = {
13
+ sources: 'Sources',
14
+ sinks: 'Sinks',
15
+ fields: 'Fields / data elements',
16
+ externalDestinations: 'External destinations',
17
+ stores: 'Stores',
18
+ aiSystems: 'AI systems & processing contexts',
19
+ transformations: 'Transformations',
20
+ unprotectedEdges: 'Unprotected or unknown edges',
21
+ policyPermittedFlows: 'Policy-permitted flows',
22
+ manualGovernanceGaps: 'Manual governance gaps',
23
+ unsupportedCandidates: 'Unsupported or unresolved candidates',
24
+ };
25
+
26
+ const UNPROTECTED_TIERS = new Set(['unprotected', 'mixed', 'unknown']);
27
+
28
+ function edgeWorstVerdict(edge) {
29
+ return worstVerdict([edge.protection.transit.verdict, edge.protection.atRest.verdict, edge.protection.handling.verdict]);
30
+ }
31
+
32
+ function nodeLabelFor(graph, nodeId) {
33
+ return graph.nodes.find((n) => n.id === nodeId)?.label ?? 'unknown';
34
+ }
35
+
36
+ // The same per-flow filter-facet properties privacy-view.js's
37
+ // computePrivacyRow() attaches, computed the same way (this flow's own
38
+ // resolved edges, aggregated per-dimension via worstVerdict()), for
39
+ // Inventory's two flow-shaped categories (policyPermittedFlows,
40
+ // manualGovernanceGaps' "Flow"-subject rows) only. worstVerdict() always
41
+ // returns a real verdict string (never null/undefined, even for an empty
42
+ // edge list), so the three verdict properties are always set
43
+ // unconditionally; sourceCategory/sinkCategory/destinationExternality are
44
+ // only set when a real, non-null value exists, per the same rule
45
+ // computePrivacyRow() follows.
46
+ function computeFlowFilterProperties(graph, flow) {
47
+ const pathEdges = flow.edgeIds.map((edgeId) => graph.edges.find((e) => e.id === edgeId)).filter(Boolean);
48
+ const sourceNode = graph.nodes.find((n) => n.id === flow.source);
49
+ const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
50
+ return {
51
+ transitVerdict: worstVerdict(pathEdges.map((e) => e.protection.transit.verdict)),
52
+ atRestVerdict: worstVerdict(pathEdges.map((e) => e.protection.atRest.verdict)),
53
+ handlingVerdict: worstVerdict(pathEdges.map((e) => e.protection.handling.verdict)),
54
+ policyVerdict: flow.policyVerdict,
55
+ ...(sourceNode?.subtype ? { sourceCategory: sourceNode.subtype } : {}),
56
+ ...(sinkNode?.subtype ? { sinkCategory: sinkNode.subtype } : {}),
57
+ ...(sinkNode?.externality?.value ? { destinationExternality: sinkNode.externality.value } : {}),
58
+ };
59
+ }
60
+
61
+ const TABLE_COMPUTE = {
62
+ sources: (graph) => ({
63
+ columns: ['Label', 'Category', 'Coverage', 'Externality'],
64
+ rows: graph.nodes.filter((n) => n.kind === 'source').map((n) => ({
65
+ id: n.id, selectableId: n.id,
66
+ cells: [n.label, n.subtype ?? '—', n.coverageStatus, n.externality?.value ?? 'unknown'],
67
+ })),
68
+ }),
69
+ sinks: (graph) => ({
70
+ columns: ['Label', 'Category', 'Coverage', 'Externality'],
71
+ rows: graph.nodes.filter((n) => n.kind === 'sink').map((n) => ({
72
+ id: n.id, selectableId: n.id,
73
+ cells: [n.label, n.subtype ?? '—', n.coverageStatus, n.externality?.value ?? 'unknown'],
74
+ })),
75
+ }),
76
+ fields: (graph) => ({
77
+ columns: ['Name', 'Data classes', 'AI contexts'],
78
+ rows: graph.dataElements.map((d) => ({
79
+ id: d.id, selectableId: d.id,
80
+ cells: [d.name, (d.dataClasses ?? []).join(', ') || '—', (d.aiContexts ?? []).join(', ') || '—'],
81
+ dataClasses: d.dataClasses ?? [],
82
+ })),
83
+ }),
84
+ externalDestinations: (graph) => ({
85
+ columns: ['Label', 'Kind', 'Resolution status', 'Literal value'],
86
+ rows: graph.nodes.filter((n) => n.externality?.value === 'external').map((n) => ({
87
+ id: n.id, selectableId: n.id,
88
+ cells: [n.label, n.kind, n.destination?.resolutionStatus ?? 'unknown', n.destination?.literalValue ?? '—'],
89
+ })),
90
+ }),
91
+ stores: (graph) => ({
92
+ columns: ['Label', 'Operation', 'Columns'],
93
+ rows: graph.nodes.filter((n) => n.kind === 'store').map((n) => ({
94
+ id: n.id, selectableId: n.id,
95
+ cells: [n.label, n.storeDetail?.operation ?? 'unknown', (n.storeDetail?.columns ?? []).join(', ') || '—'],
96
+ })),
97
+ }),
98
+ aiSystems: (graph) => {
99
+ const aiNodes = graph.nodes.filter((n) => n.subtype && AI_SUBTYPES.has(n.subtype)).map((n) => ({
100
+ id: n.id, selectableId: n.id,
101
+ cells: ['Node', n.label, n.subtype],
102
+ }));
103
+ const aiDataElements = graph.dataElements.filter((d) => (d.aiContexts ?? []).length > 0).map((d) => ({
104
+ id: d.id, selectableId: d.id,
105
+ cells: ['Data element', d.name, (d.aiContexts ?? []).join(', ')],
106
+ }));
107
+ return { columns: ['Subject', 'Label', 'Category / context'], rows: [...aiNodes, ...aiDataElements] };
108
+ },
109
+ transformations: (graph) => ({
110
+ columns: ['Kind', 'Reversibility'],
111
+ rows: graph.transformations.map((t) => ({
112
+ id: t.id, selectableId: t.id,
113
+ cells: [t.kind, t.reversibility],
114
+ })),
115
+ }),
116
+ unprotectedEdges: (graph) => ({
117
+ columns: ['From', 'To', 'Transit', 'At rest', 'Handling', 'Worst verdict'],
118
+ rows: graph.edges.filter((e) => UNPROTECTED_TIERS.has(edgeWorstVerdict(e))).map((e) => ({
119
+ id: e.id, selectableId: e.id,
120
+ cells: [nodeLabelFor(graph, e.from), nodeLabelFor(graph, e.to), e.protection.transit.verdict, e.protection.atRest.verdict, e.protection.handling.verdict, edgeWorstVerdict(e)],
121
+ })),
122
+ }),
123
+ policyPermittedFlows: (graph) => ({
124
+ columns: ['Field', 'Source', 'Sink', 'Policy verdict'],
125
+ rows: graph.flows.filter((f) => f.policyVerdict === 'permitted').map((f) => {
126
+ const dataElement = graph.dataElements.find((d) => f.dataElementIds.includes(d.id));
127
+ return {
128
+ id: f.id, selectableId: f.id,
129
+ cells: [dataElement?.name ?? 'unknown field', nodeLabelFor(graph, f.source), nodeLabelFor(graph, f.sink), f.policyVerdict],
130
+ dataClasses: dataElement?.dataClasses ?? [],
131
+ protectionSummary: f.protectionSummary,
132
+ ...computeFlowFilterProperties(graph, f),
133
+ };
134
+ }),
135
+ }),
136
+ manualGovernanceGaps: (graph) => {
137
+ const manualFlows = graph.flows.filter((f) => f.policyVerdict === 'manual_review_required').map((f) => {
138
+ const dataElement = graph.dataElements.find((d) => f.dataElementIds.includes(d.id));
139
+ return {
140
+ id: f.id, selectableId: f.id,
141
+ cells: ['Flow', dataElement?.name ?? 'unknown field', 'policyVerdict: manual_review_required'],
142
+ dataClasses: dataElement?.dataClasses ?? [],
143
+ protectionSummary: f.protectionSummary,
144
+ ...computeFlowFilterProperties(graph, f),
145
+ };
146
+ });
147
+ const manualNodes = graph.nodes.filter((n) => n.coverageStatus === 'manual').map((n) => ({
148
+ id: n.id, selectableId: n.id,
149
+ cells: ['Node', n.label, 'coverageStatus: manual'],
150
+ }));
151
+ const manualEdges = graph.edges.filter((e) => e.coverageStatus === 'manual').map((e) => ({
152
+ id: e.id, selectableId: e.id,
153
+ cells: ['Edge', `${nodeLabelFor(graph, e.from)} → ${nodeLabelFor(graph, e.to)}`, 'coverageStatus: manual'],
154
+ }));
155
+ return { columns: ['Subject', 'Label', 'Reason'], rows: [...manualFlows, ...manualNodes, ...manualEdges] };
156
+ },
157
+ unsupportedCandidates: (graph) => ({
158
+ columns: ['Label', 'Kind', 'Coverage status', 'Reason'],
159
+ rows: graph.nodes.filter((n) => n.kind === 'unresolved' || n.coverageStatus === 'unsupported' || n.coverageStatus === 'candidate').map((n) => ({
160
+ id: n.id, selectableId: n.id,
161
+ cells: [n.label, n.kind, n.coverageStatus, n.coverageReason ?? '—'],
162
+ })),
163
+ }),
164
+ };
165
+
166
+ // Categories whose rows carry the properties filter-rail.js's three chip
167
+ // groups filter on (dataClasses / protectionSummary / AI relevance). Wired
168
+ // per docs/superpowers/plans/2026-09-01-data-flow-explorer-m3-inventory-scoping.md's
169
+ // decision 4 — deliberately not every category; see that doc before
170
+ // changing this set.
171
+ const FILTERABLE_TABLES = new Set(['fields', 'policyPermittedFlows', 'manualGovernanceGaps']);
172
+
173
+ // Milestone 3, sub-project M3-UX-Query, Task 4. The query language's
174
+ // compileQuery predicate is fundamentally FLOW-scoped (query-language.js's
175
+ // own header comment: "the predicate operates on a FLOW"), but Inventory's
176
+ // 11 categories are a mix of flow/node/edge/dataElement/transformation rows.
177
+ // Rather than special-case each category, this looks up a real flow by the
178
+ // row's own id — which only resolves for rows whose `id` genuinely IS a
179
+ // flow id (policyPermittedFlows' rows, and manualGovernanceGaps' "Flow"-
180
+ // subject rows, both of which use `id: f.id`). A row with no corresponding
181
+ // flow (a source/sink/store/dataElement/transformation/node/edge row) has
182
+ // nothing for the query predicate to test against, so the query has no
183
+ // effect there and the row's visibility is governed only by the existing
184
+ // dataClass/protection filters — an honest, disclosed scope limitation, not
185
+ // a bug: this DSL was never designed to describe a bare node or edge.
186
+ function rowMatchesQuery(graph, row, queryPredicate) {
187
+ if (!queryPredicate) return true;
188
+ const flow = graph.flows.find((f) => f.id === row.id);
189
+ if (!flow) return true;
190
+ return queryPredicate(flow);
191
+ }
192
+
193
+ /**
194
+ * @param {object} graph
195
+ * @param {object} state
196
+ * @param {((flow: object) => boolean) | null} [queryPredicate] - see
197
+ * rowMatchesQuery's own comment above. Applied as an ADDITIONAL condition
198
+ * alongside the existing dataClass/protection filters, independent of
199
+ * whether the active table is in FILTERABLE_TABLES (query narrowing is
200
+ * orthogonal to the filter-rail's own chip-based facets). Omitted/null
201
+ * (every pre-existing caller/test) matches every row, so behavior is
202
+ * unchanged for anyone not passing it.
203
+ */
204
+ export function computeInventoryViewModel(graph, state, queryPredicate = null) {
205
+ const activeTable = INVENTORY_TABLES.includes(state.table) ? state.table : INVENTORY_TABLES[0];
206
+ const tables = INVENTORY_TABLES.map((id) => ({
207
+ id, label: TABLE_LABELS[id],
208
+ count: TABLE_COMPUTE[id](graph).rows.length,
209
+ }));
210
+
211
+ const { columns, rows: rawRows } = TABLE_COMPUTE[activeTable](graph);
212
+ const filterable = FILTERABLE_TABLES.has(activeTable);
213
+ const rows = rawRows.map((row) => ({
214
+ ...row,
215
+ selected: row.id === state.selectedId,
216
+ visible: (filterable ? matchesFilters(row, state.filters ?? {}) : true) && rowMatchesQuery(graph, row, queryPredicate),
217
+ }));
218
+
219
+ return { tables, activeTable, columns, rows, filterable };
220
+ }
221
+
222
+ function renderSubNav(viewModel, onTableChange) {
223
+ const buttons = viewModel.tables.map((t) =>
224
+ el(
225
+ 'button',
226
+ {
227
+ class: 'inventory-subnav-button',
228
+ 'data-table-id': t.id,
229
+ 'data-active': String(t.id === viewModel.activeTable),
230
+ 'aria-pressed': String(t.id === viewModel.activeTable),
231
+ onClick: () => onTableChange(t.id),
232
+ },
233
+ `${t.label} (${t.count})`,
234
+ ),
235
+ );
236
+ return el('div', { class: 'inventory-subnav' }, buttons);
237
+ }
238
+
239
+ function renderRow(row, onSelect) {
240
+ return el(
241
+ 'tr',
242
+ {
243
+ class: 'inventory-row',
244
+ 'data-selected': String(row.selected),
245
+ 'data-visible': String(row.visible),
246
+ tabindex: '0',
247
+ role: 'button',
248
+ 'aria-label': `${row.cells[0]}${row.selected ? ', selected' : ''}`,
249
+ onClick: () => row.selectableId && onSelect(row.selectableId),
250
+ onKeydown: (evt) => {
251
+ if ((evt.key === 'Enter' || evt.key === ' ') && row.selectableId) {
252
+ evt.preventDefault();
253
+ onSelect(row.selectableId);
254
+ }
255
+ },
256
+ },
257
+ row.cells.map((cellText) => el('td', {}, cellText)),
258
+ );
259
+ }
260
+
261
+ function sortRows(rows, columnIndex, direction) {
262
+ const sorted = [...rows].sort((a, b) => {
263
+ const cmp = String(a.cells[columnIndex]).localeCompare(String(b.cells[columnIndex]));
264
+ return direction === 'asc' ? cmp : -cmp;
265
+ });
266
+ return sorted;
267
+ }
268
+
269
+ /**
270
+ * @param {ReturnType<typeof computeInventoryViewModel>} viewModel
271
+ * @param {HTMLElement} canvasEl
272
+ * @param {(selectableId: string) => void} onSelect
273
+ * @param {(tableId: string) => void} onTableChange
274
+ */
275
+ export function renderInventoryView(viewModel, canvasEl, onSelect, onTableChange) {
276
+ clear(canvasEl);
277
+
278
+ const subNav = renderSubNav(viewModel, onTableChange);
279
+
280
+ let sortState = { columnIndex: null, direction: 'asc' };
281
+ const headerRow = el('tr', {}, viewModel.columns.map((col, i) =>
282
+ el('th', { onClick: () => {
283
+ sortState = sortState.columnIndex === i ? { columnIndex: i, direction: sortState.direction === 'asc' ? 'desc' : 'asc' } : { columnIndex: i, direction: 'asc' };
284
+ const sortedRows = sortRows(viewModel.rows, sortState.columnIndex, sortState.direction);
285
+ renderInventoryView({ ...viewModel, rows: sortedRows }, canvasEl, onSelect, onTableChange);
286
+ } }, col),
287
+ ));
288
+ const bodyRows = viewModel.rows.map((row) => renderRow(row, onSelect));
289
+ const table = el('table', { class: 'inventory-table' }, [el('thead', {}, headerRow), el('tbody', {}, bodyRows)]);
290
+
291
+ canvasEl.appendChild(el('div', { class: 'inventory-view' }, [subNav, table]));
292
+ }
@@ -0,0 +1,172 @@
1
+ import { flowPathNodeIds, isAiRelevantFlow } from '../lib/flow-path.js';
2
+ import { el, clear } from '../lib/dom.js';
3
+ import { protectionVisual, worstVerdict } from '../lib/protection-visual.js';
4
+ import { matchesFilters } from '../lib/row-filters.js';
5
+
6
+ export const LIFECYCLE_STAGES = Object.freeze(['collection', 'processing', 'storage', 'sharing', 'retention', 'deletion']);
7
+
8
+ export function stageForNode(node) {
9
+ return LIFECYCLE_STAGES.includes(node.lifecycleStages?.[0]) ? node.lifecycleStages[0] : 'processing';
10
+ }
11
+
12
+ export function computePrivacyRow(graph, flow) {
13
+ const dataElement = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
14
+ const pathNodeIds = flowPathNodeIds(graph, flow);
15
+ const pathNodes = graph.nodes.filter((n) => pathNodeIds.has(n.id));
16
+ // This flow's own resolved edges — the same per-edgeId lookup
17
+ // flowPathNodeIds() already performs internally, applied here to keep the
18
+ // edge objects (rather than just the node ids) so the three protection
19
+ // dimensions below can be aggregated per-flow via worstVerdict().
20
+ const pathEdges = flow.edgeIds.map((edgeId) => graph.edges.find((e) => e.id === edgeId)).filter(Boolean);
21
+
22
+ const stageCells = LIFECYCLE_STAGES.map((stage) => ({
23
+ stage,
24
+ nodeLabels: pathNodes.filter((n) => stageForNode(n) === stage).map((n) => n.label),
25
+ }));
26
+
27
+ const sourceNode = graph.nodes.find((n) => n.id === flow.source);
28
+ const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
29
+
30
+ return {
31
+ flowId: flow.id,
32
+ dataElementName: dataElement?.name ?? 'unknown field',
33
+ dataClasses: dataElement?.dataClasses ?? [],
34
+ stageCells,
35
+ governanceRefs: flow.governanceRefs ?? {},
36
+ protectionSummary: flow.protectionSummary,
37
+ policyVerdict: flow.policyVerdict,
38
+ isAiRelevant: isAiRelevantFlow(graph, flow),
39
+ // worstVerdict() always returns a real verdict string (falling back to
40
+ // 'not_assessed', never null/undefined) even for an empty edge list, so
41
+ // these three are always set unconditionally — unlike sourceCategory/
42
+ // sinkCategory/destinationExternality below, there is no genuinely-absent
43
+ // case to guard against.
44
+ transitVerdict: worstVerdict(pathEdges.map((e) => e.protection.transit.verdict)),
45
+ atRestVerdict: worstVerdict(pathEdges.map((e) => e.protection.atRest.verdict)),
46
+ handlingVerdict: worstVerdict(pathEdges.map((e) => e.protection.handling.verdict)),
47
+ // Only set when a real, non-null value exists — keeps matchesFilters's
48
+ // own "property absent = unaffected, never a hide" semantics clean
49
+ // rather than introducing a third (present-but-null) state.
50
+ ...(sourceNode?.subtype ? { sourceCategory: sourceNode.subtype } : {}),
51
+ ...(sinkNode?.subtype ? { sinkCategory: sinkNode.subtype } : {}),
52
+ ...(sinkNode?.externality?.value ? { destinationExternality: sinkNode.externality.value } : {}),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * @param {object} graph
58
+ * @param {object} state
59
+ * @param {((flow: object) => boolean) | null} [queryPredicate] - Milestone 3,
60
+ * sub-project M3-UX-Query, Task 4: the query language's compiled predicate
61
+ * (lib/query-language.js's `compileQuery`), applied here as an ADDITIONAL
62
+ * condition alongside the existing dataClass/protection/ai filters — a row
63
+ * must pass BOTH to stay visible. Omitted/null (every pre-existing
64
+ * caller/test) means "no query active," matching every row, so behavior
65
+ * is unchanged for anyone not passing it.
66
+ */
67
+ export function computePrivacyViewModel(graph, state, queryPredicate = null) {
68
+ const matchesQuery = queryPredicate ?? (() => true);
69
+ const rows = graph.flows.map((flow) => {
70
+ const row = computePrivacyRow(graph, flow);
71
+ return {
72
+ ...row,
73
+ selected: row.flowId === state.selectedId,
74
+ visible: matchesFilters(row, state.filters ?? {}) && matchesQuery(flow),
75
+ };
76
+ });
77
+ return { stages: LIFECYCLE_STAGES, rows };
78
+ }
79
+
80
+ const CLASS_BADGE_COLOR_VAR = { PII: '--class-pii', PHI: '--class-phi', PCI: '--class-pci' };
81
+
82
+ /**
83
+ * @param {ReturnType<typeof computePrivacyViewModel>} viewModel
84
+ * @param {HTMLElement} canvasEl
85
+ * @param {(flowId: string) => void} onSelectFlow
86
+ */
87
+ export function renderPrivacyView(viewModel, canvasEl, onSelectFlow) {
88
+ clear(canvasEl);
89
+
90
+ const headerRow = el('tr', {}, [
91
+ el('th', {}, 'Field'),
92
+ el('th', {}, 'Protection'),
93
+ ...viewModel.stages.map((stage) => el('th', {}, stage.charAt(0).toUpperCase() + stage.slice(1))),
94
+ ]);
95
+
96
+ const bodyRows = viewModel.rows.map((row) => renderPrivacyRow(row, onSelectFlow));
97
+
98
+ const table = el('table', { class: 'privacy-table' }, [el('thead', {}, headerRow), el('tbody', {}, bodyRows)]);
99
+
100
+ canvasEl.appendChild(el('div', { class: 'privacy-view' }, table));
101
+ }
102
+
103
+ function renderPrivacyRow(row, onSelectFlow) {
104
+ const classBadges = row.dataClasses.map((cls) =>
105
+ el('span', { class: 'privacy-class-badge', style: `color: var(${CLASS_BADGE_COLOR_VAR[cls] ?? '--text-secondary'}); border-color: var(${CLASS_BADGE_COLOR_VAR[cls] ?? '--border-default'})` }, cls),
106
+ );
107
+
108
+ const fieldCell = el('td', { class: 'privacy-field-cell' }, [
109
+ el('div', {}, row.dataElementName),
110
+ el('div', {}, classBadges),
111
+ row.isAiRelevant ? el('span', { class: 'privacy-governance-badge', style: 'border-color: var(--context-ai); color: var(--context-ai)' }, 'AI processing') : null,
112
+ ]);
113
+
114
+ const visual = protectionVisual(row.protectionSummary);
115
+ const protectionCell = el(
116
+ 'td',
117
+ { class: 'privacy-protection-cell' },
118
+ el('span', { style: `border-color: var(${visual.colorVar}); color: var(${visual.colorVar})` }, `${visual.glyph} ${visual.label}`),
119
+ );
120
+
121
+ const stageCells = row.stageCells.map((cell) => renderStageCell(cell, row));
122
+
123
+ return el(
124
+ 'tr',
125
+ {
126
+ class: 'privacy-row',
127
+ 'data-selected': String(row.selected),
128
+ 'data-visible': String(row.visible),
129
+ tabindex: '0',
130
+ role: 'button',
131
+ 'aria-label': `${row.dataElementName} lifecycle, ${row.protectionSummary}${row.selected ? ', selected' : ''}`,
132
+ onClick: () => onSelectFlow(row.flowId),
133
+ onKeydown: (evt) => {
134
+ if (evt.key === 'Enter' || evt.key === ' ') {
135
+ evt.preventDefault();
136
+ onSelectFlow(row.flowId);
137
+ }
138
+ },
139
+ },
140
+ [fieldCell, protectionCell, ...stageCells],
141
+ );
142
+ }
143
+
144
+ function renderStageCell(cell, row) {
145
+ const children = [];
146
+ if (cell.nodeLabels.length > 0) {
147
+ children.push(el('div', {}, cell.nodeLabels.join(', ')));
148
+ } else {
149
+ children.push(el('div', { class: 'privacy-stage-cell-empty-label' }, '—'));
150
+ }
151
+
152
+ // Governance facts are shown once, on whichever stage cell is the most
153
+ // relevant home for them — sharing (recipient/purpose/lawfulBasis/transfer)
154
+ // or retention/deletion — rather than repeating them on every cell. This
155
+ // loop runs unconditionally (independent of whether nodeLabels is empty)
156
+ // so a fact like deletion:not_found is never structurally unreachable just
157
+ // because a flow's path happens not to touch a deletion-stage node.
158
+ const governanceKeysForStage = {
159
+ sharing: ['recipient', 'purpose', 'lawfulBasis', 'transfer'],
160
+ retention: ['retention'],
161
+ deletion: ['deletion'],
162
+ };
163
+ const relevantKeys = governanceKeysForStage[cell.stage] ?? [];
164
+ for (const key of relevantKeys) {
165
+ if (key in row.governanceRefs) {
166
+ const value = row.governanceRefs[key];
167
+ children.push(el('div', { class: 'privacy-governance-badge' }, `${key}: ${value}`));
168
+ }
169
+ }
170
+
171
+ return el('td', { class: cell.nodeLabels.length === 0 ? 'privacy-stage-cell privacy-stage-cell-empty' : 'privacy-stage-cell' }, children);
172
+ }
@@ -0,0 +1,206 @@
1
+ import { el, clear } from '../lib/dom.js';
2
+ import { protectionVisual } from '../lib/protection-visual.js';
3
+
4
+ export function computeTraceSteps(graph, flow) {
5
+ const steps = [];
6
+ const sourceNode = graph.nodes.find((n) => n.id === flow.source);
7
+ const dataElement = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
8
+
9
+ steps.push({
10
+ kind: 'source',
11
+ fieldName: dataElement?.name ?? 'unknown field',
12
+ node: sourceNode?.label ?? 'unknown source',
13
+ });
14
+
15
+ const edges = flow.edgeIds.map((id) => graph.edges.find((e) => e.id === id)).filter(Boolean);
16
+ for (const edge of edges) {
17
+ const toNode = graph.nodes.find((n) => n.id === edge.to);
18
+ const mappings = edge.fieldMappings ?? [];
19
+
20
+ if (mappings.length === 0) {
21
+ steps.push({
22
+ kind: 'hop',
23
+ node: toNode?.label ?? 'unknown',
24
+ fromNodeId: edge.from,
25
+ protection: edge.protection,
26
+ evidenceRefs: edge.evidenceRefs ?? [],
27
+ });
28
+ continue;
29
+ }
30
+
31
+ for (const mapping of mappings) {
32
+ const transformations = (mapping.transformationIds ?? [])
33
+ .map((tid) => graph.transformations.find((t) => t.id === tid))
34
+ .filter(Boolean);
35
+ steps.push({
36
+ kind: transformations.length > 0 ? 'transformation' : 'propagation',
37
+ fromPath: mapping.fromPath,
38
+ toPath: mapping.toPath,
39
+ mappingType: mapping.mappingType,
40
+ transformations,
41
+ node: toNode?.label ?? 'unknown',
42
+ fromNodeId: edge.from,
43
+ boundaryCrossing: (edge.boundaryCrossings ?? []).length > 0,
44
+ protection: edge.protection,
45
+ evidenceRefs: edge.evidenceRefs ?? [],
46
+ });
47
+ }
48
+ }
49
+
50
+ const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
51
+ steps.push({
52
+ kind: 'sink',
53
+ node: sinkNode?.label ?? 'unknown destination',
54
+ externality: sinkNode?.externality?.value ?? 'unknown',
55
+ protectionSummary: flow.protectionSummary,
56
+ });
57
+
58
+ return steps;
59
+ }
60
+
61
+ export function computeTraceStepGroups(steps) {
62
+ const middleSteps = steps.filter((s) => s.kind !== 'source' && s.kind !== 'sink');
63
+ const sourceStep = steps.find((s) => s.kind === 'source');
64
+ const sinkStep = steps.find((s) => s.kind === 'sink');
65
+
66
+ const groupsByFrom = new Map();
67
+ for (const step of middleSteps) {
68
+ if (!groupsByFrom.has(step.fromNodeId)) groupsByFrom.set(step.fromNodeId, []);
69
+ groupsByFrom.get(step.fromNodeId).push(step);
70
+ }
71
+
72
+ const groups = [];
73
+ if (sourceStep) groups.push({ type: 'source', step: sourceStep });
74
+ for (const groupSteps of groupsByFrom.values()) {
75
+ if (groupSteps.length > 1) {
76
+ groups.push({ type: 'branch', steps: groupSteps });
77
+ } else {
78
+ groups.push({ type: 'sequential', step: groupSteps[0] });
79
+ }
80
+ }
81
+ if (sinkStep) groups.push({ type: 'sink', step: sinkStep });
82
+ return groups;
83
+ }
84
+
85
+ export function computeAlternatePaths(graph, flow) {
86
+ return graph.flows
87
+ .filter((f) => f.id !== flow.id && f.dataElementIds.some((id) => flow.dataElementIds.includes(id)))
88
+ .map((f) => ({
89
+ flowId: f.id,
90
+ destinationLabel: graph.nodes.find((n) => n.id === f.sink)?.label ?? 'unknown',
91
+ protectionSummary: f.protectionSummary,
92
+ }));
93
+ }
94
+
95
+ export function computeTraceViewModel(graph, state) {
96
+ if (!state.selectedId) return null;
97
+ const flow = graph.flows.find((f) => f.id === state.selectedId);
98
+ if (!flow) return null;
99
+ return {
100
+ flow,
101
+ steps: computeTraceSteps(graph, flow),
102
+ alternatePaths: computeAlternatePaths(graph, flow),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * @param {ReturnType<typeof computeTraceViewModel>} viewModel
108
+ * @param {HTMLElement} canvasEl
109
+ * @param {(flowId: string) => void} onSelectAlternate
110
+ */
111
+ export function renderTraceView(viewModel, canvasEl, onSelectAlternate) {
112
+ clear(canvasEl);
113
+
114
+ if (!viewModel) {
115
+ canvasEl.appendChild(el('p', { class: 'trace-empty' }, 'Select a flow from Privacy View (or click a node/edge in Architecture View that resolves to a flow) to trace it.'));
116
+ return;
117
+ }
118
+
119
+ const container = el('div', { class: 'trace-view' });
120
+ const groups = computeTraceStepGroups(viewModel.steps);
121
+ let stepNumber = 0;
122
+ for (const group of groups) {
123
+ stepNumber += 1;
124
+ if (group.type === 'branch') {
125
+ container.appendChild(renderTraceBranchGroup(group.steps, stepNumber));
126
+ } else {
127
+ container.appendChild(renderTraceStep(group.step, String(stepNumber)));
128
+ }
129
+ }
130
+
131
+ if (viewModel.alternatePaths.length > 0) {
132
+ const items = viewModel.alternatePaths.map((alt) => {
133
+ const visual = protectionVisual(alt.protectionSummary);
134
+ return el(
135
+ 'div',
136
+ {
137
+ class: 'trace-alternate-item',
138
+ tabindex: '0',
139
+ role: 'button',
140
+ onClick: () => onSelectAlternate(alt.flowId),
141
+ onKeydown: (evt) => {
142
+ if (evt.key === 'Enter' || evt.key === ' ') {
143
+ evt.preventDefault();
144
+ onSelectAlternate(alt.flowId);
145
+ }
146
+ },
147
+ },
148
+ `${visual.glyph} ${alt.destinationLabel} — ${visual.label}`,
149
+ );
150
+ });
151
+ container.appendChild(el('div', { class: 'trace-alternates' }, [el('h4', {}, 'Alternate destinations'), ...items]));
152
+ }
153
+
154
+ canvasEl.appendChild(container);
155
+ }
156
+
157
+ function renderTraceBranchGroup(steps, number) {
158
+ const letters = 'abcdefghijklmnopqrstuvwxyz';
159
+ const items = steps.map((step, i) => renderTraceStep(step, `${number}${letters[i]}`));
160
+ return el('div', { class: 'trace-branch-group' }, [
161
+ el('div', { class: 'trace-branch-label' }, `${steps.length} branches from here`),
162
+ ...items,
163
+ ]);
164
+ }
165
+
166
+ function renderTraceStep(step, label) {
167
+ const bodyChildren = [el('div', { class: 'trace-step-kind' }, step.kind)];
168
+
169
+ if (step.kind === 'source') {
170
+ bodyChildren.push(el('div', { class: 'trace-step-mapping' }, step.fieldName));
171
+ } else if (step.kind === 'transformation' || step.kind === 'propagation') {
172
+ bodyChildren.push(el('div', { class: 'trace-step-mapping' }, `${step.fromPath} → ${step.toPath} (${step.mappingType})`));
173
+ for (const t of step.transformations) {
174
+ bodyChildren.push(el('div', { class: 'trace-step-mapping' }, `${t.callee}() — ${t.kind}, ${t.reversibility}`));
175
+ }
176
+ }
177
+
178
+ bodyChildren.push(el('div', { class: 'trace-step-node' }, step.node));
179
+
180
+ if (step.boundaryCrossing) {
181
+ bodyChildren.push(el('span', { class: 'trace-step-boundary' }, 'Trust boundary crossing'));
182
+ }
183
+
184
+ if (step.protection) {
185
+ const dims = [
186
+ ['Transit', step.protection.transit.verdict],
187
+ ['At rest', step.protection.atRest.verdict],
188
+ ['Handling', step.protection.handling.verdict],
189
+ ];
190
+ for (const [label, verdict] of dims) {
191
+ const visual = protectionVisual(verdict);
192
+ bodyChildren.push(el('div', { class: 'trace-step-mapping' }, `${visual.glyph} ${label}: ${visual.label}`));
193
+ }
194
+ }
195
+
196
+ if (step.evidenceRefs?.length > 0) {
197
+ bodyChildren.push(el('div', { class: 'trace-step-mapping' }, `${step.evidenceRefs.length} evidence reference(s)`));
198
+ }
199
+
200
+ if (step.kind === 'sink') {
201
+ const visual = protectionVisual(step.protectionSummary);
202
+ bodyChildren.push(el('div', { class: 'trace-step-mapping' }, `${visual.glyph} Overall: ${visual.label} · ${step.externality} destination`));
203
+ }
204
+
205
+ return el('div', { class: 'trace-step' }, [el('div', { class: 'trace-step-number' }, label), el('div', { class: 'trace-step-body' }, bodyChildren)]);
206
+ }