Files
Claude cd19a73c55 feat(scripts): interactive dependency-graph viewer, with when-to-use guidance
Renders every production file under src/ as a pannable graph in one self-contained
HTML file — no external requests, no runtime dependency, layouts precomputed at
build time so the viewer never runs a physics simulation on a phone.

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

It reuses the layering gate's model (`listSourceFiles`, `resolveImportEdges`,
`zoneRank`) rather than extracting its own graph. That matters more than it
sounds: a separate extractor with its own resolution behaviour would draw a graph
nobody enforces. Because the model is shared, its R6 count reproduces
TYPE_INVERSION_BASELINE exactly, which doubles as a self-check.

The README now documents WHEN it is productive, because the honest answer is
"for three questions, and it misleads on a fourth":

- what am I about to break (dependent counts, including the type-only and dynamic
  edges a grep for `from '...'` misses);
- where is the debt concentrated (zone-level counts);
- what is wrong that CI does not enforce — ~1300 transitively redundant value
  edges and 8 type-only/dynamic cycles, both outside the gate by design.

The fourth: a cluster's SIZE IS NOT ITS DIFFICULTY. `commands -> client` looked
like the obvious win at 28 edges into 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/. The render shows an edge's weight, not whether it can be
reversed — so the README pairs every visual question with the numeric query that
answers "can this actually move?", verified against the real output rather than
written from memory.

Also states plainly that `pnpm check:layering` is authoritative and nothing here
gates a merge: it is an instrument, not a rule.

