mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
2c519cd562
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
140 lines
5.2 KiB
TypeScript
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)));
|
|
}
|