Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 33 additions & 2 deletions scripts/depgraph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <path>`.
- **`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

Expand Down
64 changes: 57 additions & 7 deletions scripts/depgraph/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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<string, number>;
/**
* 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 {
Expand Down Expand Up @@ -84,7 +105,7 @@ function hasValueCycle(graph: Pick<GraphData, 'cycles'>): 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);
Expand Down Expand Up @@ -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),
};
}

Expand All @@ -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` +
Expand All @@ -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 `?? <default>` 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<number> {
// `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'),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
);
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`);

Expand Down
9 changes: 9 additions & 0 deletions scripts/depgraph/import-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ export const VALUE_EDGES: ReadonlySet<EdgeKind> = new Set(['value']);
*/
export const EXECUTABLE_EDGES: ReadonlySet<EdgeKind> = 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<EdgeKind> = 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.
Expand Down
81 changes: 81 additions & 0 deletions scripts/depgraph/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,12 @@ test('build.ts writes the default path and a summary consistent with the JSON',
edgeAuthorities: string[][];
authorityCounts: Record<string, number>;
typeInversions: Record<string, number>;
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
Expand All @@ -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`);
}
Expand All @@ -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(
Expand All @@ -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', () => {
Expand All @@ -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);
});
Loading
Loading