diff --git a/package.json b/package.json index fcb7890ec4..e20bffd295 100644 --- a/package.json +++ b/package.json @@ -153,7 +153,7 @@ "check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/*.test.ts && node --experimental-strip-types scripts/layering/check.ts", "check:di-seams": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/di-seams/*.test.ts && node --experimental-strip-types scripts/di-seams/check.ts", "depgraph": "node --experimental-strip-types scripts/depgraph/build.ts", - "depgraph:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts", + "depgraph:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/import-graph.test.ts scripts/depgraph/affected.test.ts", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues", "check:bundle-owner-files": "node --experimental-strip-types scripts/check-bundle-owner-files.ts", "check:package": "node --experimental-strip-types scripts/check-package.ts", @@ -293,8 +293,8 @@ "@agent-device/device-selection": "workspace:*", "@agent-device/host-kit": "workspace:*", "@agent-device/kernel": "workspace:*", - "@agent-device/managed-allocation": "workspace:*", "@agent-device/maestro": "workspace:*", + "@agent-device/managed-allocation": "workspace:*", "@agent-device/platform-android": "workspace:*", "@agent-device/platform-apple": "workspace:*", "@agent-device/platform-harmonyos": "workspace:*", @@ -315,6 +315,7 @@ "@limrun/api": "^0.59.0", "@nkzw/eslint-plugin": "^2.0.0", "@nkzw/oxlint-config": "^1.3.0", + "@statelyai/graph": "2.4.0", "@stryker-mutator/core": "10.0.0", "@stryker-mutator/vitest-runner": "10.0.0", "@types/json-schema": "^7.0.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0f06abb97..0784e3e2be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: '@nkzw/oxlint-config': specifier: ^1.3.0 version: 1.3.0(eslint@10.9.1(supports-color@7.2.0))(oxlint@1.79.0)(supports-color@7.2.0)(typescript@7.0.2) + '@statelyai/graph': + specifier: 2.4.0 + version: 2.4.0(zod@4.4.3) '@stryker-mutator/core': specifier: 10.0.0 version: 10.0.0(@types/node@22.20.1) @@ -1787,6 +1790,47 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@statelyai/graph@2.4.0': + resolution: {integrity: sha512-bJfR7s7q2s/0fyM17qJl3QBVBnYj38OzmWYkpXstkD8DVTuFNvwfpGXtkOAbHWqDmdLerRGmcRRS+mUsJYC7gQ==} + peerDependencies: + '@dagrejs/dagre': ^3.0.0 || ^4.0.0 + '@hpcc-js/wasm-graphviz': ^1.0.0 + cytoscape: ^3.0.0 + d3-force: ^3.0.0 + d3-hierarchy: ^3.0.0 + dotparser: ^1.0.0 + elkjs: ^0.9.0 || ^0.10.0 || ^0.11.0 + fast-xml-parser: ^5.0.0 + graphology: ^0.26.0 + graphology-layout-forceatlas2: ^0.10.0 + webcola: ^3.4.0 + zod: ^4.0.0 + peerDependenciesMeta: + '@dagrejs/dagre': + optional: true + '@hpcc-js/wasm-graphviz': + optional: true + cytoscape: + optional: true + d3-force: + optional: true + d3-hierarchy: + optional: true + dotparser: + optional: true + elkjs: + optional: true + fast-xml-parser: + optional: true + graphology: + optional: true + graphology-layout-forceatlas2: + optional: true + webcola: + optional: true + zod: + optional: true + '@stryker-mutator/api@10.0.0': resolution: {integrity: sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==} engines: {node: '>=22.0.0'} @@ -5215,6 +5259,10 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@statelyai/graph@2.4.0(zod@4.4.3)': + optionalDependencies: + zod: 4.4.3 + '@stryker-mutator/api@10.0.0': dependencies: mutation-testing-metrics: 3.8.4 diff --git a/scripts/depgraph/README.md b/scripts/depgraph/README.md index 8b1ad8b1b2..fe63f051a4 100644 --- a/scripts/depgraph/README.md +++ b/scripts/depgraph/README.md @@ -45,8 +45,10 @@ gate rejects value-import cycles (R4) and spine back-edges (R5); the graph addit `{ c as b }`, the path exists and deleting `a -> c` still breaks `a`), it does not preserve when a module's side effects run, and a direct import is often deliberately clearer than reaching through a barrel. Deciding whether any given edge can go needs symbol-level analysis this does - not attempt. ~1300 of them: a place to look, never a work list. -- **type-only and dynamic cycles** — 8 of them, all outside R4 by design (a type-only import is + not attempt. ~2300 of them: a place to look, never a work list. They are exactly the value + edges the transitive reduction drops. A value-import cycle (an R4 failure) makes the reduction + undefined, so the report then lists the cycle and leaves this count uncomputed. +- **type-only and dynamic cycles** — 23 of them, all outside R4 by design (a type-only import is free at runtime, a dynamic one is a deliberate cold-start seam). Worth reading when a module feels hard to reason about. @@ -99,6 +101,15 @@ the gate enforces — a separate extractor with its own resolution behaviour wou graph nobody is enforcing. Cross-checked once against `dependency-cruiser` 3.1.1 (at the commit it was written): same modules and edges, plus 88 dynamic/type-only edges dependency-cruiser fails to resolve. +## Graph algorithms + +Extraction stays with the layering gate; traversal does not. `import-graph.ts` turns the collapsed +edges into a [`@statelyai/graph`](https://stately.ai/docs/packages/graph) graph filtered to the +edge kinds a question needs (value for evaluation order, value + dynamic for what a handler can +run), and the report and `affected` query use its BFS, postorder, and transitive reduction rather +than hand-rolled walks. The library also carries centrality, community, dominator, and layout +algorithms; reach for them in a throwaway probe before writing a new traversal here. + ## What the JSON carries - `zones[]` — id, spine `rank` (`null` when intentionally unranked), `classification`, file diff --git a/scripts/depgraph/affected.ts b/scripts/depgraph/affected.ts index 58fb1c3c67..7dd5c77c71 100644 --- a/scripts/depgraph/affected.ts +++ b/scripts/depgraph/affected.ts @@ -7,48 +7,18 @@ // The graph itself comes from `scripts/depgraph/model.ts`, which is extracted with the layering // gate's own model — the dependents reported here are exactly the edges CI enforces. +import { genBFS, getPredecessors, type Graph, type TraversalDirection } from '@statelyai/graph'; +import { EXECUTABLE_EDGES, importGraph, VALUE_EDGES } from './import-graph.ts'; import type { GraphEdge, GraphNode } from './model.ts'; -/** Reverse adjacency over value edges — the subgraph R4 keeps acyclic. */ -function valuePredecessors(edges: readonly GraphEdge[]): Map { - const predecessors = new Map(); - for (const edge of edges) { - if (edge.kind !== 'value') continue; - const list = predecessors.get(edge.to) ?? []; - list.push(edge.from); - predecessors.set(edge.to, list); - } - return predecessors; -} - -/** - * Forward adjacency over the edges a module actually executes: value imports plus dynamic - * ones. Dynamic edges matter here because the daemon loads every command handler through - * `import()` (`src/daemon/request-handler-chain.ts`) — dropping them would report that no - * command's handler chain reaches anything. - */ -function executableSuccessors(edges: readonly GraphEdge[]): Map { - const successors = new Map(); - for (const edge of edges) { - if (edge.kind === 'type') continue; - const list = successors.get(edge.from) ?? []; - list.push(edge.to); - successors.set(edge.from, list); - } - return successors; -} - -/** Breadth-first closure from `start`, excluding `start` itself. */ -function reachable(start: string, adjacency: ReadonlyMap): Set { - const seen = new Set([start]); - const queue = [start]; - for (let index = 0; index < queue.length; index++) { - for (const next of adjacency.get(queue[index]!) ?? []) { - if (seen.has(next)) continue; - seen.add(next); - queue.push(next); - } - } +/** Every node a breadth-first walk reaches from `start`, excluding `start` itself. */ +function reachable( + graph: Graph, + start: string, + direction: TraversalDirection = 'outgoing', +): Set { + const seen = new Set(); + for (const node of genBFS(graph, { from: start, direction })) seen.add(node.id); seen.delete(start); return seen; } @@ -69,9 +39,11 @@ export type DependentSet = { * make the count unactionable. */ export function collectDependents(file: string, edges: readonly GraphEdge[]): DependentSet { - const predecessors = valuePredecessors(edges); - const direct = [...new Set(predecessors.get(file) ?? [])].sort(); - const all = [...reachable(file, predecessors)].sort(); + const valueGraph = importGraph(edges, VALUE_EDGES, [file]); + const direct = getPredecessors(valueGraph, file) + .map((node) => node.id) + .sort(); + const all = [...reachable(valueGraph, file, 'incoming')].sort(); const directSet = new Set(direct); return { direct, all, transitiveOnly: all.filter((entry) => !directSet.has(entry)) }; } @@ -140,12 +112,16 @@ export function commandsReaching( chains: readonly CommandChain[], edges: readonly GraphEdge[], ): CommandChain[] { - const successors = executableSuccessors(edges); + const executableGraph = importGraph( + edges, + EXECUTABLE_EDGES, + chains.map((chain) => chain.entry), + ); const closures = new Map>(); const closureFor = (entry: string): Set => { let closure = closures.get(entry); if (!closure) { - closure = reachable(entry, successors); + closure = reachable(executableGraph, entry); closure.add(entry); closures.set(entry, closure); } diff --git a/scripts/depgraph/build.ts b/scripts/depgraph/build.ts index 269b869f25..0fa1fc125e 100644 --- a/scripts/depgraph/build.ts +++ b/scripts/depgraph/build.ts @@ -18,7 +18,7 @@ import { pathToFileURL } from 'node:url'; import { zoneRank } from '../layering/model.ts'; import { runAffected } from './affected-run.ts'; import { loadGraph } from './load.ts'; -import { computeLevels, type GraphData } from './model.ts'; +import { computeLevels, markTransitivelyReachableEdges, type GraphData } from './model.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', @@ -79,8 +79,14 @@ function edgeFlags(edge: GraphData['edges'][number]): number { ); } +/** Transitive reachability is undefined on a value cycle; the report then lists the cycle instead. */ +function hasValueCycle(graph: Pick): boolean { + return graph.cycles.some((cycle) => cycle.kind === 'value'); +} + function buildPayload(): Payload { const graph = loadGraph(repoRoot); + if (!hasValueCycle(graph)) markTransitivelyReachableEdges(graph.edges); const levels = computeLevels(graph.nodes, graph.edges); const zoneIndex = new Map(graph.zones.map((zone, index) => [zone.id, index])); @@ -115,6 +121,33 @@ function buildPayload(): Payload { }; } +/** The text summary printed after the JSON is written; every count is read back from `payload`. */ +function formatSummary(payload: Payload, jsonPath: string): string { + const valueCycles = payload.cycles.filter((cycle) => cycle.kind === 'value').length; + const otherCycles = payload.cycles.length - valueCycles; + const backEdges = payload.edges.filter(([, , , flags]) => flags & 1).length; + const transitivelyReachable = + valueCycles > 0 + ? 'not computed while a value-import cycle exists' + : payload.edges.filter(([, , , flags]) => flags & 2).length; + const typeInversions = Object.values(payload.typeInversions).reduce((sum, n) => sum + n, 0); + const authorityCounts = Object.entries(payload.authorityCounts) + .map(([label, count]) => `${label}=${count}`) + .join(', '); + return ( + `Dependency graph: ${payload.generated.files} files, ${payload.generated.edges} edges, ` + + `${payload.zones.length} zones\n` + + ` value-import cycles (R4): ${valueCycles}\n` + + ` type-only/dynamic cycles (not gate-rejected): ${otherCycles}\n` + + ` spine back-edges (R5): ${backEdges}\n` + + ` type-only spine inversions (R6): ${typeInversions}\n` + + ` value edges whose target is also reachable at distance >= 2: ${transitivelyReachable}\n` + + ` declared-authority labels: ${authorityCounts}\n` + + ` (reachability only — not a removability claim, see scripts/depgraph/README.md)\n` + + ` wrote ${path.relative(repoRoot, jsonPath)}\n` + ); +} + async function main(argv: readonly string[]): Promise { // `affected` is the query subcommand (scripts/depgraph/affected-run.ts); with no subcommand // this stays the whole-graph report. @@ -130,26 +163,7 @@ async function main(argv: readonly string[]): Promise { fs.mkdirSync(path.dirname(jsonPath), { recursive: true }); fs.writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`); - const valueCycles = payload.cycles.filter((cycle) => cycle.kind === 'value').length; - const otherCycles = payload.cycles.length - valueCycles; - const backEdges = payload.edges.filter(([, , , flags]) => flags & 1).length; - const transitivelyReachable = payload.edges.filter(([, , , flags]) => flags & 2).length; - const typeInversions = Object.values(payload.typeInversions).reduce((sum, n) => sum + n, 0); - const authorityCounts = Object.entries(payload.authorityCounts) - .map(([label, count]) => `${label}=${count}`) - .join(', '); - process.stdout.write( - `Dependency graph: ${payload.generated.files} files, ${payload.generated.edges} edges, ` + - `${payload.zones.length} zones\n` + - ` value-import cycles (R4): ${valueCycles}\n` + - ` type-only/dynamic cycles (not gate-rejected): ${otherCycles}\n` + - ` spine back-edges (R5): ${backEdges}\n` + - ` type-only spine inversions (R6): ${typeInversions}\n` + - ` value edges whose target is also reachable at distance >= 2: ${transitivelyReachable}\n` + - ` declared-authority labels: ${authorityCounts}\n` + - ` (reachability only — not a removability claim, see scripts/depgraph/README.md)\n` + - ` wrote ${path.relative(repoRoot, jsonPath)}\n`, - ); + process.stdout.write(formatSummary(payload, jsonPath)); return 0; } diff --git a/scripts/depgraph/import-graph.test.ts b/scripts/depgraph/import-graph.test.ts new file mode 100644 index 0000000000..8bc4dfc679 --- /dev/null +++ b/scripts/depgraph/import-graph.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveImportEdges } from '../layering/model.ts'; +import { EXECUTABLE_EDGES, importEdgeId, importGraph, VALUE_EDGES } from './import-graph.ts'; +import { collapseEdges } from './model.ts'; + +const edges = collapseEdges( + resolveImportEdges( + new Map( + Object.entries({ + 'src/core/a.ts': [ + "import { b } from './b.ts';", + "import type { C } from './c.ts';", + "void import('./d.ts');", + ].join('\n'), + 'src/core/b.ts': 'export const b = 1;', + 'src/core/c.ts': 'export type C = string;', + 'src/core/d.ts': 'export const d = 1;', + }), + ), + ), +); + +test('importGraph keeps only the requested edge kinds, keyed by file pair', () => { + assert.deepEqual( + importGraph(edges, VALUE_EDGES).edges.map((edge) => edge.id), + [importEdgeId('src/core/a.ts', 'src/core/b.ts')], + ); + assert.deepEqual( + importGraph(edges, EXECUTABLE_EDGES).edges.map((edge) => edge.id), + [ + importEdgeId('src/core/a.ts', 'src/core/b.ts'), + importEdgeId('src/core/a.ts', 'src/core/d.ts'), + ], + ); +}); + +test('importGraph adds explicit files that no kept edge touches', () => { + assert.deepEqual( + importGraph(edges, VALUE_EDGES, ['src/core/c.ts']) + .nodes.map((node) => node.id) + .sort(), + ['src/core/a.ts', 'src/core/b.ts', 'src/core/c.ts'], + ); +}); + +test('importGraph gives every distinct file pair its own edge id', () => { + const pair = (from: string, to: string) => ({ ...edges[0]!, from, to }); + const graph = importGraph( + [pair('src/a -> b.ts', 'src/c.ts'), pair('src/a', 'b.ts -> src/c.ts')], + VALUE_EDGES, + ); + + assert.equal(new Set(graph.edges.map((edge) => edge.id)).size, 2); +}); diff --git a/scripts/depgraph/import-graph.ts b/scripts/depgraph/import-graph.ts new file mode 100644 index 0000000000..cebcf65cbd --- /dev/null +++ b/scripts/depgraph/import-graph.ts @@ -0,0 +1,49 @@ +// The collapsed import edges as a `@statelyai/graph` graph, filtered to the edge kinds one +// question cares about. Every traversal in the depgraph report and the `affected` query runs on +// a graph built here, so a kind filter is the only thing that distinguishes their subgraphs. + +import { createGraph, type Graph } from '@statelyai/graph'; +import type { EdgeKind, GraphEdge } from './model.ts'; + +/** The subgraph R4 keeps acyclic: what a module needs before it can evaluate. */ +export const VALUE_EDGES: ReadonlySet = new Set(['value']); + +/** + * What a module can actually run: value imports plus dynamic ones. The daemon loads every + * command handler through `import()` (`src/daemon/request-handler-chain.ts`), so dropping + * dynamic edges would cut every handler chain at its root. + */ +export const EXECUTABLE_EDGES: ReadonlySet = new Set(['value', 'dynamic']); + +/** + * Identity of one file pair, shared by `collapseEdges` and the graph's edge ids. NUL cannot occur + * in a file path, so distinct pairs never share an id. + */ +export function importEdgeId(from: string, to: string): string { + return `${from}\u0000${to}`; +} + +/** + * One node per file that appears on either side of a kept edge, plus any `files` passed in, so + * isolated modules still exist as nodes. + */ +export function importGraph( + edges: readonly GraphEdge[], + kinds: ReadonlySet, + files: Iterable = [], +): Graph { + const kept = edges.filter((edge) => kinds.has(edge.kind)); + const ids = new Set(files); + for (const edge of kept) { + ids.add(edge.from); + ids.add(edge.to); + } + return createGraph({ + nodes: [...ids].map((id) => ({ id })), + edges: kept.map((edge) => ({ + id: importEdgeId(edge.from, edge.to), + sourceId: edge.from, + targetId: edge.to, + })), + }); +} diff --git a/scripts/depgraph/model.test.ts b/scripts/depgraph/model.test.ts index a9f807c664..ec802a67b8 100644 --- a/scripts/depgraph/model.test.ts +++ b/scripts/depgraph/model.test.ts @@ -243,6 +243,33 @@ test('flags only value edges whose target is already reachable at distance >= 2' assert.deepEqual(flagged, ['src/core/a.ts -> src/core/c.ts']); }); +function valueCycleFixture(): Map { + return sources({ + 'src/core/a.ts': "import { b } from './b.ts';\nexport const a = b;", + 'src/core/b.ts': "import { a } from './a.ts';\nexport const b = a;", + }); +} + +test('transitive marking is undefined on a value cycle', () => { + const edges = collapseEdges(resolveImportEdges(valueCycleFixture())); + + assert.throws(() => markTransitivelyReachableEdges(edges), /cycle/); +}); + +test('buildGraph still reports a value cycle and leaves edges unmarked', () => { + const files = valueCycleFixture(); + const graph = buildGraph(files, resolveImportEdges(files)); + + assert.deepEqual( + graph.cycles.map((cycle) => cycle.kind), + ['value'], + ); + assert.deepEqual( + graph.edges.filter((edge) => edge.transitivelyReachable), + [], + ); +}); + test('a type-only shortcut is never flagged against a value path', () => { const edges = collapseEdges( resolveImportEdges( diff --git a/scripts/depgraph/model.ts b/scripts/depgraph/model.ts index fc717494d1..fe723a212a 100644 --- a/scripts/depgraph/model.ts +++ b/scripts/depgraph/model.ts @@ -15,6 +15,8 @@ import { type ResolvedImportEdge, } from '../layering/model.ts'; import { ARCHITECTURE_OWNERSHIP, matchesDeclaredRoot } from '../layering/architecture-ownership.ts'; +import { genPostorder, getSuccessors, getTransitiveReduction } from '@statelyai/graph'; +import { importEdgeId, importGraph, VALUE_EDGES } from './import-graph.ts'; export type EdgeKind = 'value' | 'type' | 'dynamic'; @@ -72,7 +74,6 @@ export type GraphEdge = { backEdge: string | null; /** Set when this edge is a type-only spine inversion (`R6`), as `from-zone -> to-zone`. */ typeInversion: string | null; - /** True when the same pair is also reachable through a longer path of the same weight class. */ /** Target also reachable at distance >= 2. Reachability only — see the marker function. */ transitivelyReachable: boolean; /** Declared labels accumulated from every raw import in this collapsed pair. */ @@ -147,10 +148,6 @@ export function authorityLabelsForEdge(edge: ResolvedImportEdge): AuthorityLabel return authorityLabelsForDeclared(declaredAuthorities(edge)); } -function edgePair(from: string, to: string): string { - return `${from}\u0000${to}`; -} - function countAuthorityLabels(edgeAuthorities: readonly AuthorityLabel[][]): AuthorityCounts { const counts = Object.fromEntries(AUTHORITY_LABELS.map((label) => [label, 0])) as AuthorityCounts; for (const labels of edgeAuthorities) { @@ -183,7 +180,7 @@ export function collapseEdges(edges: readonly ResolvedImportEdge[]): GraphEdge[] const byPair = new Map(); for (const edge of edges) { if (edge.file === edge.target) continue; - const key = edgePair(edge.file, edge.target); + const key = importEdgeId(edge.file, edge.target); const kind = edgeKind(edge); const existing = byPair.get(key); const authorities = orderedDeclaredAuthorities([ @@ -210,22 +207,6 @@ export function collapseEdges(edges: readonly ResolvedImportEdge[]): GraphEdge[] ); } -/** - * Value-edge adjacency. Both the redundancy pass and the level computation walk the same - * subgraph — the one R4 guarantees is a DAG — so they share its construction rather than each - * rebuilding it. - */ -function valueSuccessors(edges: readonly GraphEdge[]): Map { - const successors = new Map(); - for (const edge of edges) { - if (edge.kind !== 'value') continue; - const list = successors.get(edge.from) ?? []; - list.push(edge.to); - successors.set(edge.from, list); - } - return successors; -} - /** * Flag value edges whose target is ALSO reachable from the source at distance >= 2. * @@ -241,39 +222,20 @@ function valueSuccessors(edges: readonly GraphEdge[]): Map { * - It says nothing about re-export chains being intentional. A direct import is frequently * clearer than reaching through a barrel. * - * So the output is a place to look, not a work list — and at ~1300 edges, a large one. Deciding + * So the output is a place to look, not a work list — and at ~2300 edges, a large one. Deciding * whether any given edge can go needs symbol-level analysis this does not attempt. + * + * On a DAG, the edges with a longer alternative path are exactly the ones the transitive + * reduction drops. The reduction is undefined on a cycle, so callers mark only when the graph has + * no `value` cycle (R4); the library throws otherwise. */ export function markTransitivelyReachableEdges(edges: GraphEdge[]): void { - const successors = valueSuccessors(edges); + const valueGraph = importGraph(edges, VALUE_EDGES); + const kept = new Set(getTransitiveReduction(valueGraph).edges.map((edge) => edge.id)); for (const edge of edges) { if (edge.kind !== 'value') continue; - edge.transitivelyReachable = reachableBeyondDirectEdge(edge, successors); - } -} - -/** - * Whether `edge.to` is reachable from `edge.from` WITHOUT using the direct edge, i.e. at - * distance >= 2. The frontier is seeded with the one-hop neighbours other than `to`, which is - * what excludes the direct hop without having to track path lengths. - */ -function reachableBeyondDirectEdge( - edge: GraphEdge, - successors: ReadonlyMap, -): boolean { - const seen = new Set([edge.from]); - const queue = (successors.get(edge.from) ?? []).filter((next) => next !== edge.to); - for (const next of queue) seen.add(next); - - for (let index = 0; index < queue.length; index++) { - for (const next of successors.get(queue[index]!) ?? []) { - if (next === edge.to) return true; - if (seen.has(next)) continue; - seen.add(next); - queue.push(next); - } + edge.transitivelyReachable = !kept.has(importEdgeId(edge.from, edge.to)); } - return false; } /** @@ -423,7 +385,7 @@ export function typeInversionsByPair(edges: readonly ResolvedImportEdge[]): Reco for (const edge of edges) { const pair = typeInversionPair(edge); if (!pair) continue; - const identity = `${edge.file} -> ${edge.target}`; + const identity = importEdgeId(edge.file, edge.target); if (seen.has(identity)) continue; seen.add(identity); byPair.set(pair, (byPair.get(pair) ?? 0) + 1); @@ -436,7 +398,6 @@ export function buildGraph( edges: readonly ResolvedImportEdge[], ): GraphData { const collapsed = collapseEdges(edges); - markTransitivelyReachableEdges(collapsed); const edgeAuthorities = collapsed.map(({ authorities }) => authorityLabelsForDeclared(authorities), ); @@ -456,34 +417,23 @@ export function buildGraph( } /** - * Longest distance from each node to a sink over value edges. The layering gate rejects - * production value-import cycles (R4), so that subgraph is a DAG and the height is - * well-defined; the `visiting` guard only exists so a future cycle degrades instead of - * overflowing the stack. + * Longest distance from each node to a sink over value edges. R4 keeps that subgraph a DAG; on a + * value cycle the edge that closes the loop adds no height, so levels stay finite. */ export function computeLevels( nodes: readonly GraphNode[], edges: readonly GraphEdge[], ): Map { - const successors = valueSuccessors(edges); - + const ids = nodes.map((node) => node.id); + const valueGraph = importGraph(edges, VALUE_EDGES, ids); const levels = new Map(); - const visiting = new Set(); - - const height = (id: string): number => { - const cached = levels.get(id); - if (cached !== undefined) return cached; - if (visiting.has(id)) return 0; - visiting.add(id); + for (const node of genPostorder(valueGraph, { from: ids })) { let best = 0; - for (const next of successors.get(id) ?? []) { - best = Math.max(best, height(next) + 1); + for (const next of getSuccessors(valueGraph, node.id)) { + const height = levels.get(next.id); + if (height !== undefined) best = Math.max(best, height + 1); } - visiting.delete(id); - levels.set(id, best); - return best; - }; - - for (const node of nodes) height(node.id); + levels.set(node.id, best); + } return levels; }