mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
cd19a73c55
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
148 lines
4.6 KiB
TypeScript
148 lines
4.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { resolveImportEdges } from '../layering/model.ts';
|
|
import { buildGraph, collapseEdges, collectCycles, markRedundantEdges } from './model.ts';
|
|
|
|
function sources(entries: Record<string, string>): Map<string, string> {
|
|
return new Map(Object.entries(entries));
|
|
}
|
|
|
|
test('collapseEdges keeps one edge per pair at the strongest kind', () => {
|
|
const edges = resolveImportEdges(
|
|
sources({
|
|
'src/core/a.ts': [
|
|
"import type { Shape } from './b.ts';",
|
|
"import { run } from './b.ts';",
|
|
"import type { Other } from './c.ts';",
|
|
"void import('./d.ts');",
|
|
].join('\n'),
|
|
'src/core/b.ts': 'export const run = 1;',
|
|
'src/core/c.ts': 'export type Other = string;',
|
|
'src/core/d.ts': 'export const lazy = 1;',
|
|
}),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
collapseEdges(edges).map((edge) => ({ to: edge.to, kind: edge.kind })),
|
|
[
|
|
{ to: 'src/core/b.ts', kind: 'value' },
|
|
{ to: 'src/core/c.ts', kind: 'type' },
|
|
{ to: 'src/core/d.ts', kind: 'dynamic' },
|
|
],
|
|
);
|
|
});
|
|
|
|
test('redundant marks only value edges whose target is already reachable at distance >= 2', () => {
|
|
const edges = collapseEdges(
|
|
resolveImportEdges(
|
|
sources({
|
|
// a -> b -> c makes the direct a -> c edge removable; a -> d is the only route to d.
|
|
'src/core/a.ts': [
|
|
"import { b } from './b.ts';",
|
|
"import { c } from './c.ts';",
|
|
"import { d } from './d.ts';",
|
|
].join('\n'),
|
|
'src/core/b.ts': "export { c as b } from './c.ts';",
|
|
'src/core/c.ts': 'export const c = 1;',
|
|
'src/core/d.ts': 'export const d = 1;',
|
|
}),
|
|
),
|
|
);
|
|
markRedundantEdges(edges);
|
|
|
|
const flagged = edges
|
|
.filter((edge) => edge.redundant)
|
|
.map((edge) => `${edge.from} -> ${edge.to}`);
|
|
assert.deepEqual(flagged, ['src/core/a.ts -> src/core/c.ts']);
|
|
});
|
|
|
|
test('a type-only shortcut is never treated as redundant against a value path', () => {
|
|
const edges = collapseEdges(
|
|
resolveImportEdges(
|
|
sources({
|
|
'src/core/a.ts': ["import { b } from './b.ts';", "import type { C } from './c.ts';"].join(
|
|
'\n',
|
|
),
|
|
'src/core/b.ts': "export { c as b } from './c.ts';",
|
|
'src/core/c.ts': 'export type C = string;\nexport const c = 1;',
|
|
}),
|
|
),
|
|
);
|
|
markRedundantEdges(edges);
|
|
|
|
assert.deepEqual(
|
|
edges.filter((edge) => edge.redundant),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test('collectCycles separates gate-rejected value cycles from type-only and dynamic loops', () => {
|
|
const valueCycle = collectCycles(
|
|
resolveImportEdges(
|
|
sources({
|
|
'src/core/a.ts': "import { b } from './b.ts';\nexport const a = 1;",
|
|
'src/core/b.ts': "import { a } from './a.ts';\nexport const b = 1;",
|
|
}),
|
|
),
|
|
);
|
|
assert.deepEqual(
|
|
valueCycle.map((cycle) => cycle.kind),
|
|
['value'],
|
|
);
|
|
|
|
const typeCycle = collectCycles(
|
|
resolveImportEdges(
|
|
sources({
|
|
'src/core/a.ts': "import type { B } from './b.ts';\nexport type A = B;",
|
|
'src/core/b.ts': "import type { A } from './a.ts';\nexport type B = A | null;",
|
|
}),
|
|
),
|
|
);
|
|
assert.deepEqual(
|
|
typeCycle.map((cycle) => cycle.kind),
|
|
['type'],
|
|
);
|
|
|
|
const dynamicCycle = collectCycles(
|
|
resolveImportEdges(
|
|
sources({
|
|
'src/core/a.ts': "export const a = () => import('./b.ts');",
|
|
'src/core/b.ts': "export const b = () => import('./a.ts');",
|
|
}),
|
|
),
|
|
);
|
|
assert.deepEqual(
|
|
dynamicCycle.map((cycle) => cycle.kind),
|
|
['dynamic'],
|
|
);
|
|
});
|
|
|
|
test('buildGraph reports zone membership, degrees, and cross-zone edge counts', () => {
|
|
const files = sources({
|
|
'src/kernel/errors.ts': 'export const fail = 1;\n',
|
|
'src/core/interactors/tap.ts': "import { fail } from '../../kernel/errors.ts';\n",
|
|
'src/commands/tap.ts': [
|
|
"import { fail } from '../kernel/errors.ts';",
|
|
"import '../core/interactors/tap.ts';",
|
|
].join('\n'),
|
|
});
|
|
const graph = buildGraph(files, resolveImportEdges(files));
|
|
|
|
const kernel = graph.nodes.find((node) => node.id === 'src/kernel/errors.ts')!;
|
|
assert.equal(kernel.zone, 'kernel');
|
|
assert.equal(kernel.fanIn, 2);
|
|
assert.equal(kernel.fanOut, 0);
|
|
|
|
const interactor = graph.nodes.find((node) => node.id === 'src/core/interactors/tap.ts')!;
|
|
assert.equal(interactor.group, 'core/interactors');
|
|
|
|
assert.deepEqual(
|
|
graph.zoneEdges.map((edge) => `${edge.from} -> ${edge.to} (${edge.count})`),
|
|
['commands -> core (1)', 'commands -> kernel (1)', 'core -> kernel (1)'],
|
|
);
|
|
assert.deepEqual(
|
|
graph.zones.map((zone) => zone.classification),
|
|
['ranked', 'ranked', 'ranked'],
|
|
);
|
|
});
|