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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ jobs:
node --experimental-strip-types --test scripts/layering/model.test.ts
node --experimental-strip-types scripts/layering/check.ts

- name: Check the depgraph report agrees with the gate
# scripts/depgraph reads the same model as the gate, so its inversion count must
# reproduce TYPE_INVERSION_BASELINE. Free two-sources check: if the tree changes
# and only one side is updated, this fails and names the difference. Runs here
# rather than in its own job so the two can never be green independently.
run: node --experimental-strip-types --test scripts/depgraph/model.test.ts

affected-selector:
name: Affected-check Selector
runs-on: ubuntu-latest
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,16 @@
"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",
"check:quick": "pnpm lint && pnpm typecheck",
"sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs",
"check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check",
"version": "node scripts/sync-mcp-metadata.mjs && git add server.json",
"check:tooling": "pnpm lint && pnpm typecheck && pnpm check:layering && pnpm check:production-exports && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files",
"check:tooling": "pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:production-exports && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files",
"check:unit": "pnpm test:unit && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
"prepack": "pnpm check:mcp-metadata && pnpm build:all && pnpm package:apple-runner:npm && pnpm package:android-snapshot-helper:npm && pnpm package:android-ime-helper:npm",
Expand Down
120 changes: 120 additions & 0 deletions scripts/depgraph/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Dependency graph report

```sh
pnpm depgraph # -> .tmp/depgraph/graph.json + a text summary
pnpm depgraph --out /tmp/graph.json
pnpm depgraph:test
```

Emits the dependency graph of every production file under `src/` (tests excluded) as JSON,
plus a short summary of what the layering gate does not enforce. There is no renderer: the
productive artifact is the JSON, queried directly.

## 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:

- **value edges whose target is also reachable at distance >= 2** — static module reachability,
and *only* that. It is **not** a removability claim, and the obvious reading is wrong:
reachability does not carry bindings (if `a` imports `{ c }` while `b` only re-exports it as
`{ 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
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. Read `typeInversions` rather than deriving it from
# `zoneEdges`: those counts come from the COLLAPSED edge list, where one edge per file pair
# survives and `dynamic` outranks `type`, so a module imported both lazily and for its types
# would drop out. `typeInversions` is counted by the gate's own rule and is what CI compares
# against TYPE_INVERSION_BASELINE.
node -e "const j=require('./.tmp/depgraph/graph.json');
Object.entries(j.typeInversions)
.sort((a, b) => b[1] - a[1])
.forEach(([pair, n]) => console.log(String(n).padStart(4), 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 — and that
agreement is now enforced rather than hoped for: the **Layering Guard job runs
`scripts/depgraph/model.test.ts`**, whose last test asserts this report's inversion count reproduces
`TYPE_INVERSION_BASELINE`. If the tree changes and only one side is updated, CI fails and names the
difference. The two cannot be green independently.

What that check proves precisely: the report's graph build, over the real tree, agrees with the
gate's baseline. It is a cross-check of the extraction and the baseline against reality, not two
independent algorithms — `typeInversionsByPair` deliberately applies the gate's counting rule (once
per file pair, over raw edges) so the numbers cannot diverge for a reason unrelated to layering. In
particular it does NOT count from the collapsed edge list, where `dynamic` outranks `type` and a
module imported both lazily and for its types would drop out.

If they ever disagree, the gate is right and the baseline or the tree is wrong.

### Notes


`pnpm check:layering` is. This 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.

One thing here DOES run in CI, and only one: the Layering Guard job runs
`scripts/depgraph/model.test.ts`, whose parity test asserts this report's inversion count equals
`TYPE_INVERSION_BASELINE`. Nothing else here gates a merge — the report itself is an instrument, not
a rule, and no finding it produces is enforced.

## 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 JSON carries

- `zones[]` — id, spine `rank` (`null` when intentionally unranked), `classification`, file
count, LOC.
- `zoneEdges[]` — per zone pair: total `count`, `valueCount`, and `backEdge` (R5 value
back-edges only — see the note above).
- `nodes[]` — per file: zone index, LOC, `in`/`out` degree, `lvl` (longest path to a sink over
value edges; R4 guarantees that subgraph is a DAG), and `cyc` (index into `cycles`, or `-1`).
- `edges[]` — index-addressed `[from, to, kind, flags]`. Kind: `0` value, `1` type-only, `2`
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.

Bit `2` means the target is reachable from the source at distance >= 2 over value edges. That is
module reachability, not removability — see the caveats above. Treat it as a question ("why is
this imported directly as well?"), never as an instruction.
151 changes: 151 additions & 0 deletions scripts/depgraph/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Dependency-graph report — the numbers the layering gate does not enforce.
//
// node --experimental-strip-types scripts/depgraph/build.ts [--out <path>]
//
// Emits a JSON graph plus a short text summary. It reuses scripts/layering/model.ts, the same
// module check.ts uses in CI, so the file set, zone partition, edge kinds and cycle definition
// are the ones actually enforced — a second extractor would describe a graph nobody gates.
//
// What it adds over `pnpm check:layering`: value edges whose target is also reachable at distance
// >= 2 (module reachability, NOT a removability claim), cycles that are
// deliberately outside R4 (type-only and dynamic), per-zone size, and per-file fan-in/fan-out.
// See README.md for which question each field answers.

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

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;
}[];
/**
* `[fromIndex, toIndex, kind, flags]`; kind 0=value 1=type 2=dynamic,
* flags bit0=R5 back-edge, bit1=target also reachable at distance >= 2, bit2=R6 type inversion.
*/
edges: [number, number, number, number][];
cycles: { kind: string; path: number[] }[];
/** Type-only spine inversions per zone pair, by the gate's counting rule. */
typeInversions: Record<string, number>;
};

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

const EDGE_KIND_CODES = { value: 0, type: 1, dynamic: 2 } as const;

/** Wire code for an edge kind, so the payload carries a number rather than a string per edge. */
function edgeKindCode(kind: GraphData['edges'][number]['kind']): number {
return EDGE_KIND_CODES[kind];
}

/**
* Bitfield: 1 = spine back-edge (R5), 2 = target also reachable at distance >= 2, 4 = type-only
* inversion (R6). Bit 2 is reachability, NOT removability — see markTransitivelyReachableEdges.
*/
function edgeFlags(edge: GraphData['edges'][number]): number {
return (
(edge.backEdge ? 1 : 0) |
(edge.transitivelyReachable ? 2 : 0) |
(edge.typeInversion ? 4 : 0)
);
}

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 zoneIndex = new Map(graph.zones.map((zone, index) => [zone.id, index]));
const nodeIndex = new Map(graph.nodes.map((node, index) => [node.id, index]));

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,
})),
edges: graph.edges.map((edge) => [
nodeIndex.get(edge.from)!,
nodeIndex.get(edge.to)!,
edgeKindCode(edge.kind),
edgeFlags(edge),
]),
cycles: graph.cycles.map((cycle) => ({
kind: cycle.kind,
path: cycle.path.map((file) => nodeIndex.get(file)!),
})),
typeInversions: graph.typeInversions,
};
}

function main(argv: readonly string[]): number {
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 payload = buildPayload();
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);
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` +
` (reachability only — not a removability claim, see scripts/depgraph/README.md)\n` +
` wrote ${path.relative(repoRoot, jsonPath)}\n`,
);
return 0;
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
process.exit(main(process.argv.slice(2)));
}
Loading
Loading