@clear-capabilities/agentic-security-scanner 0.184.0 → 0.185.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/agentic-security.js +87 -0
  3. package/dist/{dad58d1113a12c23a142.json → 0b301d45b231f144a1b4.json} +26 -9
  4. package/dist/1128.index.js +908 -0
  5. package/dist/1336.index.js +261 -112
  6. package/dist/1665.index.js +828 -0
  7. package/dist/9579.index.js +1 -1
  8. package/dist/agentic-security.mjs +15 -15
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/dist/frontend/src/shell.js +12 -2
  11. package/package.json +26 -9
  12. package/src/dataflow/CLAUDE.md +43 -1
  13. package/src/dataflow/backward.js +42 -7
  14. package/src/dataflow/cross-service-taint.js +83 -1
  15. package/src/dataflow/demand-query.js +929 -0
  16. package/src/dataflow/dep-fingerprint.js +718 -0
  17. package/src/dataflow/engine.js +48 -6
  18. package/src/dataflow/ifds-precise.js +22 -0
  19. package/src/dataflow/incremental-replay.js +118 -0
  20. package/src/dataflow/incremental-session.js +257 -0
  21. package/src/dataflow/incremental.js +237 -35
  22. package/src/dataflow/index.js +25 -94
  23. package/src/dataflow/summaries.js +72 -6
  24. package/src/dataflow/tabulation.js +80 -24
  25. package/src/engine.js +51 -3
  26. package/src/graph/CLAUDE.md +31 -0
  27. package/src/graph/adapters/artifact.js +81 -0
  28. package/src/graph/adapters/crossrepo.js +75 -0
  29. package/src/graph/adapters/dataflow.js +76 -0
  30. package/src/graph/adapters/provenance.js +92 -0
  31. package/src/graph/adapters/semantic.js +89 -0
  32. package/src/graph/crosswalk-builders.js +152 -0
  33. package/src/graph/crosswalk.js +146 -0
  34. package/src/graph/index.js +15 -0
  35. package/src/graph/observation.js +57 -0
  36. package/src/graph/refs.js +105 -0
  37. package/src/graph/registry.js +129 -0
  38. package/src/graph/relations.js +81 -0
  39. package/src/graph/scc.js +241 -0
  40. package/src/graph/view.js +254 -0
  41. package/src/lineage/CLAUDE.md +71 -8
  42. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  43. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  44. package/src/lineage/boundary-contract.js +273 -0
  45. package/src/lineage/completion.js +205 -0
  46. package/src/lineage/continuation.js +140 -0
  47. package/src/lineage/control-credit.js +313 -0
  48. package/src/lineage/correlation.js +199 -0
  49. package/src/lineage/coverage.js +8 -1
  50. package/src/lineage/cross-repo-link.js +22 -0
  51. package/src/lineage/cut-planner.js +548 -0
  52. package/src/lineage/driver.js +219 -66
  53. package/src/lineage/engine.js +166 -21
  54. package/src/lineage/export-json.js +5 -0
  55. package/src/lineage/graph-builder.js +84 -7
  56. package/src/lineage/haskell-view.js +59 -0
  57. package/src/lineage/index.js +3 -0
  58. package/src/lineage/nix-view.js +35 -0
  59. package/src/lineage/owned-build.js +77 -0
  60. package/src/lineage/owned-worker-entry.js +22 -0
  61. package/src/lineage/owned-worker.js +95 -0
  62. package/src/lineage/path-evidence.js +123 -0
  63. package/src/lineage/path-predicates.js +529 -0
  64. package/src/lineage/path-query.js +189 -20
  65. package/src/lineage/path-store.js +191 -17
  66. package/src/lineage/scc-solver.js +186 -0
  67. package/src/lineage/smt-orchestrator.js +138 -0
  68. package/src/lineage/source-seeding.js +36 -2
  69. package/src/lineage/summaries.js +66 -4
  70. package/src/lineage/value-versions.js +130 -0
  71. package/src/lineage/witness-selection.js +95 -0
  72. package/src/pipeline/scan-health.js +18 -0
  73. package/src/posture/CLAUDE.md +13 -0
  74. package/src/posture/evidence-bundle.js +1 -1
  75. package/src/posture/witness-bundle.js +573 -0
  76. package/src/posture/witness-runtime.js +276 -0
  77. package/src/report/index.js +2 -0
