Skip to content
Closed
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
1 change: 1 addition & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"examples/test-app/**",
"scripts/perf/**",
"scripts/layering/**",
"scripts/depgraph/**",
"scripts/maestro-conformance/**",
"apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan",
"scripts/write-xcuitest-cache-metadata.mjs"
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@
"check:affected": "node --experimental-strip-types scripts/check-affected/run.ts",
"check:affected:test": "node --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts",
"check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts && node --experimental-strip-types scripts/layering/check.ts",
"depgraph": "node --experimental-strip-types scripts/depgraph/build.ts",
"depgraph:test": "node --experimental-strip-types --test scripts/depgraph/model.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:freerange": "fr",
Expand Down
97 changes: 97 additions & 0 deletions scripts/depgraph/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Dependency graph viewer

```sh
pnpm depgraph # -> .tmp/depgraph/index.html (+ index.json)
pnpm depgraph --out /tmp/graph.html
pnpm depgraph:test
```

Renders every production file under `src/` (tests excluded) as an interactive graph in a
single self-contained HTML file — no external requests, no build step, no runtime
dependency. Open it from `file://`, publish it as a static page, or embed it.

## When to reach for this

It pays for itself on three questions, and misleads on a fourth.

**"What am I about to break?"** `nodes[].in` is the dependent count — blast radius. Size the
nodes by dependents and the files you should touch carefully are the big ones. Faster than
grepping, and it counts type-only and dynamic edges that a grep for `from '...'` misses.

**"Where is the debt actually concentrated?"** Zone-level counts (`zoneEdges`) answer "which
boundary carries the most traffic" in one query. The pass that produced ADR-adjacent findings
started here.

**"What is wrong that the gate does not enforce?"** This is the part CI cannot give you. The
gate rejects value-import cycles (R4) and spine back-edges (R5); the graph additionally reports:

- **transitively redundant value edges** — the target is still reachable from the source at
distance >= 2, so the direct import changes nothing about what the module can see. A
_candidate_, not a defect: a direct import is often clearer than a re-export chain. There are
~1300 of these, so treat it as 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
free at runtime, a dynamic one is a deliberate cold-start seam). Worth reading when a module
feels hard to reason about.

**Where it misleads: a cluster's size is not its difficulty.** This is worth stating plainly
because it already cost a day. The `commands -> client` cluster looked like the obvious win — 28
type-only inversions, all pointing at one file. Moving that file down took the gate from **42 to
48**, because the vocabulary it holds *depends on* `commands/`, `metro/`, `core/` and `remote/`;
declaring it in `contracts/` made the foundation depend on the layers above it. The picture shows
you an edge's weight, not whether it can be reversed.

So: use the render to find a candidate, then answer "can this move?" numerically before planning
anything. The question is always *what does the target itself import, and what rank is that?*

```sh
pnpm depgraph
# Zone pairs that invert the ranked spine, by type-only edge count. Reproduces the gate's R6
# breakdown from the JSON alone — if these disagree with TYPE_INVERSION_BASELINE, regenerate.
node -e "const j=require('./.tmp/depgraph/index.json');
const rank=Object.fromEntries(j.zones.map(z=>[z.id,z.rank]));
j.zoneEdges
.filter(e=>rank[e.from]!=null && rank[e.to]!=null && rank[e.from]<rank[e.to])
.map(e=>({pair:e.from+' -> '+e.to, typeOnly:e.count-e.valueCount}))
.filter(e=>e.typeOnly>0).sort((a,b)=>b.typeOnly-a.typeOnly)
.forEach(e=>console.log(String(e.typeOnly).padStart(4), e.pair));"
```

Note `zoneEdges[].backEdge` flags **R5 value** back-edges only, and there are none — filtering on
it returns an empty list, which is the gate passing, not a broken query.


## What is authoritative

`pnpm check:layering` is. The viewer reads the same model, so the numbers should agree — its R6
count matching `TYPE_INVERSION_BASELINE` is a useful self-check — but if they ever diverge, the
gate is right and the graph is stale. Nothing here runs in CI, and nothing here should gate a
merge: it is an instrument, not a rule.

## Why it reuses the layering gate

The graph is extracted with `scripts/layering/model.ts`, the same module
`scripts/layering/check.ts` uses in CI. File set, zone partition, edge kinds
(value / type-only / dynamic), and cycle definition are therefore identical to the rules
the gate enforces — a separate extractor with its own resolution behaviour would draw a
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.

## What the view encodes

- **Colour** is the ranked spine (`kernel` sink → `cli`); zones sharing a rank differ in
lightness. Unranked zones (`UNRANKED_ZONES`) get a muted palette of their own, because
the gate deliberately asserts no ordering over them.
- **Size** is coupling, dependents, or lines — dependents is the blast-radius metric.
- **Clusters** layout: folder groups placed by how much they import from each other, then
files relaxed inside their group. Tight blobs are cohesive; long bridges are coupling.
- **Layers** layout: x is the longest path to a leaf over static value imports. R4
guarantees that subgraph is a DAG, so every edge should read leftwards.
- **Overlays** for spine back-edges (R5), import cycles, and transitively redundant edges.

A "redundant" edge means the target is still reachable from the source at distance >= 2
over value edges, so removing the direct import would not change what the module can see.
That makes it a _candidate_, not a defect: plenty of direct imports are clearer than
relying on a re-export chain.