scripts/depgraph/** joins scripts/layering/**, scripts/perf/** and
scripts/maestro-conformance/** in Fallow's ignorePatterns, which is how this repo
already treats tooling trees. Worth knowing rather than discovering: that exempts
viewer.js from the complexity gate, and its `draw` function would fail it.

Two exports added to scripts/layering/model.ts: `zoneRank` (the viewer colours
nodes by rank, so an inversion reads as an edge pointing the wrong way down the
ramp) and `targetDagZone`, previously module-private.

`pnpm check` green, 4488 unit tests. Verified against current main: 898 files,
4627 edges, 25 zones, R6 count matching the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur
2026-07-27 06:54:46 +00:00

275 lines
8.6 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Dependency-graph analysis model — pure functions over the layering gate's edge model.
//
// The graph is deliberately derived from `scripts/layering/model.ts` rather than a
// third-party extractor: the gate's file set (production `src/**/*.ts`, tests excluded),
// zone partition, edge kinds (value/type-only/dynamic), and cycle definition are already
// the repo's source of truth. A second extractor with its own resolution rules would
// visualize a graph the gate does not enforce.
import {
backEdgePair,
classifyZone,
findValueImportCycles,
targetDagZone,
typeInversionPair,
type ResolvedImportEdge,
} from '../layering/model.ts';
export type EdgeKind = 'value' | 'type' | 'dynamic';
export type GraphEdge = {
from: string;
to: string;
kind: EdgeKind;
line: number;
/** Set when this edge is a ranked-spine back-edge (`R5`), as `from-zone -> to-zone`. */
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. */
redundant: boolean;
};
export type GraphNode = {
id: string;
zone: string;
/** First two path segments — a finer cluster than the zone, used for layout gravity. */
group: string;
loc: number;
fanIn: number;
fanOut: number;
/** Index into `GraphData.cycles`, or -1. */
cycle: number;
};
export type ZoneEdge = {
from: string;
to: string;
count: number;
valueCount: number;
backEdge: boolean;
};
export type GraphCycle = {
path: string[];
/** `value` cycles are gate-rejected (R4); the others are gate-invisible by design. */
kind: EdgeKind;
};
export type GraphData = {
nodes: GraphNode[];
edges: GraphEdge[];
zones: { id: string; classification: string; files: number; loc: number }[];
zoneEdges: ZoneEdge[];
cycles: GraphCycle[];
};
export function fileGroup(file: string): string {
const match = /^src\/([^/]+)\/([^/]+)\//.exec(file);
if (match) return `${match[1]}/${match[2]}`;
return targetDagZone(file);
}
function countLines(source: string): number {
let lines = 1;
for (let index = 0; index < source.length; index++) {
if (source[index] === '\n') lines++;
}
return lines;
}
function edgeKind(edge: ResolvedImportEdge): EdgeKind {
if (edge.dynamic) return 'dynamic';
if (edge.typeOnly) return 'type';
return 'value';
}
/**
* Deduplicate parsed import edges down to one edge per (from, to) pair, keeping the
* strongest kind. A file that imports both a type and a value from the same module has one
* dependency on it, and the value import is what constrains layering and cold-start.
*/
export function collapseEdges(edges: readonly ResolvedImportEdge[]): GraphEdge[] {
const strength: Record<EdgeKind, number> = { type: 0, dynamic: 1, value: 2 };
const byPair = new Map<string, GraphEdge>();
for (const edge of edges) {
if (edge.file === edge.target) continue;
const key = `${edge.file}${edge.target}`;
const kind = edgeKind(edge);
const existing = byPair.get(key);
if (existing && strength[existing.kind] >= strength[kind]) continue;
byPair.set(key, {
from: edge.file,
to: edge.target,
kind,
line: edge.line,
backEdge: backEdgePair(edge),
typeInversion: typeInversionPair(edge),
redundant: false,
});
}
return [...byPair.values()].sort(
(left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to),
);
}
/**
* Mark edges removable without changing reachability: `a -> b` is redundant when `b` is
* still reachable from `a` through a path of length >= 2. These are the "you already
* depend on this transitively" edges — the cheap simplification candidates.
*
* Reachability is computed over value edges only, because a type-only or dynamic edge is
* not interchangeable with a static value dependency.
*/
export function markRedundantEdges(edges: GraphEdge[]): void {
const successors = new Map<string, string[]>();
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);
}
for (const edge of edges) {
if (edge.kind !== 'value') continue;
const seen = new Set<string>([edge.from]);
// Seed with the one-hop neighbours other than `to`, so the search only ever finds
// `to` at distance >= 2.
const queue = (successors.get(edge.from) ?? []).filter((next) => next !== edge.to);
for (const next of queue) seen.add(next);
let index = 0;
while (index < queue.length) {
const current = queue[index++]!;
for (const next of successors.get(current) ?? []) {
if (next === edge.to) {
edge.redundant = true;
index = queue.length;
break;
}
if (seen.has(next)) continue;
seen.add(next);
queue.push(next);
}
}
}
}
/**
* Cycles over an edge subset that includes weaker edge kinds. `findValueImportCycles`
* covers the gate's R4 scope (static value edges); passing type-only and dynamic edges
* through the same detector surfaces the cycles the gate deliberately does not reject —
* still design signal, because a type-only cycle means two modules co-define one contract.
*/
export function collectCycles(edges: readonly ResolvedImportEdge[]): GraphCycle[] {
const asValue = (subset: readonly ResolvedImportEdge[]): ResolvedImportEdge[] =>
subset.map((edge) => ({ ...edge, dynamic: false, typeOnly: false }));
const valuePaths = findValueImportCycles(edges);
const valueKeys = new Set(valuePaths.map(cycleKey));
const cycles: GraphCycle[] = valuePaths.map((path) => ({ path, kind: 'value' }));
const staticEdges = edges.filter((edge) => !edge.dynamic);
for (const path of findValueImportCycles(asValue(staticEdges))) {
if (valueKeys.has(cycleKey(path))) continue;
valueKeys.add(cycleKey(path));
cycles.push({ path, kind: 'type' });
}
for (const path of findValueImportCycles(asValue(edges))) {
if (valueKeys.has(cycleKey(path))) continue;
valueKeys.add(cycleKey(path));
cycles.push({ path, kind: 'dynamic' });
}
return cycles;
}
/** Rotation-independent identity for a cycle path, so the same loop is not reported twice. */
function cycleKey(path: readonly string[]): string {
const members = [...new Set(path)].sort();
return members.join('');
}
export function buildGraph(
sources: ReadonlyMap<string, string>,
edges: readonly ResolvedImportEdge[],
): GraphData {
const collapsed = collapseEdges(edges);
markRedundantEdges(collapsed);
const cycles = collectCycles(edges);
const cycleByFile = new Map<string, number>();
for (let index = 0; index < cycles.length; index++) {
for (const file of cycles[index]!.path) {
if (!cycleByFile.has(file)) cycleByFile.set(file, index);
}
}
const nodes = new Map<string, GraphNode>();
for (const [file, source] of sources) {
nodes.set(file, {
id: file,
zone: targetDagZone(file),
group: fileGroup(file),
loc: countLines(source),
fanIn: 0,
fanOut: 0,
cycle: cycleByFile.get(file) ?? -1,
});
}
for (const edge of collapsed) {
const from = nodes.get(edge.from);
const to = nodes.get(edge.to);
if (from) from.fanOut++;
if (to) to.fanIn++;
}
const zoneEdges = new Map<string, ZoneEdge>();
for (const edge of collapsed) {
const from = nodes.get(edge.from)?.zone;
const to = nodes.get(edge.to)?.zone;
if (!from || !to || from === to) continue;
const key = `${from}${to}`;
const existing = zoneEdges.get(key) ?? {
from,
to,
count: 0,
valueCount: 0,
backEdge: false,
};
existing.count++;
if (edge.kind === 'value') existing.valueCount++;
if (edge.backEdge) existing.backEdge = true;
zoneEdges.set(key, existing);
}
const zoneStats = new Map<string, { files: number; loc: number }>();
for (const node of nodes.values()) {
const stats = zoneStats.get(node.zone) ?? { files: 0, loc: 0 };
stats.files++;
stats.loc += node.loc;
zoneStats.set(node.zone, stats);
}
return {
nodes: [...nodes.values()].sort((left, right) => left.id.localeCompare(right.id)),
edges: collapsed,
zones: [...zoneStats]
.map(([id, stats]) => ({
id,
classification: classifyZone(id),
files: stats.files,
loc: stats.loc,
}))
.sort((left, right) => right.loc - left.loc),
zoneEdges: [...zoneEdges.values()].sort(
(left, right) =>
right.count - left.count ||
left.from.localeCompare(right.from) ||
left.to.localeCompare(right.to),
),
cycles,
};
}