@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.
- package/CHANGELOG.md +266 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +779 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +379 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +841 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/dist/frontend/index.html +21 -0
- package/dist/frontend/src/app.js +176 -0
- package/dist/frontend/src/components/evidence-inspector.js +141 -0
- package/dist/frontend/src/components/filter-rail.js +119 -0
- package/dist/frontend/src/components/query-bar.js +126 -0
- package/dist/frontend/src/data/flagship-graph.js +1460 -0
- package/dist/frontend/src/export-entry.js +36 -0
- package/dist/frontend/src/lib/api-client.js +92 -0
- package/dist/frontend/src/lib/contrast.js +34 -0
- package/dist/frontend/src/lib/dom.js +24 -0
- package/dist/frontend/src/lib/escape-html.js +16 -0
- package/dist/frontend/src/lib/flow-path.js +40 -0
- package/dist/frontend/src/lib/focus-controls.js +149 -0
- package/dist/frontend/src/lib/protection-visual.js +46 -0
- package/dist/frontend/src/lib/query-language.js +240 -0
- package/dist/frontend/src/lib/row-filters.js +43 -0
- package/dist/frontend/src/lib/state.js +84 -0
- package/dist/frontend/src/main.js +83 -0
- package/dist/frontend/src/shell.js +184 -0
- package/dist/frontend/src/views/architecture-view.js +798 -0
- package/dist/frontend/src/views/inventory-view.js +292 -0
- package/dist/frontend/src/views/privacy-view.js +172 -0
- package/dist/frontend/src/views/trace-view.js +206 -0
- package/dist/frontend/styles/architecture-view.css +93 -0
- package/dist/frontend/styles/filter-rail.css +34 -0
- package/dist/frontend/styles/inspector.css +69 -0
- package/dist/frontend/styles/inventory-view.css +74 -0
- package/dist/frontend/styles/privacy-view.css +86 -0
- package/dist/frontend/styles/query-bar.css +107 -0
- package/dist/frontend/styles/shell.css +155 -0
- package/dist/frontend/styles/tokens.css +128 -0
- package/dist/frontend/styles/trace-view.css +95 -0
- package/package.json +13 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +144 -0
- package/src/shared/frontend-root.js +52 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// PRD §15.2's query language: a small DSL for filtering the Data Flow
|
|
2
|
+
// Explorer's views (e.g. `class:PCI AND sink:(log,database,external_api)`).
|
|
3
|
+
// This module is ONLY the tokenizer + recursive-descent parser, producing
|
|
4
|
+
// an AST — no evaluation, no UI. A later increment consumes the AST to
|
|
5
|
+
// actually filter a graph/view.
|
|
6
|
+
//
|
|
7
|
+
// Hand-written tokenizer, not regex-based single-pass lexing — the grammar
|
|
8
|
+
// has quoted strings (which must suppress internal `:`/`(`/`)`/`,`
|
|
9
|
+
// interpretation) and needs real position tracking for actionable error
|
|
10
|
+
// messages, both easier to get right with an explicit character-scanning
|
|
11
|
+
// loop.
|
|
12
|
+
//
|
|
13
|
+
// `parseQuery` NEVER throws to its caller, matching `state.js`'s own
|
|
14
|
+
// convention of never letting a malformed/adversarial user-supplied string
|
|
15
|
+
// (there, a URL hash; here, a query string) crash the caller — a bad query
|
|
16
|
+
// returns a structured `{error: {message, pos}}` instead, so a caller can
|
|
17
|
+
// check `.error` and keep the prior state rather than losing the page.
|
|
18
|
+
|
|
19
|
+
import { isAiRelevantFlow, flowPathNodeIds } from './flow-path.js';
|
|
20
|
+
|
|
21
|
+
const KEYWORDS = new Set(['and', 'or']);
|
|
22
|
+
|
|
23
|
+
export function tokenize(input) {
|
|
24
|
+
const tokens = [];
|
|
25
|
+
let i = 0;
|
|
26
|
+
const isFieldChar = (c) => /[a-zA-Z0-9_.]/.test(c);
|
|
27
|
+
|
|
28
|
+
while (i < input.length) {
|
|
29
|
+
const c = input[i];
|
|
30
|
+
if (c === ' ' || c === '\t' || c === '\n') { i++; continue; }
|
|
31
|
+
if (c === '(') { tokens.push({ type: 'LPAREN', value: '(', pos: i }); i++; continue; }
|
|
32
|
+
if (c === ')') { tokens.push({ type: 'RPAREN', value: ')', pos: i }); i++; continue; }
|
|
33
|
+
if (c === ',') { tokens.push({ type: 'COMMA', value: ',', pos: i }); i++; continue; }
|
|
34
|
+
if (c === '!' && input[i + 1] === '=') { tokens.push({ type: 'OP', value: '!=', pos: i }); i += 2; continue; }
|
|
35
|
+
if (c === ':') { tokens.push({ type: 'OP', value: ':', pos: i }); i++; continue; }
|
|
36
|
+
if (c === '"') {
|
|
37
|
+
const start = i;
|
|
38
|
+
i++;
|
|
39
|
+
let value = '';
|
|
40
|
+
while (i < input.length && input[i] !== '"') { value += input[i]; i++; }
|
|
41
|
+
if (input[i] !== '"') return { error: true, pos: start, message: 'unterminated quoted string' };
|
|
42
|
+
i++; // consume closing quote
|
|
43
|
+
tokens.push({ type: 'VALUE', value, pos: start });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (isFieldChar(c)) {
|
|
47
|
+
const start = i;
|
|
48
|
+
let word = '';
|
|
49
|
+
while (i < input.length && isFieldChar(input[i])) { word += input[i]; i++; }
|
|
50
|
+
const lower = word.toLowerCase();
|
|
51
|
+
if (lower === 'and') tokens.push({ type: 'AND', value: word, pos: start });
|
|
52
|
+
else if (lower === 'or') tokens.push({ type: 'OR', value: word, pos: start });
|
|
53
|
+
else {
|
|
54
|
+
// A bare word followed by ':' or '!=' is a FIELD; otherwise it's a
|
|
55
|
+
// TEXT-search VALUE (a later increment evaluates TEXT nodes; the
|
|
56
|
+
// parser just needs to tell the two shapes apart here by lookahead).
|
|
57
|
+
const nextNonSpace = input.slice(i).match(/^\s*(:|!=)/);
|
|
58
|
+
tokens.push({ type: nextNonSpace ? 'FIELD' : 'VALUE', value: word, pos: start });
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
return { error: true, pos: i, message: `unexpected character "${c}"` };
|
|
63
|
+
}
|
|
64
|
+
return tokens;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A standard recursive-descent parser: parseOr calls parseAnd repeatedly
|
|
68
|
+
// across OR tokens; parseAnd calls parseAtom repeatedly across explicit AND
|
|
69
|
+
// tokens; parseAtom handles `(expr)`, `field OP value-or-list`, and bare
|
|
70
|
+
// TEXT terms.
|
|
71
|
+
export function parseQuery(input) {
|
|
72
|
+
if (input.trim() === '') return { ast: null };
|
|
73
|
+
const tokenResult = tokenize(input);
|
|
74
|
+
if (!Array.isArray(tokenResult)) return { error: { message: tokenResult.message, pos: tokenResult.pos } };
|
|
75
|
+
const tokens = tokenResult;
|
|
76
|
+
let pos = 0;
|
|
77
|
+
|
|
78
|
+
function peek() { return tokens[pos]; }
|
|
79
|
+
function advance() { return tokens[pos++]; }
|
|
80
|
+
function fail(message) {
|
|
81
|
+
const t = peek();
|
|
82
|
+
throw { message, pos: t ? t.pos : input.length };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseOr() {
|
|
86
|
+
let node = parseAnd();
|
|
87
|
+
while (peek()?.type === 'OR') {
|
|
88
|
+
advance();
|
|
89
|
+
node = { type: 'OR', left: node, right: parseAnd() };
|
|
90
|
+
}
|
|
91
|
+
return node;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseAnd() {
|
|
95
|
+
let node = parseAtom();
|
|
96
|
+
while (peek()?.type === 'AND') {
|
|
97
|
+
advance();
|
|
98
|
+
node = { type: 'AND', left: node, right: parseAtom() };
|
|
99
|
+
}
|
|
100
|
+
return node;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseAtom() {
|
|
104
|
+
const t = peek();
|
|
105
|
+
if (!t) fail('unexpected end of query');
|
|
106
|
+
if (t.type === 'LPAREN') {
|
|
107
|
+
advance();
|
|
108
|
+
const inner = parseOr();
|
|
109
|
+
if (peek()?.type !== 'RPAREN') fail('expected closing ")"');
|
|
110
|
+
advance();
|
|
111
|
+
return inner;
|
|
112
|
+
}
|
|
113
|
+
if (t.type === 'FIELD') {
|
|
114
|
+
advance();
|
|
115
|
+
const opToken = advance();
|
|
116
|
+
if (!opToken || opToken.type !== 'OP') fail(`expected ":" or "!=" after field "${t.value}"`);
|
|
117
|
+
const values = parseValueList();
|
|
118
|
+
return { type: 'COMPARISON', field: t.value, op: opToken.value, values };
|
|
119
|
+
}
|
|
120
|
+
if (t.type === 'VALUE') {
|
|
121
|
+
advance();
|
|
122
|
+
return { type: 'TEXT', value: t.value };
|
|
123
|
+
}
|
|
124
|
+
fail(`unexpected token "${t.value}"`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseValueList() {
|
|
128
|
+
if (peek()?.type === 'LPAREN') {
|
|
129
|
+
advance();
|
|
130
|
+
const values = [];
|
|
131
|
+
values.push(expectValue());
|
|
132
|
+
while (peek()?.type === 'COMMA') { advance(); values.push(expectValue()); }
|
|
133
|
+
if (peek()?.type !== 'RPAREN') fail('expected closing ")" in value list');
|
|
134
|
+
advance();
|
|
135
|
+
return values;
|
|
136
|
+
}
|
|
137
|
+
return [expectValue()];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function expectValue() {
|
|
141
|
+
const t = peek();
|
|
142
|
+
if (!t || t.type !== 'VALUE') fail('expected a value');
|
|
143
|
+
advance();
|
|
144
|
+
return t.value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const ast = parseOr();
|
|
149
|
+
if (pos < tokens.length) fail(`unexpected trailing token "${peek().value}"`);
|
|
150
|
+
return { ast };
|
|
151
|
+
} catch (e) {
|
|
152
|
+
if (e && typeof e.message === 'string') return { error: e };
|
|
153
|
+
throw e; // a genuine bug, not a user-facing parse error — do not swallow
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// AST -> predicate. `compileQuery(ast, graph) -> (flow) -> boolean`. The
|
|
158
|
+
// predicate operates on a FLOW (matching how filtering already works
|
|
159
|
+
// throughout this codebase — Privacy/Inventory both filter at the flow
|
|
160
|
+
// level), with field accessors reaching into the flow's own source/sink
|
|
161
|
+
// nodes, data element, and edges as needed.
|
|
162
|
+
|
|
163
|
+
// Field name -> accessor function: (graph, flow) -> string[] (the set of
|
|
164
|
+
// real values that field name means for THIS flow — a comparison
|
|
165
|
+
// matches if ANY of a COMPARISON's own `values` appears in this set).
|
|
166
|
+
// Grounded in the parent scoping doc's own real-vs-inert audit — every
|
|
167
|
+
// accessor here reads a field CONFIRMED populated by real scan code.
|
|
168
|
+
const FIELD_ACCESSORS = {
|
|
169
|
+
class: (graph, flow) => {
|
|
170
|
+
const de = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
|
|
171
|
+
return de?.dataClasses ?? [];
|
|
172
|
+
},
|
|
173
|
+
field: (graph, flow) => {
|
|
174
|
+
const de = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
|
|
175
|
+
return de?.name ? [de.name] : [];
|
|
176
|
+
},
|
|
177
|
+
sink: (graph, flow) => {
|
|
178
|
+
const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
|
|
179
|
+
return sinkNode?.subtype ? [sinkNode.subtype] : [];
|
|
180
|
+
},
|
|
181
|
+
source: (graph, flow) => {
|
|
182
|
+
const sourceNode = graph.nodes.find((n) => n.id === flow.source);
|
|
183
|
+
return sourceNode?.subtype ? [sourceNode.subtype] : [];
|
|
184
|
+
},
|
|
185
|
+
'transit.verdict': (graph, flow) => edgeVerdicts(graph, flow, 'transit'),
|
|
186
|
+
'at_rest.verdict': (graph, flow) => edgeVerdicts(graph, flow, 'atRest'),
|
|
187
|
+
'handling.verdict': (graph, flow) => edgeVerdicts(graph, flow, 'handling'),
|
|
188
|
+
policy: (graph, flow) => (flow.policyVerdict ? [flow.policyVerdict] : []),
|
|
189
|
+
coverage: (graph, flow) => {
|
|
190
|
+
const pathNodeIds = flowPathNodeIds(graph, flow);
|
|
191
|
+
return graph.nodes.filter((n) => pathNodeIds.has(n.id)).map((n) => n.coverageStatus);
|
|
192
|
+
},
|
|
193
|
+
ai: (graph, flow) => [String(isAiRelevantFlow(graph, flow))],
|
|
194
|
+
'destination.external': (graph, flow) => {
|
|
195
|
+
const pathNodeIds = flowPathNodeIds(graph, flow);
|
|
196
|
+
const anyExternal = graph.nodes.some((n) => pathNodeIds.has(n.id) && n.externality?.value === 'external');
|
|
197
|
+
return [String(anyExternal)];
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
function edgeVerdicts(graph, flow, dimension) {
|
|
202
|
+
return flow.edgeIds
|
|
203
|
+
.map((id) => graph.edges.find((e) => e.id === id))
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
.map((e) => e.protection[dimension].verdict);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function compileQuery(ast, graph) {
|
|
209
|
+
if (ast === null || ast === undefined) return () => true;
|
|
210
|
+
return (flow) => evaluateNode(ast, graph, flow);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function evaluateNode(node, graph, flow) {
|
|
214
|
+
if (node.type === 'AND') return evaluateNode(node.left, graph, flow) && evaluateNode(node.right, graph, flow);
|
|
215
|
+
if (node.type === 'OR') return evaluateNode(node.left, graph, flow) || evaluateNode(node.right, graph, flow);
|
|
216
|
+
if (node.type === 'TEXT') return matchesText(graph, flow, node.value);
|
|
217
|
+
if (node.type === 'COMPARISON') {
|
|
218
|
+
const accessor = FIELD_ACCESSORS[node.field];
|
|
219
|
+
if (!accessor) {
|
|
220
|
+
// Real, reported condition (Step 1's own deliberately-open test) —
|
|
221
|
+
// throwing here, caught by compileQuery's own caller (Task 4's
|
|
222
|
+
// query bar), is the chosen mechanism: it surfaces as a real error
|
|
223
|
+
// to the user rather than silently matching nothing. Document
|
|
224
|
+
// this choice at the call site too.
|
|
225
|
+
throw new Error(`unrecognized query field "${node.field}"`);
|
|
226
|
+
}
|
|
227
|
+
const realValues = accessor(graph, flow).map((v) => String(v).toLowerCase());
|
|
228
|
+
const matches = node.values.some((v) => realValues.includes(v.toLowerCase()));
|
|
229
|
+
return node.op === '!=' ? !matches : matches;
|
|
230
|
+
}
|
|
231
|
+
throw new Error(`unknown AST node type "${node.type}"`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function matchesText(graph, flow, term) {
|
|
235
|
+
const lower = term.toLowerCase();
|
|
236
|
+
const sourceLabel = graph.nodes.find((n) => n.id === flow.source)?.label ?? '';
|
|
237
|
+
const sinkLabel = graph.nodes.find((n) => n.id === flow.sink)?.label ?? '';
|
|
238
|
+
const de = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
|
|
239
|
+
return [sourceLabel, sinkLabel, de?.name ?? ''].some((s) => s.toLowerCase().includes(lower));
|
|
240
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Shared, deduplicated row-vs-active-filters matcher — was previously two
|
|
2
|
+
// near-identical private copies (privacy-view.js's and inventory-view.js's
|
|
3
|
+
// own rowMatchesFilters). Every check here reads a PRE-ATTACHED row
|
|
4
|
+
// property (never the graph directly) — the caller's own row-computation
|
|
5
|
+
// step is responsible for attaching whichever of these properties make
|
|
6
|
+
// sense for that row's own shape (see lib/filter-rail.js's own facet list
|
|
7
|
+
// and each view's own row-building code). A facet whose property the row
|
|
8
|
+
// does not carry AT ALL is skipped (never a hide) — this is what makes it
|
|
9
|
+
// safe for a single shared function to serve row shapes as different as a
|
|
10
|
+
// Privacy flow-row and an Inventory dataElement-row.
|
|
11
|
+
const LIST_FACETS = [
|
|
12
|
+
['dataClass', 'dataClasses', true], // true = row property is itself an array (dataClasses), match if ANY overlaps
|
|
13
|
+
['protection', 'protectionSummary', false], // false = row property is a single value, match if included in the filter's list
|
|
14
|
+
['transitVerdict', 'transitVerdict', false],
|
|
15
|
+
['atRestVerdict', 'atRestVerdict', false],
|
|
16
|
+
['handlingVerdict', 'handlingVerdict', false],
|
|
17
|
+
['sourceCategory', 'sourceCategory', false],
|
|
18
|
+
['sinkCategory', 'sinkCategory', false],
|
|
19
|
+
['destinationExternality', 'destinationExternality', false],
|
|
20
|
+
['policyVerdict', 'policyVerdict', false],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function matchesFilters(row, filters) {
|
|
24
|
+
for (const [filterKey, rowProp, rowIsArray] of LIST_FACETS) {
|
|
25
|
+
const activeValues = filters[filterKey];
|
|
26
|
+
if (!activeValues?.length) continue; // this facet isn't active at all
|
|
27
|
+
if (!(rowProp in row)) continue; // row doesn't carry this property — unaffected, not hidden
|
|
28
|
+
if (rowIsArray) {
|
|
29
|
+
if (!(row[rowProp] ?? []).some((v) => activeValues.includes(v))) return false;
|
|
30
|
+
} else {
|
|
31
|
+
if (row[rowProp] !== undefined && !activeValues.includes(row[rowProp])) return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Unlike the 9 facets above (list-of-selected-values), `ai` is a single
|
|
35
|
+
// boolean toggle, matching its existing shape in both views — kept as
|
|
36
|
+
// its own explicit check, not folded into LIST_FACETS's generic loop,
|
|
37
|
+
// since it's structurally different (a boolean flag, not a multi-select).
|
|
38
|
+
// Checked for ANY row carrying `isAiRelevant` — this is the real fix for
|
|
39
|
+
// Inventory's own previously-missing AI check (its private
|
|
40
|
+
// rowMatchesFilters never checked `ai` at all; Privacy's did).
|
|
41
|
+
if (filters.ai && 'isAiRelevant' in row && !row.isAiRelevant) return false;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Shared cross-view selection/filter state, persisted in the URL hash so
|
|
2
|
+
// switching views preserves the selected canonical ID and filters (AC-16),
|
|
3
|
+
// and so state is shareable/bookmarkable without a server. Per PRD §7.11:
|
|
4
|
+
// "URL state contains canonical IDs and non-sensitive filter expressions,
|
|
5
|
+
// never source snippets, field values, or secret-bearing endpoints" — this
|
|
6
|
+
// module only ever carries canonical IDs and filter keys/values the caller
|
|
7
|
+
// supplies, never arbitrary text.
|
|
8
|
+
//
|
|
9
|
+
// `state.filters` is an opaque, caller-defined object — this module never
|
|
10
|
+
// validates individual keys inside it, only that the blob as a whole is
|
|
11
|
+
// valid JSON. Milestone 3, sub-project M3-UX-Query adds `filters.query`
|
|
12
|
+
// (PRD §15.2's query-language string, a plain string) as one more key
|
|
13
|
+
// alongside the pre-existing `dataClass`/`protection`/`ai` keys — it
|
|
14
|
+
// round-trips through the hash for free via the same generic JSON
|
|
15
|
+
// serialize/parse path below, with no code change needed here beyond this
|
|
16
|
+
// note. Validating/parsing the query text itself is `lib/query-language.js`'s
|
|
17
|
+
// and `components/query-bar.js`'s job, not this module's — exactly like
|
|
18
|
+
// `dataClass`/`protection`/`ai` are validated by their own consumers
|
|
19
|
+
// (filter-rail.js, privacy-view.js, inventory-view.js), never here.
|
|
20
|
+
|
|
21
|
+
const VALID_VIEWS = new Set(['architecture', 'privacy', 'trace', 'inventory']);
|
|
22
|
+
|
|
23
|
+
// PRD §7.6's own 11 required inventory categories, in the order that
|
|
24
|
+
// section lists them. The canonical id list — inventory-view.js's
|
|
25
|
+
// per-category compute dispatch and its sub-nav strip both key off this
|
|
26
|
+
// exact array, so a rename here must update both call sites too.
|
|
27
|
+
export const INVENTORY_TABLES = Object.freeze([
|
|
28
|
+
'sources',
|
|
29
|
+
'sinks',
|
|
30
|
+
'fields',
|
|
31
|
+
'externalDestinations',
|
|
32
|
+
'stores',
|
|
33
|
+
'aiSystems',
|
|
34
|
+
'transformations',
|
|
35
|
+
'unprotectedEdges',
|
|
36
|
+
'policyPermittedFlows',
|
|
37
|
+
'manualGovernanceGaps',
|
|
38
|
+
'unsupportedCandidates',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const DEFAULT_STATE = Object.freeze({ view: 'architecture', selectedId: null, filters: {}, table: INVENTORY_TABLES[0] });
|
|
42
|
+
|
|
43
|
+
export function parseStateFromHash(hash) {
|
|
44
|
+
const raw = String(hash ?? '').replace(/^#/, '');
|
|
45
|
+
if (!raw) return { ...DEFAULT_STATE, filters: {} };
|
|
46
|
+
|
|
47
|
+
let params;
|
|
48
|
+
try {
|
|
49
|
+
params = new URLSearchParams(raw);
|
|
50
|
+
} catch {
|
|
51
|
+
return { ...DEFAULT_STATE, filters: {} };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const view = params.get('view');
|
|
55
|
+
const selectedId = params.get('selected');
|
|
56
|
+
const filtersRaw = params.get('filters');
|
|
57
|
+
const tableRaw = params.get('table');
|
|
58
|
+
|
|
59
|
+
let filters = {};
|
|
60
|
+
if (filtersRaw) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(filtersRaw);
|
|
63
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) filters = parsed;
|
|
64
|
+
} catch {
|
|
65
|
+
filters = {};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
view: VALID_VIEWS.has(view) ? view : DEFAULT_STATE.view,
|
|
71
|
+
selectedId: selectedId || null,
|
|
72
|
+
filters,
|
|
73
|
+
table: INVENTORY_TABLES.includes(tableRaw) ? tableRaw : DEFAULT_STATE.table,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function serializeStateToHash(state) {
|
|
78
|
+
const params = new URLSearchParams();
|
|
79
|
+
params.set('view', VALID_VIEWS.has(state.view) ? state.view : DEFAULT_STATE.view);
|
|
80
|
+
if (state.selectedId) params.set('selected', state.selectedId);
|
|
81
|
+
if (state.filters && Object.keys(state.filters).length > 0) params.set('filters', JSON.stringify(state.filters));
|
|
82
|
+
if (state.table && state.table !== DEFAULT_STATE.table) params.set('table', state.table);
|
|
83
|
+
return `#${params.toString()}`;
|
|
84
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// main.js — Milestone 3, sub-project Wire.
|
|
2
|
+
//
|
|
3
|
+
// The page's real entry point, external to index.html for one reason:
|
|
4
|
+
// index.html's own inline <script type="module"> block would need
|
|
5
|
+
// `script-src 'unsafe-inline'` under the explore server's static-asset CSP
|
|
6
|
+
// (scanner/src/server/static-assets.js's STATIC_CSP_HEADER_VALUE). Moving
|
|
7
|
+
// this logic into its own file, loaded via
|
|
8
|
+
// `<script type="module" src="./src/main.js">`, lets that CSP stay a
|
|
9
|
+
// strict `script-src 'self'` with no exception — see the M3-Wire plan's own
|
|
10
|
+
// "load-bearing correction" on this point.
|
|
11
|
+
//
|
|
12
|
+
// Replaces the old static import of FLAGSHIP_GRAPH with a live fetch
|
|
13
|
+
// against the explore server's own /api/v1/graph endpoint, authenticated
|
|
14
|
+
// via the token carried in the page's own URL fragment. bootstrap() itself
|
|
15
|
+
// is UNCHANGED — it already takes a plain graph object with no assumption
|
|
16
|
+
// about where it came from.
|
|
17
|
+
|
|
18
|
+
import { bootstrap } from './app.js';
|
|
19
|
+
import { el, clear } from './lib/dom.js';
|
|
20
|
+
import { extractTokenFromFragment, fetchGraph } from './lib/api-client.js';
|
|
21
|
+
|
|
22
|
+
// Exported for test/golden-state-matrix.test.js (AC-22's "Error" state row)
|
|
23
|
+
// only. This is a disclosed, deliberate choice, not an oversight: Node's
|
|
24
|
+
// stable `node:test` in this repo's Node version (confirmed this session —
|
|
25
|
+
// `node:test`'s `mock` object has no `.module` method here) has no ESM
|
|
26
|
+
// module-mocking primitive, so there is no way to make main.js's real
|
|
27
|
+
// `init()`/fetch-catch path exercise a REAL failing `fetchGraph` without
|
|
28
|
+
// either (a) a network-mocking dependency this repo doesn't otherwise carry,
|
|
29
|
+
// or (b) exporting the real render function and calling it directly with the
|
|
30
|
+
// same message shape `init()`'s own catch block already builds. (b) is the
|
|
31
|
+
// smaller footprint — it adds one keyword to an existing function instead of
|
|
32
|
+
// a parallel test-only reimplementation or a new dependency, and it matches
|
|
33
|
+
// this sub-project's own golden-DOM pattern elsewhere (golden-architecture.
|
|
34
|
+
// test.js / golden-privacy.test.js call the real compute/render functions
|
|
35
|
+
// directly rather than a test double). The exported symbol is the exact
|
|
36
|
+
// function `init()` calls on a fetch failure — no test-only duplicate logic.
|
|
37
|
+
export function showError(rootEl, message) {
|
|
38
|
+
clear(rootEl);
|
|
39
|
+
rootEl.appendChild(
|
|
40
|
+
el('div', { class: 'app-error' }, [
|
|
41
|
+
el('div', { class: 'app-error__title' }, 'Data Flow Explorer could not load'),
|
|
42
|
+
el('div', { class: 'app-error__message' }, message),
|
|
43
|
+
]),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function init() {
|
|
48
|
+
const rootEl = document.getElementById('app-root');
|
|
49
|
+
|
|
50
|
+
const token = extractTokenFromFragment();
|
|
51
|
+
if (!token) {
|
|
52
|
+
showError(
|
|
53
|
+
rootEl,
|
|
54
|
+
'No session token found in this page’s URL. Start a new session with `agentic-security explore` and open the URL it prints (including the "#token=..." part).',
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let graph;
|
|
60
|
+
try {
|
|
61
|
+
graph = await fetchGraph({ token });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
showError(rootEl, `Failed to load the data flow graph: ${err && err.message ? err.message : String(err)}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
bootstrap(rootEl, graph);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Guarded on the mount point actually existing before auto-invoking, rather
|
|
71
|
+
// than calling init() unconditionally: this file's ONLY export
|
|
72
|
+
// (`showError`, above) is imported directly by test/golden-state-matrix.
|
|
73
|
+
// test.js, and importing an ES module always runs its top-level statements
|
|
74
|
+
// — including this one — as a side effect. Without this guard, importing
|
|
75
|
+
// main.js under test/dom-shim.js's minimal document (no `#app-root` element
|
|
76
|
+
// exists, since dom-shim builds a bare virtual tree, not a full page)
|
|
77
|
+
// crashed with an unhandled rejection from `document.getElementById`
|
|
78
|
+
// returning something the rest of init() can't use. The guard is also a
|
|
79
|
+
// real, sensible defensive property outside of tests — this script is never
|
|
80
|
+
// meant to run against a page lacking its own mount point.
|
|
81
|
+
if (typeof document !== 'undefined' && typeof document.getElementById === 'function' && document.getElementById('app-root')) {
|
|
82
|
+
init();
|
|
83
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { el, clear } from './lib/dom.js';
|
|
2
|
+
import { parseStateFromHash, serializeStateToHash } from './lib/state.js';
|
|
3
|
+
|
|
4
|
+
const VIEWS = [
|
|
5
|
+
{ id: 'architecture', label: 'Architecture' },
|
|
6
|
+
{ id: 'privacy', label: 'Privacy' },
|
|
7
|
+
{ id: 'trace', label: 'Trace' },
|
|
8
|
+
{ id: 'inventory', label: 'Inventory' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {HTMLElement} rootEl
|
|
13
|
+
* @param {object} graph - a DataFlowGraph v1 envelope (already validated at build time)
|
|
14
|
+
* @returns {{
|
|
15
|
+
* setActiveView: (viewName: string) => void,
|
|
16
|
+
* getState: () => {view: string, selectedId: string|null, filters: object},
|
|
17
|
+
* setSelection: (selectedId: string|null) => void,
|
|
18
|
+
* setFilters: (filters: object) => void,
|
|
19
|
+
* setTable: (tableId: string) => void,
|
|
20
|
+
* onStateChange: (listener: (state: object) => void) => (() => void),
|
|
21
|
+
* getCanvasEl: () => HTMLElement,
|
|
22
|
+
* getInspectorEl: () => HTMLElement,
|
|
23
|
+
* getContextRailEl: () => HTMLElement,
|
|
24
|
+
* getLeftRailEl: () => HTMLElement,
|
|
25
|
+
* getQueryBarEl: () => HTMLElement,
|
|
26
|
+
* destroy: () => void,
|
|
27
|
+
* }}
|
|
28
|
+
*/
|
|
29
|
+
export function mountShell(rootEl, graph) {
|
|
30
|
+
let state = parseStateFromHash(window.location.hash);
|
|
31
|
+
let stateChangeListeners = [];
|
|
32
|
+
let inspectorOverlayOpen = false;
|
|
33
|
+
|
|
34
|
+
const shell = el('div', { class: 'shell' });
|
|
35
|
+
const header = buildHeader(graph);
|
|
36
|
+
const coverageBanner = buildCoverageBanner(graph);
|
|
37
|
+
const tabs = buildViewTabs(state.view, (nextView) => {
|
|
38
|
+
updateState({ ...state, view: nextView });
|
|
39
|
+
});
|
|
40
|
+
const leftRail = el('div', { class: 'shell__left-rail' }, 'Filters (wired by the next plan)');
|
|
41
|
+
const canvas = el('div', { class: 'shell__canvas' });
|
|
42
|
+
const inspector = el('div', { class: 'shell__inspector', id: 'shell-inspector' }, 'Evidence inspector (wired by the next plan)');
|
|
43
|
+
const contextRail = el('div', { class: 'shell__context-rail' }, buildContextRailText(graph));
|
|
44
|
+
|
|
45
|
+
const inspectorToggle = el(
|
|
46
|
+
'button',
|
|
47
|
+
{
|
|
48
|
+
class: 'shell__inspector-toggle',
|
|
49
|
+
'aria-expanded': 'false',
|
|
50
|
+
'aria-controls': 'shell-inspector',
|
|
51
|
+
onClick: () => {
|
|
52
|
+
inspectorOverlayOpen = !inspectorOverlayOpen;
|
|
53
|
+
inspector.setAttribute('data-overlay-open', String(inspectorOverlayOpen));
|
|
54
|
+
inspectorToggle.setAttribute('aria-expanded', String(inspectorOverlayOpen));
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
'Inspector',
|
|
58
|
+
);
|
|
59
|
+
tabs.appendChild(inspectorToggle);
|
|
60
|
+
|
|
61
|
+
// Milestone 3, sub-project M3-UX-Query, Task 4: the query bar's own real
|
|
62
|
+
// slot. Placed into the existing view-tabs row (shell__view-tabs) rather
|
|
63
|
+
// than a new grid region, matching this session's own A11y precedent of
|
|
64
|
+
// adding a new control into an existing shell row (inspectorToggle,
|
|
65
|
+
// directly above) instead of widening shell.css's grid-template-areas.
|
|
66
|
+
const queryBar = el('div', { class: 'shell__query-bar-slot' });
|
|
67
|
+
tabs.appendChild(queryBar);
|
|
68
|
+
|
|
69
|
+
shell.appendChild(header);
|
|
70
|
+
shell.appendChild(coverageBanner);
|
|
71
|
+
shell.appendChild(tabs);
|
|
72
|
+
shell.appendChild(leftRail);
|
|
73
|
+
shell.appendChild(canvas);
|
|
74
|
+
shell.appendChild(inspector);
|
|
75
|
+
shell.appendChild(contextRail);
|
|
76
|
+
|
|
77
|
+
clear(rootEl);
|
|
78
|
+
rootEl.appendChild(shell);
|
|
79
|
+
|
|
80
|
+
function notifyStateChange() {
|
|
81
|
+
const snapshot = { ...state };
|
|
82
|
+
for (const listener of stateChangeListeners) listener(snapshot);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Shared by setActiveView/setSelection/setFilters/the tab-click handler:
|
|
86
|
+
// update the closure state, sync the URL hash, refresh the tab UI, notify.
|
|
87
|
+
function updateState(nextState) {
|
|
88
|
+
state = nextState;
|
|
89
|
+
window.location.hash = serializeStateToHash(state);
|
|
90
|
+
applyActiveTab(tabs, state.view);
|
|
91
|
+
notifyStateChange();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handleHashChange() {
|
|
95
|
+
state = parseStateFromHash(window.location.hash);
|
|
96
|
+
applyActiveTab(tabs, state.view);
|
|
97
|
+
notifyStateChange();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
window.addEventListener('hashchange', handleHashChange);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
setActiveView(viewName) {
|
|
104
|
+
updateState({ ...state, view: viewName });
|
|
105
|
+
},
|
|
106
|
+
getState() {
|
|
107
|
+
return { ...state };
|
|
108
|
+
},
|
|
109
|
+
setSelection(selectedId) {
|
|
110
|
+
updateState({ ...state, selectedId });
|
|
111
|
+
},
|
|
112
|
+
setFilters(filters) {
|
|
113
|
+
updateState({ ...state, filters });
|
|
114
|
+
},
|
|
115
|
+
setTable(tableId) {
|
|
116
|
+
updateState({ ...state, table: tableId });
|
|
117
|
+
},
|
|
118
|
+
onStateChange(listener) {
|
|
119
|
+
stateChangeListeners.push(listener);
|
|
120
|
+
return () => {
|
|
121
|
+
stateChangeListeners = stateChangeListeners.filter((l) => l !== listener);
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
getCanvasEl: () => canvas,
|
|
125
|
+
getInspectorEl: () => inspector,
|
|
126
|
+
getContextRailEl: () => contextRail,
|
|
127
|
+
getLeftRailEl: () => leftRail,
|
|
128
|
+
getQueryBarEl: () => queryBar,
|
|
129
|
+
destroy() {
|
|
130
|
+
window.removeEventListener('hashchange', handleHashChange);
|
|
131
|
+
stateChangeListeners = [];
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildHeader(graph) {
|
|
137
|
+
const repo = graph.scope?.repository ?? 'unknown repository';
|
|
138
|
+
const env = graph.scope?.environment ?? 'unknown environment';
|
|
139
|
+
const scanStatus = graph.scanHealth?.status ?? 'unknown';
|
|
140
|
+
const isFixture = graph.scope?.source === 'fixture';
|
|
141
|
+
return el('div', { class: 'shell__header' }, [
|
|
142
|
+
el('div', { class: 'shell__header-title' }, 'Data Flow Explorer'),
|
|
143
|
+
el('div', { class: 'shell__header-meta' }, `${repo} · ${env} · Scan ${scanStatus}`),
|
|
144
|
+
isFixture ? el('div', { class: 'shell__header-meta', 'data-illustrative': 'true' }, 'Illustrative demo data') : null,
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildCoverageBanner(graph) {
|
|
149
|
+
const status = graph.coverage?.status ?? graph.scanHealth?.status;
|
|
150
|
+
const banner = el('div', { class: 'shell__coverage-banner' }, `Coverage: ${status ?? 'unknown'} — not a complete assessment`);
|
|
151
|
+
if (status && status !== 'complete') banner.setAttribute('data-visible', 'true');
|
|
152
|
+
return banner;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildViewTabs(activeView, onSelect) {
|
|
156
|
+
const tabs = el(
|
|
157
|
+
'div',
|
|
158
|
+
{ class: 'shell__view-tabs', role: 'tablist' },
|
|
159
|
+
VIEWS.map((v) =>
|
|
160
|
+
el(
|
|
161
|
+
'button',
|
|
162
|
+
{
|
|
163
|
+
class: 'shell__view-tab',
|
|
164
|
+
role: 'tab',
|
|
165
|
+
'aria-selected': String(v.id === activeView),
|
|
166
|
+
'data-view-id': v.id,
|
|
167
|
+
onClick: () => onSelect(v.id),
|
|
168
|
+
},
|
|
169
|
+
v.label,
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
return tabs;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function applyActiveTab(tabsEl, activeView) {
|
|
177
|
+
for (const btn of tabsEl.querySelectorAll('[data-view-id]')) {
|
|
178
|
+
btn.setAttribute('aria-selected', String(btn.getAttribute('data-view-id') === activeView));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function buildContextRailText(graph) {
|
|
183
|
+
return `${graph.nodes.length} nodes · ${graph.edges.length} edges · ${graph.flows.length} flows`;
|
|
184
|
+
}
|