Layouts are computed at build time and shipped as coordinates, so the viewer never runs a
physics simulation on the reader's phone, and the same commit always renders identically.
156 changes: 156 additions & 0 deletions scripts/depgraph/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Dependency graph generator — emits a single self-contained HTML file.
//
// node --experimental-strip-types scripts/depgraph/build.ts [--out <path>]
//
// The output has no external requests of any kind: data, styles, and viewer script are
// inlined, so it works from `file://`, from a static host, or inside a sandboxed page.

import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { listSourceFiles } from '../layering/check.ts';
import { resolveImportEdges, zoneRank } from '../layering/model.ts';
import { clusterLayout, computeLevels, layeredLayout } from './layout.ts';
import { buildGraph, type GraphData } from './model.ts';

const here = path.dirname(fileURLToPath(import.meta.url));

const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
encoding: 'utf8',
}).trim();

/** Compact wire form. Nodes and edges are index-addressed to keep the payload small. */
type Payload = {
generated: { commit: string; files: number; edges: number };
zones: { id: string; rank: number | null; classification: string; files: number; loc: number }[];
zoneEdges: GraphData['zoneEdges'];
nodes: {
id: string;
z: number;
loc: number;
in: number;
out: number;
lvl: number;
cyc: number;
cx: number;
cy: number;
lx: number;
ly: number;
}[];
/**
* `[fromIndex, toIndex, kind, flags]`; kind 0=value 1=type 2=dynamic,
* flags bit0=R5 back-edge, bit1=transitively redundant, bit2=R6 type inversion.
*/
edges: [number, number, number, number][];
cycles: { kind: string; path: number[] }[];
};

function headCommit(): string {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
cwd: repoRoot,
encoding: 'utf8',
}).trim();
} catch {
return 'unknown';
}
}

export function buildPayload(): Payload {
const files = listSourceFiles();
const sources = new Map(
files.map((file) => [file, fs.readFileSync(path.join(repoRoot, file), 'utf8')]),
);
const resolved = resolveImportEdges(sources);
const graph = buildGraph(sources, resolved);
const levels = computeLevels(graph.nodes, graph.edges);
const cluster = clusterLayout(graph);
const layered = layeredLayout(graph, levels);

const zoneIndex = new Map(graph.zones.map((zone, index) => [zone.id, index]));
const nodeIndex = new Map(graph.nodes.map((node, index) => [node.id, index]));
const round = (value: number): number => Math.round(value * 10) / 10;

return {
generated: { commit: headCommit(), files: graph.nodes.length, edges: graph.edges.length },
zones: graph.zones.map((zone) => ({ ...zone, rank: zoneRank(zone.id) })),
zoneEdges: graph.zoneEdges,
nodes: graph.nodes.map((node) => ({
id: node.id.replace(/^src\//, ''),
z: zoneIndex.get(node.zone)!,
loc: node.loc,
in: node.fanIn,
out: node.fanOut,
lvl: levels.get(node.id) ?? 0,
cyc: node.cycle,
cx: round(cluster.get(node.id)!.x),
cy: round(cluster.get(node.id)!.y),
lx: round(layered.get(node.id)!.x),
ly: round(layered.get(node.id)!.y),
})),
edges: graph.edges.map((edge) => [
nodeIndex.get(edge.from)!,
nodeIndex.get(edge.to)!,
edge.kind === 'value' ? 0 : edge.kind === 'type' ? 1 : 2,
(edge.backEdge ? 1 : 0) | (edge.redundant ? 2 : 0) | (edge.typeInversion ? 4 : 0),
]),
cycles: graph.cycles.map((cycle) => ({
kind: cycle.kind,
path: cycle.path.map((file) => nodeIndex.get(file)!),
})),
};
}

function renderHtml(payload: Payload): string {
const template = fs.readFileSync(path.join(here, 'viewer.html'), 'utf8');
const script = fs.readFileSync(path.join(here, 'viewer.js'), 'utf8');
const styles = fs.readFileSync(path.join(here, 'viewer.css'), 'utf8');
// `</script>` inside JSON would close the inline tag early; `<!--` would open a comment.
const data = JSON.stringify(payload)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
return template
.replace('/*__STYLES__*/', () => styles)
.replace('"__DATA__"', () => data)
.replace('/*__SCRIPT__*/', () => script);
}

export function main(argv: readonly string[]): number {
const outFlag = argv.indexOf('--out');
const outPath =
outFlag >= 0 && argv[outFlag + 1]
? path.resolve(argv[outFlag + 1]!)
: path.join(repoRoot, '.tmp/depgraph/index.html');

const payload = buildPayload();
const html = renderHtml(payload);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, html);

const jsonPath = outPath.replace(/\.html$/, '.json');
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 redundant = payload.edges.filter(([, , , flags]) => flags & 2).length;
const typeInversions = payload.edges.filter(([, , , flags]) => flags & 4).length;
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` +
` transitively redundant value edges: ${redundant}\n` +
` wrote ${path.relative(repoRoot, outPath)} and ${path.relative(repoRoot, jsonPath)}\n`,
);
return 0;
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
process.exit(main(process.argv.slice(2)));
}
Binary file added scripts/depgraph/layout.ts
Binary file not shown.
Loading
Loading