diff --git a/package.json b/package.json index e20bffd295..7200ecd80c 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/import-graph.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 scripts/depgraph/structure-summary.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", diff --git a/scripts/depgraph/README.md b/scripts/depgraph/README.md index fe63f051a4..109eb48955 100644 --- a/scripts/depgraph/README.md +++ b/scripts/depgraph/README.md @@ -107,8 +107,10 @@ Extraction stays with the layering gate; traversal does not. `import-graph.ts` t 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. +than hand-rolled walks. `structure-summary.ts` additionally uses its dominator tree, strongly +connected components, and Louvain community detection — see "Structural summaries" below. The +library also carries centrality and layout algorithms; reach for them in a throwaway probe before +writing a new traversal here. ## What the JSON carries @@ -122,6 +124,35 @@ algorithms; reach for them in a throwaway probe before writing a new traversal h dynamic. Flags bitfield: `1` spine back-edge, `2` target also reachable at distance >= 2, `4` type-only inversion. - `cycles[]` — each with `kind` (`value` / `type` / `dynamic`) and its node path. +- `dominatorSummary`, `zoneSccSummary`, `cohesionSummary` — see "Structural summaries" below. + +## Structural summaries (report-only) + +`scripts/depgraph/structure-summary.ts` adds three fields nothing in `scripts/layering/` or +`scripts/check-affected/` reads. They are the numbers workstream 7 of #3276 asked to keep a +permanent home in the tooling, not a gate. + +- **`dominatorSummary`** — the dominator tree of one entry's eager-load closure over VALUE edges + only (dynamic imports are a deliberate cold-start seam, so they never join the closure). + `reachableFiles` is the eager closure size; `bottlenecks` ranks every reachable non-entry file + by how many files become unreachable if it disappeared — the size of the branch moving it + behind `import()` would cut. Defaults to `src/daemon.ts`; override with + `pnpm depgraph --dominator-entry `. +- **`zoneSccSummary`** — strongly connected components of the zone graph, built from VALUE zone + pairs only (`zoneEdges` entries with `valueCount > 0`), the same edge kind R4 keeps acyclic at + file level. R4 is file-level; nothing stops a loop from closing once files collapse into zones — + the guardrail gap workstream 4 of #3276 names. A component with `size > 1` is a live cycle. +- **`cohesionSummary`** — Louvain communities (`getLouvainCommunities`, mixing every edge kind + since community detection treats the graph as undirected) versus declared zones, scored with + `getModularity`, plus a per-zone `cohesionShare`: the share of a zone's files that land in its + largest detected community. Low values are where the folder partition cuts across a cluster the + import graph actually forms. + +```sh +pnpm depgraph +pnpm depgraph --dominator-entry src/cli/entry.ts +jq '{dominatorSummary, zoneSccSummary, cohesionSummary}' .tmp/depgraph/graph.json +``` ## Declared-authority overlay diff --git a/scripts/depgraph/build.ts b/scripts/depgraph/build.ts index 0fa1fc125e..903536bf5c 100644 --- a/scripts/depgraph/build.ts +++ b/scripts/depgraph/build.ts @@ -19,11 +19,25 @@ import { zoneRank } from '../layering/model.ts'; import { runAffected } from './affected-run.ts'; import { loadGraph } from './load.ts'; import { computeLevels, markTransitivelyReachableEdges, type GraphData } from './model.ts'; +import { + computeCohesionSummary, + computeDominatorSummary, + computeZoneSccSummary, + type CohesionSummary, + type DominatorSummary, + type ZoneSccSummary, +} from './structure-summary.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', }).trim(); +/** + * The daemon is the one process entry point whose eager-load closure is worth asking about by + * default; `--dominator-entry` overrides it for any other file. + */ +const DEFAULT_DOMINATOR_ENTRY = 'src/daemon.ts'; + /** Compact wire form. Nodes and edges are index-addressed to keep the payload small. */ type Payload = { generated: { commit: string; files: number; edges: number }; @@ -49,6 +63,13 @@ type Payload = { cycles: { kind: string; path: number[] }[]; /** Type-only spine inversions per zone pair, by the gate's counting rule. */ typeInversions: Record; + /** + * Report-only structural summaries (scripts/depgraph/structure-summary.ts): no layering or + * check-affected gate reads these fields today. + */ + dominatorSummary: DominatorSummary; + zoneSccSummary: ZoneSccSummary; + cohesionSummary: CohesionSummary; }; function headCommit(): string { @@ -84,7 +105,7 @@ function hasValueCycle(graph: Pick): boolean { return graph.cycles.some((cycle) => cycle.kind === 'value'); } -function buildPayload(): Payload { +function buildPayload(dominatorEntry: string): Payload { const graph = loadGraph(repoRoot); if (!hasValueCycle(graph)) markTransitivelyReachableEdges(graph.edges); const levels = computeLevels(graph.nodes, graph.edges); @@ -118,6 +139,9 @@ function buildPayload(): Payload { path: cycle.path.map((file) => nodeIndex.get(file)!), })), typeInversions: graph.typeInversions, + dominatorSummary: computeDominatorSummary(dominatorEntry, graph), + zoneSccSummary: computeZoneSccSummary(graph), + cohesionSummary: computeCohesionSummary(graph), }; } @@ -134,6 +158,9 @@ function formatSummary(payload: Payload, jsonPath: string): string { const authorityCounts = Object.entries(payload.authorityCounts) .map(([label, count]) => `${label}=${count}`) .join(', '); + const { dominatorSummary, zoneSccSummary, cohesionSummary } = payload; + const topBottleneck = dominatorSummary.bottlenecks[0]; + const leastCohesive = cohesionSummary.zoneCohesion[0]; return ( `Dependency graph: ${payload.generated.files} files, ${payload.generated.edges} edges, ` + `${payload.zones.length} zones\n` + @@ -144,22 +171,45 @@ function formatSummary(payload: Payload, jsonPath: string): string { ` 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` + + ` dominator summary for ${dominatorSummary.entry}: ${dominatorSummary.reachableFiles} of ` + + `${dominatorSummary.totalFiles} files loaded eagerly` + + (topBottleneck + ? `, biggest branch ${topBottleneck.file} (${topBottleneck.files} files, ${topBottleneck.loc} loc)\n` + : '\n') + + ` zone-level SCCs: ${zoneSccSummary.components.length} cyclic group(s) across ` + + `${zoneSccSummary.zones} zones\n` + + ` modularity: declared zones ${cohesionSummary.modularity.declaredZones.toFixed(3)}, ` + + `detected communities ${cohesionSummary.modularity.detectedCommunities.toFixed(3)}` + + (leastCohesive + ? `, least cohesive zone ${leastCohesive.zone} (${(leastCohesive.cohesionShare * 100).toFixed(0)}%)\n` + : '\n') + ` wrote ${path.relative(repoRoot, jsonPath)}\n` ); } +/** + * A flag's value, or `undefined` when the flag is absent, trailing with nothing after it, or + * given an empty string — the last case matters because `?? ` only catches nullish + * values, and an empty `--out ""` must fall back the same way a missing one does, not resolve to + * the current directory and crash the subsequent write with EISDIR. + */ +function flagValue(argv: readonly string[], flag: string): string | undefined { + const index = argv.indexOf(flag); + const value = index >= 0 ? argv[index + 1] : undefined; + return value ? value : undefined; +} + 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. if (argv[0] === 'affected') return await runAffected(argv.slice(1), repoRoot); - const outFlag = argv.indexOf('--out'); - const jsonPath = - outFlag >= 0 && argv[outFlag + 1] - ? path.resolve(argv[outFlag + 1]!) - : path.join(repoRoot, '.tmp/depgraph/graph.json'); + const jsonPath = path.resolve( + flagValue(argv, '--out') ?? path.join(repoRoot, '.tmp/depgraph/graph.json'), + ); + const dominatorEntry = flagValue(argv, '--dominator-entry') ?? DEFAULT_DOMINATOR_ENTRY; - const payload = buildPayload(); + const payload = buildPayload(dominatorEntry); fs.mkdirSync(path.dirname(jsonPath), { recursive: true }); fs.writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`); diff --git a/scripts/depgraph/import-graph.ts b/scripts/depgraph/import-graph.ts index cebcf65cbd..b3cb50ffac 100644 --- a/scripts/depgraph/import-graph.ts +++ b/scripts/depgraph/import-graph.ts @@ -15,6 +15,15 @@ export const VALUE_EDGES: ReadonlySet = new Set(['value']); */ export const EXECUTABLE_EDGES: ReadonlySet = new Set(['value', 'dynamic']); +/** + * Every collapsed edge kind, for the structural question ("does anything reference this file at + * all") community detection asks (`computeCohesionSummary`). Community algorithms treat the graph + * as undirected regardless of kind, so mixing kinds in is the right input, not a loosening of a + * rule — there is no gate riding on this set. Zone-level cycle reporting (`computeZoneSccSummary`) + * does not use this set: it builds its own zone graph from VALUE zone pairs only, to mirror R4. + */ +export const ALL_EDGES: ReadonlySet = new Set(['value', 'type', '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. diff --git a/scripts/depgraph/model.test.ts b/scripts/depgraph/model.test.ts index ec802a67b8..55faa9bf65 100644 --- a/scripts/depgraph/model.test.ts +++ b/scripts/depgraph/model.test.ts @@ -435,6 +435,12 @@ test('build.ts writes the default path and a summary consistent with the JSON', edgeAuthorities: string[][]; authorityCounts: Record; typeInversions: Record; + dominatorSummary: { entry: string; reachableFiles: number; totalFiles: number }; + zoneSccSummary: { zones: number; components: { zones: string[]; size: number }[] }; + cohesionSummary: { + modularity: { declaredZones: number; detectedCommunities: number }; + zoneCohesion: { zone: string; cohesionShare: number }[]; + }; }; // Wire shape: the fields a consumer queries. A rename here is a breaking change for any script @@ -447,6 +453,9 @@ test('build.ts writes the default path and a summary consistent with the JSON', 'edges', 'cycles', 'typeInversions', + 'dominatorSummary', + 'zoneSccSummary', + 'cohesionSummary', ]) { assert.ok(field in payload, `legacy payload field ${field} disappeared`); } @@ -461,6 +470,25 @@ test('build.ts writes the default path and a summary consistent with the JSON', assert.ok(payload.zones.length > 0); assert.ok(Object.keys(payload.typeInversions).length > 0); + // Report-only fields (#3283): no gate reads these, so this pins only the wire shape. + assert.equal(payload.dominatorSummary.entry, 'src/daemon.ts'); + assert.ok(payload.dominatorSummary.reachableFiles > 0); + assert.ok(payload.dominatorSummary.reachableFiles <= payload.dominatorSummary.totalFiles); + assert.ok(payload.zoneSccSummary.zones > 0); + assert.ok( + payload.zoneSccSummary.components.every( + (component) => component.zones.length === component.size, + ), + ); + assert.ok(payload.cohesionSummary.modularity.declaredZones <= 1); + assert.ok(payload.cohesionSummary.modularity.detectedCommunities <= 1); + for (const { cohesionShare } of payload.cohesionSummary.zoneCohesion) { + assert.ok( + cohesionShare > 0 && cohesionShare <= 1, + `cohesionShare out of range: ${cohesionShare}`, + ); + } + // The printed summary must agree with the payload it was derived from. const inversions = Object.values(payload.typeInversions).reduce((sum, n) => sum + n, 0); assert.match( @@ -470,6 +498,44 @@ test('build.ts writes the default path and a summary consistent with the JSON', assert.match(stdout, new RegExp(`type-only spine inversions \\(R6\\): ${inversions}`)); const reachable = payload.edges.filter(([, , , flags]) => (flags & 2) !== 0).length; assert.match(stdout, new RegExp(`reachable at distance >= 2: ${reachable}`)); + assert.match( + stdout, + new RegExp( + `dominator summary for src/daemon\\.ts: ${payload.dominatorSummary.reachableFiles} of ${payload.dominatorSummary.totalFiles} files loaded eagerly`, + ), + ); + // Tied to the exact payload numbers (not a shape-only `\d+` regex) so a CLI summary that + // drifts from the JSON it was derived from — wrong count, wrong rounding — fails this test. + assert.ok( + stdout.includes( + `zone-level SCCs: ${payload.zoneSccSummary.components.length} cyclic group(s) across ` + + `${payload.zoneSccSummary.zones} zones`, + ), + stdout, + ); + assert.ok( + stdout.includes( + `modularity: declared zones ${payload.cohesionSummary.modularity.declaredZones.toFixed(3)}, ` + + `detected communities ${payload.cohesionSummary.modularity.detectedCommunities.toFixed(3)}`, + ), + stdout, + ); +}); + +test('build.ts honours --dominator-entry', () => { + const { status, stdout } = runBuild(['--dominator-entry', 'src/cli/entry.ts']); + assert.equal(status, 0, stdout); + const payload = JSON.parse(readFileSync('.tmp/depgraph/graph.json', 'utf8')) as { + dominatorSummary: { entry: string }; + }; + assert.equal(payload.dominatorSummary.entry, 'src/cli/entry.ts'); + assert.match(stdout, /dominator summary for src\/cli\/entry\.ts:/); +}); + +test('build.ts reports a typed error for a dominator entry outside the graph', () => { + const { status, stderr } = runBuild(['--dominator-entry', 'src/does-not-exist.ts']); + assert.notEqual(status, 0); + assert.match(stderr, /src\/does-not-exist\.ts is not a production source file/); }); test('build.ts honours --out and reports the path it wrote', () => { @@ -488,3 +554,18 @@ test('build.ts falls back to the default path when --out has no value', () => { assert.equal(status, 0, stdout); assert.ok(stdout.includes('.tmp/depgraph/graph.json'), stdout); }); + +test('build.ts falls back to the default path when --out is given an empty string', () => { + // `--out ""` must fall back the same way a missing value does. `flagValue` returning `''` + // instead of `undefined` here would skip the `??` default and resolve to the current + // directory, crashing the write with EISDIR instead of writing the default report. + const { status, stdout } = runBuild(['--out', '']); + assert.equal(status, 0, stdout); + assert.ok(stdout.includes('.tmp/depgraph/graph.json'), stdout); +}); + +test('build.ts falls back to the default dominator entry when --dominator-entry is given an empty string', () => { + const { status, stdout } = runBuild(['--dominator-entry', '']); + assert.equal(status, 0, stdout); + assert.ok(stdout.includes('dominator summary for src/daemon.ts:'), stdout); +}); diff --git a/scripts/depgraph/structure-summary.test.ts b/scripts/depgraph/structure-summary.test.ts new file mode 100644 index 0000000000..ab2160daa7 --- /dev/null +++ b/scripts/depgraph/structure-summary.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveImportEdges } from '../layering/model.ts'; +import { buildGraph } from './model.ts'; +import { + computeCohesionSummary, + computeDominatorSummary, + computeZoneSccSummary, +} from './structure-summary.ts'; + +function sources(entries: Record): Map { + return new Map(Object.entries(entries)); +} + +test('computeDominatorSummary excludes the dynamic-import seam and ranks bottlenecks by dominated subtree size', () => { + const files = sources({ + 'src/core/entry.ts': [ + "import { a } from './a.ts';", + "import { b } from './b.ts';", + "void import('./lazy.ts');", + ].join('\n'), + 'src/core/a.ts': [ + "import { shared } from './shared.ts';", + "import { onlyA } from './only-a.ts';", + ].join('\n'), + 'src/core/b.ts': "import { shared } from './shared.ts';", + 'src/core/shared.ts': 'export const shared = 1;', + 'src/core/only-a.ts': 'export const onlyA = 1;', + 'src/core/lazy.ts': 'export const lazy = 1;', + }); + const graph = buildGraph(files, resolveImportEdges(files)); + + const summary = computeDominatorSummary('src/core/entry.ts', graph); + + // `lazy.ts` is reached only through a dynamic import, so it never joins the eager closure — + // reachableFiles counts entry + a + b + shared + only-a, not the dynamically-loaded sixth file. + assert.equal(summary.reachableFiles, 5); + assert.equal(summary.totalFiles, 6); + + // `shared.ts` is reachable from both `a.ts` and `b.ts`, so its immediate dominator is `entry`, + // not either importer — it dominates only itself, same as `b.ts` and `only-a.ts`. `a.ts` is the + // only bottleneck that dominates more than its own file, because `only-a.ts` has no other path in. + assert.deepEqual( + summary.bottlenecks.map(({ file, files: fileCount }) => [file, fileCount]), + [ + ['src/core/a.ts', 2], + ['src/core/b.ts', 1], + ['src/core/only-a.ts', 1], + ['src/core/shared.ts', 1], + ], + ); +}); + +test('computeDominatorSummary rejects an entry outside the layering graph', () => { + const files = sources({ 'src/core/entry.ts': 'export const entry = 1;' }); + const graph = buildGraph(files, resolveImportEdges(files)); + + assert.throws( + () => computeDominatorSummary('src/core/missing.ts', graph), + /src\/core\/missing\.ts is not a production source file/, + ); +}); + +test('computeZoneSccSummary finds a zone-level cycle built from value edges across distinct files', () => { + // No single file pair cycles — R4 would reject that — but the zone pairs commands -> core -> + // daemon-server -> commands close a loop because the last edge targets a different commands + // file than the first edge's source. + const files = sources({ + 'src/commands/x1.ts': "import { y } from '../core/y.ts';", + 'src/core/y.ts': "import { z } from '../daemon/z.ts';", + 'src/daemon/z.ts': "import { x2 } from '../commands/x2.ts';", + 'src/commands/x2.ts': 'export const x2 = 1;', + }); + const graph = buildGraph(files, resolveImportEdges(files)); + + assert.deepEqual(computeZoneSccSummary(graph), { + zones: 3, + components: [{ zones: ['commands', 'core', 'daemon-server'], size: 3 }], + }); +}); + +test('computeZoneSccSummary reports no components over an acyclic zone graph', () => { + const files = sources({ + 'src/commands/a.ts': "import { b } from '../core/b.ts';", + 'src/core/b.ts': 'export const b = 1;', + }); + const graph = buildGraph(files, resolveImportEdges(files)); + + assert.deepEqual(computeZoneSccSummary(graph), { zones: 2, components: [] }); +}); + +test('computeCohesionSummary reports full cohesion when declared zones match the detected communities', () => { + const files = sources({ + 'src/commands/a1.ts': ["import { a2 } from './a2.ts';", "import { a3 } from './a3.ts';"].join( + '\n', + ), + 'src/commands/a2.ts': "import { a3 } from './a3.ts';", + 'src/commands/a3.ts': 'export const a3 = 1;', + 'src/core/b1.ts': ["import { b2 } from './b2.ts';", "import { b3 } from './b3.ts';"].join('\n'), + 'src/core/b2.ts': "import { b3 } from './b3.ts';", + 'src/core/b3.ts': 'export const b3 = 1;', + }); + const graph = buildGraph(files, resolveImportEdges(files)); + + const summary = computeCohesionSummary(graph); + + assert.equal(summary.modularity.declaredZones, summary.modularity.detectedCommunities); + assert.deepEqual( + summary.zoneCohesion.map(({ zone, cohesionShare }) => [zone, cohesionShare]), + [ + ['commands', 1], + ['core', 1], + ], + ); +}); + +test('computeCohesionSummary scores below the detected partition when declared zones split a cohesive cluster', () => { + // Each zone holds one file from each of two otherwise-disjoint triangles, so neither zone + // matches a detected community and the declared-zone partition scores worse than Louvain's. + const files = sources({ + 'src/commands/a1.ts': [ + "import { a2 } from '../core/a2.ts';", + "import { a3 } from '../core/a3.ts';", + ].join('\n'), + 'src/core/a2.ts': "import { a3 } from './a3.ts';", + 'src/core/a3.ts': 'export const a3 = 1;', + 'src/commands/b1.ts': [ + "import { b2 } from '../core/b2.ts';", + "import { b3 } from '../core/b3.ts';", + ].join('\n'), + 'src/core/b2.ts': "import { b3 } from './b3.ts';", + 'src/core/b3.ts': 'export const b3 = 1;', + }); + const graph = buildGraph(files, resolveImportEdges(files)); + + const summary = computeCohesionSummary(graph); + + assert.ok(summary.modularity.declaredZones < summary.modularity.detectedCommunities); + assert.deepEqual( + summary.zoneCohesion.map(({ zone, files: fileCount, largestCommunitySize }) => [ + zone, + fileCount, + largestCommunitySize, + ]), + [ + ['commands', 2, 1], + ['core', 4, 2], + ], + ); +}); diff --git a/scripts/depgraph/structure-summary.ts b/scripts/depgraph/structure-summary.ts new file mode 100644 index 0000000000..9b10dac669 --- /dev/null +++ b/scripts/depgraph/structure-summary.ts @@ -0,0 +1,209 @@ +// Report-only structural summaries the layering gate does not compute: the dominator tree of one +// entry's eager-load closure, zone-level strongly connected components, and community detection +// against declared zones. Pure functions over `GraphData`; nothing here feeds +// `scripts/layering/` or `scripts/check-affected/`, and no gate reads these fields. + +import { + createGraph, + getDominatorTree, + getLouvainCommunities, + getModularity, + getStronglyConnectedComponents, + type Graph, + type GraphNode as LibGraphNode, +} from '@statelyai/graph'; +import { ALL_EDGES, importGraph, VALUE_EDGES } from './import-graph.ts'; +import type { GraphData } from './model.ts'; + +export type DominatorBottleneck = { + file: string; + /** Files, `file` included, that become unreachable from the entry if `file` is removed. */ + files: number; + loc: number; +}; + +export type DominatorSummary = { + entry: string; + reachableFiles: number; + reachableLoc: number; + totalFiles: number; + /** Non-entry nodes ranked by dominated subtree size, largest first. */ + bottlenecks: DominatorBottleneck[]; +}; + +/** + * Dominator tree of `entry`'s eager-load closure over VALUE edges only — the files loaded before + * `entry` finishes evaluating. Dynamic imports are a deliberate cold-start seam (see + * `import-graph.ts`), so they are excluded on purpose: this answers "what does loading `entry` + * actually pull in", not "what can `entry` eventually reach". + * + * A node's dominated subtree is every file that would stop being reachable from `entry` if that + * node disappeared — the size of the branch moving it behind `import()` would cut. Nodes not + * reachable from `entry` are absent from `getDominatorTree`'s result by construction (it walks + * from `from` and only records visited nodes), so `reachableFiles` is exactly that closure size. + */ +export function computeDominatorSummary(entry: string, graph: GraphData): DominatorSummary { + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + if (!nodesById.has(entry)) { + throw new Error(`${entry} is not a production source file in the layering graph.`); + } + + const valueGraph = importGraph(graph.edges, VALUE_EDGES, [entry]); + const idom = getDominatorTree(valueGraph, { from: entry }); + const reachable = Object.keys(idom); + + const children = new Map(); + for (const [id, parent] of Object.entries(idom)) { + if (parent === null) continue; + const siblings = children.get(parent) ?? []; + siblings.push(id); + children.set(parent, siblings); + } + + const subtreeSizes = new Map(); + const sizeOf = (id: string): { files: number; loc: number } => { + const cached = subtreeSizes.get(id); + if (cached) return cached; + let files = 1; + let loc = nodesById.get(id)?.loc ?? 0; + for (const child of children.get(id) ?? []) { + const childSize = sizeOf(child); + files += childSize.files; + loc += childSize.loc; + } + const size = { files, loc }; + subtreeSizes.set(id, size); + return size; + }; + for (const id of reachable) sizeOf(id); + + const bottlenecks = reachable + .filter((id) => id !== entry) + .map((file) => ({ file, ...sizeOf(file) })) + .sort((left, right) => right.files - left.files || left.file.localeCompare(right.file)); + + return { + entry, + reachableFiles: reachable.length, + reachableLoc: sizeOf(entry).loc, + totalFiles: graph.nodes.length, + bottlenecks, + }; +} + +export type ZoneComponent = { zones: string[]; size: number }; + +export type ZoneSccSummary = { + zones: number; + /** Zone groups tied together by a cycle, largest first. Singleton zones are not components. */ + components: ZoneComponent[]; +}; + +/** + * Strongly connected components of the zone graph, built from VALUE zone pairs only (`zoneEdges` + * entries with `valueCount > 0`) — the same edge kind R4 keeps acyclic at file level. R4 is a + * file-level guarantee; nothing stops a loop from closing once files collapse into zones, which + * is exactly the guardrail gap workstream 4 of #3276 names. This reports what currently cycles at + * zone level; it enforces nothing. + */ +export function computeZoneSccSummary(graph: GraphData): ZoneSccSummary { + const zoneIds = graph.zones.map((zone) => zone.id); + const valueZoneEdges = graph.zoneEdges.filter((edge) => edge.valueCount > 0); + const zoneGraph: Graph = createGraph({ + nodes: zoneIds.map((id) => ({ id })), + edges: valueZoneEdges.map((edge, index) => ({ + id: `${edge.from}\u0000${edge.to}\u0000${index}`, + sourceId: edge.from, + targetId: edge.to, + })), + }); + + const components = getStronglyConnectedComponents(zoneGraph) + .map((component) => ({ + zones: component.map((node) => node.id).sort(), + size: component.length, + })) + .filter((component) => component.size > 1) + .sort((left, right) => right.size - left.size || left.zones[0]!.localeCompare(right.zones[0]!)); + + return { zones: zoneIds.length, components }; +} + +export type ZoneCohesion = { + zone: string; + files: number; + largestCommunitySize: number; + /** Share of the zone's files that land in its largest detected community. */ + cohesionShare: number; +}; + +export type CohesionSummary = { + modularity: { declaredZones: number; detectedCommunities: number }; + /** Least cohesive zone first. */ + zoneCohesion: ZoneCohesion[]; +}; + +/** + * `getModularity` wants `Community` — full node objects from the graph being measured — but + * `getLouvainCommunities` returns bare ids. Mapping ids back to the graph's own node objects is + * the workaround; see the upstream issue filed for this gap (scripts/depgraph/README.md). + */ +function toCommunities(graph: Graph, idGroups: readonly string[][]): LibGraphNode[][] { + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + return idGroups.map((ids) => ids.map((id) => nodesById.get(id)!)); +} + +/** + * Louvain communities vs. declared zones, and per-zone cohesion against the detected partition. + * Community detection treats the graph as undirected regardless of edge kind, so the comparison + * graph mixes value, type-only, and dynamic edges: the question is "does anything reference this + * file at all", not evaluation order. + */ +export function computeCohesionSummary(graph: GraphData): CohesionSummary { + const fullGraph = importGraph( + graph.edges, + ALL_EDGES, + graph.nodes.map((node) => node.id), + ); + const detected = getLouvainCommunities(fullGraph); + + const filesByZone = new Map(); + for (const node of graph.nodes) { + const files = filesByZone.get(node.zone) ?? []; + files.push(node.id); + filesByZone.set(node.zone, files); + } + const declaredZones = [...filesByZone.values()]; + + const modularity = { + declaredZones: getModularity(fullGraph, toCommunities(fullGraph, declaredZones)), + detectedCommunities: getModularity(fullGraph, toCommunities(fullGraph, detected)), + }; + + const communityIndexByFile = new Map(); + detected.forEach((group, index) => { + for (const file of group) communityIndexByFile.set(file, index); + }); + + const zoneCohesion = [...filesByZone.entries()] + .map(([zone, files]) => { + const counts = new Map(); + for (const file of files) { + const community = communityIndexByFile.get(file)!; + counts.set(community, (counts.get(community) ?? 0) + 1); + } + const largestCommunitySize = Math.max(...counts.values()); + return { + zone, + files: files.length, + largestCommunitySize, + cohesionShare: largestCommunitySize / files.length, + }; + }) + .sort( + (left, right) => + left.cohesionShare - right.cohesionShare || left.zone.localeCompare(right.zone), + ); + + return { modularity, zoneCohesion }; +}