Files
Claude 2c519cd562 experiment: analysis-only depgraph, to test whether it clears the gate unexempted
NOT a finished change — pushed so the comparison behind a pending decision is
reproducible rather than a claim in a chat log. PR #1409 still carries the viewer.

Question: the viewer's productive output turned out to be the JSON, not the
render. Every finding this session came from numeric queries; the render was never
opened to make a decision. So: does an analysis-only version meet the repo's bar
WITHOUT the Fallow exemption that PR #1409 needs?

Method: delete viewer.{js,css,html} and the geometry (clusterLayout,
layeredLayout), keep computeLevels (it is analysis, not layout), emit JSON plus the
text summary, and REMOVE scripts/depgraph/** from ignorePatterns.

Result, with zero exemptions:

                        with viewer   analysis only
  complexity findings   23 (1 CRIT)   2
  unused files          2             0
  unused exports        3             0
  clone groups          1             0
  lines                 2811          ~590

Identical output: 898 files, 4627 edges, 1338 redundant value edges, 8 non-gated
cycles, R6 42 (matching the gate's baseline). So the numeric part can meet the
repo's bar unexempted; viewer.js — 920 lines with a CRITICAL-complexity `draw` —
never could.

Fixed along the way rather than suppressed: extracted `valueSuccessors` (the
value-edge adjacency was built identically in markRedundantEdges and
computeLevels — a real clone), extracted `edgeKindCode`/`edgeFlags` from a nested
ternary with CRAP 42, un-exported buildPayload/main, and deleted `fileGroup` and
the `group` node field, both dead once the cluster layout went.

Still open if this direction is chosen: split `buildGraph` (81 lines, 20
cyclomatic) and `markRedundantEdges` — the last 2 complexity findings, ordinary
functions rather than a canvas renderer. README is rewritten to match the
report-only shape; the when-to-use guidance carries over unchanged, since it was
already about numeric queries.

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

140 lines
5.2 KiB
TypeScript

// 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`: transitively redundant value edges, 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=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';
}
}
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 = transitively redundant, 4 = type-only inversion (R6). */
function edgeFlags(edge: GraphData['edges'][number]): number {
return (edge.backEdge ? 1 : 0) | (edge.redundant ? 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)!),
})),
};
}
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 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, jsonPath)}\n`,
);
return 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
process.exit(main(process.argv.slice(2)));
}