@@ -0,0 +1,129 @@
1
+ //
2
+ // registry.js: the set of graph views a query may span (DAG-06 R06.6).
3
+ //
4
+ // What registration rejects, each with a typed GraphViewError:
5
+ // INCOMPATIBLE_VERSION an adapter whose major version this build cannot read
6
+ // ID_COLLISION the same (graph kind, repository, digest) registered
7
+ // twice with different content: a digest names ONE
8
+ // immutable graph
9
+ // BAD_REF a malformed view
10
+ //
11
+ // What lookups reject:
12
+ // DANGLING_REF the graph is not registered, or the entity is not in it
13
+ // VERSION_INCOMPATIBLE a reference written by a newer reference format
14
+ // AMBIGUOUS_LOCAL_ID a bare local id that exists in more than one graph; a
15
+ // local id is only meaningful inside its graph, so the
16
+ // registry will not pick one
17
+ //
18
+ // A reference never resolves across repositories: the repository is part of the
19
+ // reference, so `pnode:abc` in repository A and `pnode:abc` in repository B are
20
+ // two different entities.
21
+
22
+ import { GraphViewError, graphIdentityKey, validateRef, REF_VERSION } from './refs.js';
23
+ import { PROVENANCE_ADAPTER_VERSION } from './adapters/provenance.js';
24
+ import { DATAFLOW_ADAPTER_VERSION } from './adapters/dataflow.js';
25
+ import { SEMANTIC_ADAPTER_VERSION } from './adapters/semantic.js';
26
+ import { ARTIFACT_ADAPTER_VERSION } from './adapters/artifact.js';
27
+ import { CROSSREPO_ADAPTER_VERSION, FINDINGS_ADAPTER_VERSION } from './adapters/crossrepo.js';
28
+
29
+ const major = (v) => Number(String(v).split('.')[0]);
30
+
31
+ /** Adapter majors this build understands, per graph kind. */
32
+ export const SUPPORTED_ADAPTER_MAJORS = Object.freeze({
33
+ provenance: [major(PROVENANCE_ADAPTER_VERSION)],
34
+ dataflow: [major(DATAFLOW_ADAPTER_VERSION)],
35
+ semantic: [major(SEMANTIC_ADAPTER_VERSION)],
36
+ artifact: [major(ARTIFACT_ADAPTER_VERSION)],
37
+ 'cross-repo-links': [major(CROSSREPO_ADAPTER_VERSION)],
38
+ findings: [major(FINDINGS_ADAPTER_VERSION)],
39
+ });
40
+
41
+ export function createGraphRegistry() {
42
+ const views = new Map(); // identity key -> view
43
+
44
+ function viewFor(ref) {
45
+ const bad = validateRef(ref);
46
+ if (bad) {
47
+ throw new GraphViewError(ref && ref.v !== undefined && ref.v !== REF_VERSION ? 'VERSION_INCOMPATIBLE' : 'BAD_REF', bad, { ref });
48
+ }
49
+ const v = views.get(graphIdentityKey(ref));
50
+ if (!v) throw new GraphViewError('DANGLING_REF', `no registered ${ref.graphKind} graph for repository ${ref.repository} with digest ${ref.graphDigest.slice(0, 12)}`, { ref });
51
+ return v;
52
+ }
53
+
54
+ const foreignViews = () => [...views.values()].filter((v) => v._allowForeign);
55
+
56
+ return {
57
+ register(view) {
58
+ const kinds = SUPPORTED_ADAPTER_MAJORS[view?.graphKind];
59
+ if (!kinds) throw new GraphViewError('BAD_REF', `unknown graph kind ${view?.graphKind}`);
60
+ if (!kinds.includes(major(view.adapterVersion))) {
61
+ throw new GraphViewError('INCOMPATIBLE_VERSION', `${view.graphKind} adapter ${view.adapterVersion} is not readable by this build (supported majors: ${kinds.join(',')})`);
62
+ }
63
+ const key = graphIdentityKey(view);
64
+ const prev = views.get(key);
65
+ if (prev) {
66
+ if (prev.fingerprint !== view.fingerprint) {
67
+ throw new GraphViewError('ID_COLLISION', `${view.graphKind}/${view.repository} digest ${view.graphDigest.slice(0, 12)} is already registered with different content`, { key });
68
+ }
69
+ return prev;
70
+ }
71
+ views.set(key, view);
72
+ return view;
73
+ },
74
+
75
+ views() { return [...views.values()]; },
76
+ viewFor,
77
+
78
+ has(ref) {
79
+ const v = views.get(graphIdentityKey(ref));
80
+ return !!v && v.has(ref);
81
+ },
82
+
83
+ node(ref) { return viewFor(ref).node(ref); },
84
+
85
+ outgoing(ref, filter) {
86
+ const out = viewFor(ref).outgoing(ref, filter);
87
+ for (const fv of foreignViews()) if (fv.graphKind !== ref.graphKind || fv.repository !== ref.repository || fv.graphDigest !== ref.graphDigest) out.push(...fv.outgoing(ref, filter));
88
+ return out;
89
+ },
90
+
91
+ incoming(ref, filter) {
92
+ const inn = viewFor(ref).incoming(ref, filter);
93
+ for (const fv of foreignViews()) if (fv.graphKind !== ref.graphKind || fv.repository !== ref.repository || fv.graphDigest !== ref.graphDigest) inn.push(...fv.incoming(ref, filter));
94
+ return inn;
95
+ },
96
+
97
+ /** A bare local id is ambiguous when more than one registered graph has it. */
98
+ resolveLocal(entityKind, localId, { graphKind } = {}) {
99
+ const hits = [];
100
+ for (const v of views.values()) {
101
+ if (graphKind && v.graphKind !== graphKind) continue;
102
+ try { hits.push(v.ref(entityKind, localId)); } catch { /* not in this graph */ }
103
+ }
104
+ if (hits.length === 0) throw new GraphViewError('DANGLING_REF', `no ${entityKind} ${localId} in any registered graph`);
105
+ if (hits.length > 1) throw new GraphViewError('AMBIGUOUS_LOCAL_ID', `${entityKind} ${localId} exists in ${hits.length} registered graphs; use a full reference`, { candidates: hits });
106
+ return hits[0];
107
+ },
108
+
109
+ /**
110
+ * Classify a foreign endpoint (for example, a cross-repository link end):
111
+ * resolved registered, and the entity exists
112
+ * unresolved no registered graph with that identity (not an error:
113
+ * the remote graph simply is not loaded)
114
+ * dangling the graph is registered but the entity is not in it
115
+ * repository-mismatch a graph with that digest is registered, but under a
116
+ * different repository than the reference claims
117
+ */
118
+ classify(ref) {
119
+ const bad = validateRef(ref);
120
+ if (bad) throw new GraphViewError('BAD_REF', bad, { ref });
121
+ const v = views.get(graphIdentityKey(ref));
122
+ if (v) return v.has(ref) ? 'resolved' : 'dangling';
123
+ for (const o of views.values()) {
124
+ if (o.graphKind === ref.graphKind && o.graphDigest === ref.graphDigest && o.repository !== ref.repository) return 'repository-mismatch';
125
+ }
126
+ return 'unresolved';
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,81 @@
1
+ //
2
+ // relations.js: relation kinds and the traversal filter (DAG-06 R06.3, R06.4).
3
+ //
4
+ // Two independent axes, never collapsed into one:
5
+ // - RELATION: what the edge means (data flow, calls, ...).
6
+ // - ORIGIN: how the edge came to exist (derived from code, declared by an
7
+ // operator, observed at runtime, ...).
8
+ //
9
+ // A query chooses the relations it may traverse AND the origins it trusts.
10
+ // The default filter traverses code-derived and derived edges only, so an
11
+ // operator declaration or a runtime observation is never silently walked as
12
+ // if it were a code-derived data flow (R06.4).
13
+
14
+ import { GraphViewError } from './refs.js';
15
+
16
+ export const RELATION_KINDS = Object.freeze([
17
+ 'data_flow', // a value moves from one place to another
18
+ 'control_dependence', // execution of B depends on a decision at A
19
+ 'calls', // a call site invokes a callee
20
+ 'returns', // a callee result comes back to a caller
21
+ 'dependency', // A needs B (package, module, component)
22
+ 'declaration', // A declares/contains B (symbol -> call site, origin)
23
+ 'observation', // a runtime observation attached to an entity
24
+ 'artifact_derivation', // A was built into / produced B
25
+ ]);
26
+
27
+ export const EDGE_ORIGINS = Object.freeze([
28
+ 'code', // read directly from analyzed source
29
+ 'derived', // computed from code-derived facts by a projection or index
30
+ 'declared', // asserted by an operator (cross-repo links, deployments)
31
+ 'observed', // seen at runtime (telemetry)
32
+ 'schema', // taken from a schema or manifest
33
+ ]);
34
+
35
+ /** Origins a default query trusts as "the program's own structure". */
36
+ export const DEFAULT_TRUSTED_ORIGINS = Object.freeze(['code', 'derived', 'schema']);
37
+
38
+ /**
39
+ * Normalize a relation filter. Accepted forms:
40
+ * undefined / null -> every relation, trusted origins only
41
+ * 'calls' | ['calls', ...] -> those relations, trusted origins only
42
+ * { relations, origins } -> explicit on both axes
43
+ * Unknown relation or origin names throw, so a typo never silently widens
44
+ * or empties a query.
45
+ */
46
+ export function normalizeFilter(filter) {
47
+ let relations = null;
48
+ let origins = DEFAULT_TRUSTED_ORIGINS;
49
+ if (filter === undefined || filter === null) {
50
+ // defaults
51
+ } else if (typeof filter === 'string') {
52
+ relations = [filter];
53
+ } else if (Array.isArray(filter)) {
54
+ relations = filter;
55
+ } else if (typeof filter === 'object') {
56
+ if (filter.relations !== undefined && filter.relations !== null) {
57
+ relations = typeof filter.relations === 'string' ? [filter.relations] : filter.relations;
58
+ }
59
+ if (filter.origins !== undefined && filter.origins !== null) {
60
+ origins = typeof filter.origins === 'string' ? [filter.origins] : filter.origins;
61
+ }
62
+ } else {
63
+ throw new GraphViewError('BAD_FILTER', 'a relation filter is a name, a list of names or { relations, origins }');
64
+ }
65
+ if (relations !== null) {
66
+ if (!Array.isArray(relations)) throw new GraphViewError('BAD_FILTER', 'relations must be a list');
67
+ for (const r of relations) {
68
+ if (!RELATION_KINDS.includes(r)) throw new GraphViewError('BAD_FILTER', `unknown relation kind ${JSON.stringify(r)}`);
69
+ }
70
+ }
71
+ if (!Array.isArray(origins)) throw new GraphViewError('BAD_FILTER', 'origins must be a list');
72
+ for (const o of origins) {
73
+ if (!EDGE_ORIGINS.includes(o)) throw new GraphViewError('BAD_FILTER', `unknown edge origin ${JSON.stringify(o)}`);
74
+ }
75
+ return { relations: relations ? new Set(relations) : null, origins: new Set(origins) };
76
+ }
77
+
78
+ export function edgeMatches(edge, nf) {
79
+ if (nf.relations && !nf.relations.has(edge.relation)) return false;
80
+ return nf.origins.has(edge.origin);
81
+ }
@@ -0,0 +1,241 @@
1
+ // Strongly connected components and condensation for typed directed graphs.
2
+ //
3
+ // Pure and iterative: no recursion anywhere (a 100,000-node chain must not
4
+ // overflow the JavaScript stack), no I/O, no dependence on any analysis
5
+ // engine. The raw graph is never modified; the condensation is a derived
6
+ // view. It is a DAG, the raw graph is a provenance graph that may be cyclic.
7
+ //
8
+ // Input: `{ nodes: string[], edges: Array<[from, to] | {from, to}> }`.
9
+ // Node ids are strings. Duplicate nodes and duplicate edges are collapsed.
10
+ // An edge whose endpoint is not in `nodes` throws a TypeError rather than
11
+ // being silently dropped (the caller decides how to filter).
12
+ //
13
+ // Output is independent of the order of `nodes` and `edges`:
14
+ // components Array<{ id, members, internalEdges, recursive }>
15
+ // ids are 0..n-1 assigned by ascending smallest member, so
16
+ // they are stable. `members` is sorted. `internalEdges` is the
17
+ // sorted, de-duplicated list of [from, to] inside the
18
+ // component (self-loops included). `recursive` is true when
19
+ // the component has more than one member or a self-loop.
20
+ // componentOf Map<node, componentId>
21
+ // condensationEdges sorted unique [fromId, toId] across components
22
+ // order component ids in a deterministic topological order: for
23
+ // every cross-component edge u -> v, comp(u) appears before
24
+ // comp(v). Ties are broken by smallest component id.
25
+ //
26
+ // To schedule callees before callers, pass edges as callee -> caller (or
27
+ // reverse `order`). SCC membership says nodes can reach each other; it is
28
+ // not evidence that every pair has a feasible data path in any context.
29
+
30
+ function cmp(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
31
+
32
+ function edgeEnds(e) {
33
+ if (Array.isArray(e)) return [e[0], e[1]];
34
+ return [e.from, e.to];
35
+ }
36
+
37
+ // Minimal binary min-heap of non-negative integers.
38
+ class IntHeap {
39
+ constructor() { this.a = []; }
40
+ get size() { return this.a.length; }
41
+ push(v) {
42
+ const a = this.a;
43
+ let i = a.length;
44
+ a.push(v);
45
+ while (i > 0) {
46
+ const p = (i - 1) >> 1;
47
+ if (a[p] <= v) break;
48
+ a[i] = a[p];
49
+ i = p;
50
+ }
51
+ a[i] = v;
52
+ }
53
+ pop() {
54
+ const a = this.a;
55
+ const top = a[0];
56
+ const last = a.pop();
57
+ if (a.length > 0) {
58
+ let i = 0;
59
+ const n = a.length;
60
+ for (;;) {
61
+ let c = 2 * i + 1;
62
+ if (c >= n) break;
63
+ if (c + 1 < n && a[c + 1] < a[c]) c++;
64
+ if (a[c] >= last) break;
65
+ a[i] = a[c];
66
+ i = c;
67
+ }
68
+ a[i] = last;
69
+ }
70
+ return top;
71
+ }
72
+ }
73
+
74
+ export function computeScc(graph) {
75
+ const rawNodes = (graph && graph.nodes) || [];
76
+ const rawEdges = (graph && graph.edges) || [];
77
+ const names = [...new Set(rawNodes)].sort(cmp);
78
+ const n = names.length;
79
+ const index = new Map();
80
+ for (let i = 0; i < n; i++) index.set(names[i], i);
81
+
82
+ // Adjacency over integer indexes, de-duplicated and sorted.
83
+ const adjSets = new Array(n).fill(null);
84
+ for (const e of rawEdges) {
85
+ const [f, t] = edgeEnds(e);
86
+ const fi = index.get(f);
87
+ const ti = index.get(t);
88
+ if (fi === undefined || ti === undefined) {
89
+ throw new TypeError(`scc: edge endpoint not in nodes: ${String(f)} -> ${String(t)}`);
90
+ }
91
+ (adjSets[fi] ||= new Set()).add(ti);
92
+ }
93
+ const adj = adjSets.map(s => (s ? [...s].sort((a, b) => a - b) : []));
94
+
95
+ // Iterative Tarjan with explicit call stack.
96
+ const idx = new Int32Array(n).fill(-1);
97
+ const low = new Int32Array(n);
98
+ const onStack = new Uint8Array(n);
99
+ const comp = new Int32Array(n).fill(-1);
100
+ const sccStack = [];
101
+ const callNode = [];
102
+ const callPos = [];
103
+ let counter = 0;
104
+ const rawComps = [];
105
+ for (let root = 0; root < n; root++) {
106
+ if (idx[root] !== -1) continue;
107
+ callNode.push(root); callPos.push(0);
108
+ idx[root] = low[root] = counter++;
109
+ sccStack.push(root); onStack[root] = 1;
110
+ while (callNode.length > 0) {
111
+ const top = callNode.length - 1;
112
+ const v = callNode[top];
113
+ const pos = callPos[top];
114
+ const out = adj[v];
115
+ if (pos < out.length) {
116
+ callPos[top] = pos + 1;
117
+ const w = out[pos];
118
+ if (idx[w] === -1) {
119
+ idx[w] = low[w] = counter++;
120
+ sccStack.push(w); onStack[w] = 1;
121
+ callNode.push(w); callPos.push(0);
122
+ } else if (onStack[w] && idx[w] < low[v]) {
123
+ low[v] = idx[w];
124
+ }
125
+ } else {
126
+ if (low[v] === idx[v]) {
127
+ const members = [];
128
+ let w;
129
+ do {
130
+ w = sccStack.pop();
131
+ onStack[w] = 0;
132
+ members.push(w);
133
+ } while (w !== v);
134
+ rawComps.push(members);
135
+ }
136
+ callNode.pop(); callPos.pop();
137
+ if (callNode.length > 0) {
138
+ const parent = callNode[callNode.length - 1];
139
+ if (low[v] < low[parent]) low[parent] = low[v];
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ // Canonical component ids: ascending smallest member index (indexes follow
146
+ // the sorted names, so this is order-independent).
147
+ for (const m of rawComps) m.sort((a, b) => a - b);
148
+ rawComps.sort((a, b) => a[0] - b[0]);
149
+ for (let c = 0; c < rawComps.length; c++) for (const v of rawComps[c]) comp[v] = c;
150
+
151
+ const k = rawComps.length;
152
+ const internal = Array.from({ length: k }, () => []);
153
+ const condSets = new Array(k).fill(null);
154
+ for (let v = 0; v < n; v++) {
155
+ const cv = comp[v];
156
+ for (const w of adj[v]) {
157
+ const cw = comp[w];
158
+ if (cv === cw) internal[cv].push([v, w]);
159
+ else (condSets[cv] ||= new Set()).add(cw);
160
+ }
161
+ }
162
+ const condAdj = condSets.map(s => (s ? [...s].sort((a, b) => a - b) : []));
163
+
164
+ // Kahn's algorithm with a min-heap on component id.
165
+ const indeg = new Int32Array(k);
166
+ for (let c = 0; c < k; c++) for (const d of condAdj[c]) indeg[d]++;
167
+ const heap = new IntHeap();
168
+ for (let c = 0; c < k; c++) if (indeg[c] === 0) heap.push(c);
169
+ const order = [];
170
+ while (heap.size > 0) {
171
+ const c = heap.pop();
172
+ order.push(c);
173
+ for (const d of condAdj[c]) if (--indeg[d] === 0) heap.push(d);
174
+ }
175
+
176
+ const components = new Array(k);
177
+ for (let c = 0; c < k; c++) {
178
+ const members = rawComps[c].map(i => names[i]);
179
+ const internalEdges = internal[c].map(([a, b]) => [names[a], names[b]]);
180
+ internalEdges.sort((x, y) => cmp(x[0], y[0]) || cmp(x[1], y[1]));
181
+ components[c] = {
182
+ id: c,
183
+ members,
184
+ internalEdges,
185
+ recursive: members.length > 1 || internalEdges.length > 0,
186
+ };
187
+ }
188
+ const componentOf = new Map();
189
+ for (let v = 0; v < n; v++) componentOf.set(names[v], comp[v]);
190
+ const condensationEdges = [];
191
+ for (let c = 0; c < k; c++) for (const d of condAdj[c]) condensationEdges.push([c, d]);
192
+
193
+ return { components, componentOf, condensationEdges, order };
194
+ }
195
+
196
+ // Checks a result against its input graph. Returns `{ ok, problems }`; every
197
+ // problem is a human-readable string. Verifies: each node is in exactly one
198
+ // component, members are sorted, every edge is either internal to a
199
+ // component or goes forward in `order`, internal edges match, and the
200
+ // condensation edges are exactly the cross-component edges.
201
+ export function verifyScc(graph, result) {
202
+ const problems = [];
203
+ const nodes = [...new Set((graph && graph.nodes) || [])];
204
+ const pos = new Map();
205
+ result.order.forEach((c, i) => pos.set(c, i));
206
+ if (result.order.length !== result.components.length || pos.size !== result.components.length) {
207
+ problems.push('order is not a permutation of the components');
208
+ }
209
+ const seen = new Set();
210
+ for (const c of result.components) {
211
+ for (let i = 0; i < c.members.length; i++) {
212
+ if (i > 0 && !(c.members[i - 1] < c.members[i])) problems.push(`component ${c.id} members not strictly sorted`);
213
+ if (seen.has(c.members[i])) problems.push(`node ${c.members[i]} in more than one component`);
214
+ seen.add(c.members[i]);
215
+ if (result.componentOf.get(c.members[i]) !== c.id) problems.push(`componentOf disagrees for ${c.members[i]}`);
216
+ }
217
+ }
218
+ for (const nd of nodes) if (!seen.has(nd)) problems.push(`node ${nd} missing from components`);
219
+ const cross = new Set();
220
+ const internalCount = new Map();
221
+ const edgeSeen = new Set();
222
+ for (const e of (graph && graph.edges) || []) {
223
+ const [f, t] = edgeEnds(e);
224
+ const key = `${f}\u0000${t}`;
225
+ if (edgeSeen.has(key)) continue;
226
+ edgeSeen.add(key);
227
+ const cf = result.componentOf.get(f);
228
+ const ct = result.componentOf.get(t);
229
+ if (cf === undefined || ct === undefined) { problems.push(`edge ${f} -> ${t} has an unknown endpoint`); continue; }
230
+ if (cf === ct) { internalCount.set(cf, (internalCount.get(cf) || 0) + 1); continue; }
231
+ cross.add(`${cf}\u0000${ct}`);
232
+ if (!(pos.get(cf) < pos.get(ct))) problems.push(`cross edge ${f} -> ${t} violates topological order`);
233
+ }
234
+ for (const c of result.components) {
235
+ if (c.internalEdges.length !== (internalCount.get(c.id) || 0)) problems.push(`component ${c.id} internal edge count mismatch`);
236
+ if (c.recursive !== (c.members.length > 1 || c.internalEdges.length > 0)) problems.push(`component ${c.id} recursive flag wrong`);
237
+ }
238
+ const condKeys = new Set(result.condensationEdges.map(([a, b]) => `${a}\u0000${b}`));
239
+ if (condKeys.size !== cross.size || [...cross].some(x => !condKeys.has(x))) problems.push('condensation edges differ from the cross-component edges');
240
+ return { ok: problems.length === 0, problems };
241
+ }