mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
a79f0d2e81
* feat: add depgraph authority overlay * fix: centralize depgraph authority declarations * refactor: make authority overlay declaration-driven
453 lines
18 KiB
TypeScript
453 lines
18 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { test } from 'node:test';
|
|
import { listSourceFiles, TYPE_INVERSION_BASELINE } from '../layering/check.ts';
|
|
import { ARCHITECTURE_OWNERSHIP } from '../layering/architecture-ownership.ts';
|
|
import { resolveImportEdges } from '../layering/model.ts';
|
|
import {
|
|
AUTHORITY_LABELS,
|
|
authorityLabelsForEdge,
|
|
buildGraph,
|
|
collapseEdges,
|
|
collectCycles,
|
|
markTransitivelyReachableEdges,
|
|
typeInversionsByPair,
|
|
} from './model.ts';
|
|
|
|
function sources(entries: Record<string, string>): Map<string, string> {
|
|
return new Map(Object.entries(entries));
|
|
}
|
|
|
|
function authorityWorkspaceTargets(): Map<string, string> {
|
|
return new Map([
|
|
['@agent-device/contracts/client', 'packages/contracts/src/facades/client.ts'],
|
|
['@agent-device/contracts/capture', 'packages/contracts/src/facades/capture.ts'],
|
|
['@agent-device/contracts/replay', 'packages/contracts/src/facades/replay.ts'],
|
|
['@agent-device/contracts/progress', 'packages/contracts/src/facades/progress.ts'],
|
|
]);
|
|
}
|
|
|
|
function authorityFixture(): Map<string, string> {
|
|
return sources({
|
|
'src/core/vocabulary-consumer.ts': [
|
|
"import type { ClientShape } from '@agent-device/contracts/client';",
|
|
"import type { CaptureShape } from '@agent-device/contracts/capture';",
|
|
"import type { ReplayShape } from '@agent-device/contracts/replay';",
|
|
"import type { ProgressShape } from '@agent-device/contracts/progress';",
|
|
].join('\n'),
|
|
'src/daemon/capability-consumer.ts': [
|
|
"import { createRequestRuntimeBindings } from './request-runtime-binding.ts';",
|
|
"import { isSessionRecording } from './session-script-publication-capability.ts';",
|
|
].join('\n'),
|
|
'src/daemon/state-consumer.ts': [
|
|
"import type { SessionState } from './types.ts';",
|
|
"import { SessionStore } from './session-store.ts';",
|
|
].join('\n'),
|
|
'src/daemon/type-consumer.ts': "import type { SessionStore } from './session-store.ts';\n",
|
|
'src/snapshot/policy-consumer.ts': [
|
|
"import type { SessionState } from '../daemon/types.ts';",
|
|
"import type { SessionRef } from '../daemon/types.ts';",
|
|
"import './ordinary-target.ts';",
|
|
].join('\n'),
|
|
'src/daemon/ordinary-consumer.ts': [
|
|
"import { SessionState } from './session-state.ts';",
|
|
"import { createRequestRuntimeBindingsExtra } from './request-runtime-binding.ts';",
|
|
].join('\n'),
|
|
'src/daemon/types.ts':
|
|
'export type SessionState = { name: string };\nexport type SessionRef = unknown;\n',
|
|
'src/daemon/session-store.ts': 'export class SessionStore {}\n',
|
|
'src/daemon/request-runtime-binding.ts': 'export function createRequestRuntimeBindings() {}\n',
|
|
'src/daemon/session-script-publication-capability.ts':
|
|
'export function isSessionRecording() {}\n',
|
|
'src/daemon/session-state.ts': 'export const SessionState = 1;\n',
|
|
'src/snapshot/ordinary-target.ts': 'export const ordinary = 1;\n',
|
|
'packages/contracts/src/facades/client.ts': 'export type ClientShape = string;\n',
|
|
'packages/contracts/src/facades/capture.ts': 'export type CaptureShape = string;\n',
|
|
'packages/contracts/src/facades/replay.ts': 'export type ReplayShape = string;\n',
|
|
'packages/contracts/src/facades/progress.ts': 'export type ProgressShape = string;\n',
|
|
});
|
|
}
|
|
|
|
function graphEdge(
|
|
graph: ReturnType<typeof buildGraph>,
|
|
from: string,
|
|
to: string,
|
|
): { kind: string; labels: readonly string[] } {
|
|
const index = graph.edges.findIndex((edge) => edge.from === from && edge.to === to);
|
|
assert.notEqual(index, -1, `${from} -> ${to} was not found`);
|
|
return { kind: graph.edges[index]!.kind, labels: graph.edgeAuthorities[index]! };
|
|
}
|
|
|
|
test('authority overlay uses declared roots and symbols, keeps kind separate, and collapses labels', () => {
|
|
const files = authorityFixture();
|
|
const graph = buildGraph(files, resolveImportEdges(files, authorityWorkspaceTargets()));
|
|
|
|
assert.deepEqual(
|
|
graphEdge(graph, 'src/core/vocabulary-consumer.ts', 'packages/contracts/src/facades/client.ts'),
|
|
{ kind: 'type', labels: ['vocabulary'] },
|
|
);
|
|
assert.deepEqual(
|
|
graphEdge(graph, 'src/daemon/capability-consumer.ts', 'src/daemon/request-runtime-binding.ts'),
|
|
{ kind: 'value', labels: ['capability'] },
|
|
);
|
|
assert.deepEqual(graphEdge(graph, 'src/daemon/state-consumer.ts', 'src/daemon/types.ts'), {
|
|
kind: 'type',
|
|
labels: ['live-state-shape'],
|
|
});
|
|
assert.deepEqual(
|
|
graphEdge(graph, 'src/daemon/state-consumer.ts', 'src/daemon/session-store.ts'),
|
|
{ kind: 'value', labels: ['live-state-authority'] },
|
|
);
|
|
assert.deepEqual(graphEdge(graph, 'src/daemon/type-consumer.ts', 'src/daemon/session-store.ts'), {
|
|
kind: 'type',
|
|
labels: ['live-state-authority'],
|
|
});
|
|
assert.deepEqual(
|
|
graphEdge(graph, 'src/snapshot/policy-consumer.ts', 'src/snapshot/ordinary-target.ts'),
|
|
{ kind: 'value', labels: ['executable-policy'] },
|
|
);
|
|
assert.deepEqual(
|
|
graphEdge(graph, 'src/daemon/ordinary-consumer.ts', 'src/daemon/session-state.ts'),
|
|
{ kind: 'value', labels: ['ordinary'] },
|
|
);
|
|
assert.deepEqual(
|
|
graphEdge(graph, 'src/daemon/ordinary-consumer.ts', 'src/daemon/request-runtime-binding.ts'),
|
|
{ kind: 'value', labels: ['ordinary'] },
|
|
);
|
|
|
|
const stateEdges = resolveImportEdges(files, authorityWorkspaceTargets()).filter(
|
|
(edge) =>
|
|
edge.file === 'src/snapshot/policy-consumer.ts' && edge.target === 'src/daemon/types.ts',
|
|
);
|
|
assert.equal(stateEdges.length, 2, 'the fixture must exercise raw same-pair imports');
|
|
assert.deepEqual(graphEdge(graph, 'src/snapshot/policy-consumer.ts', 'src/daemon/types.ts'), {
|
|
kind: 'type',
|
|
labels: ['live-state-shape', 'executable-policy'],
|
|
});
|
|
assert.deepEqual(graph.edgeAuthorities.length, graph.edges.length);
|
|
assert.deepEqual(Object.keys(graph.authorityCounts), AUTHORITY_LABELS);
|
|
assert.deepEqual(graph.authorityCounts, {
|
|
vocabulary: 4,
|
|
capability: 2,
|
|
'live-state-shape': 2,
|
|
'live-state-authority': 2,
|
|
'executable-policy': 2,
|
|
ordinary: 2,
|
|
});
|
|
assert.equal(authorityLabelsForEdge(stateEdges[0]!).includes('live-state-shape'), true);
|
|
});
|
|
|
|
test('live-state labels follow shared declarations and reject lookalike targets', () => {
|
|
for (const declaration of ARCHITECTURE_OWNERSHIP.liveState) {
|
|
assert.deepEqual(
|
|
authorityLabelsForEdge({
|
|
file: 'src/core/live-state-consumer.ts',
|
|
target: declaration.root,
|
|
spec: `./${declaration.root.split('/').at(-1)}`,
|
|
dynamic: false,
|
|
typeOnly: true,
|
|
line: 1,
|
|
symbols: [...declaration.exports],
|
|
fromZone: 'core',
|
|
toZone: 'daemon-server',
|
|
}),
|
|
[declaration.kind],
|
|
);
|
|
}
|
|
|
|
const sessionState = ARCHITECTURE_OWNERSHIP.liveState.find(
|
|
({ kind }) => kind === 'live-state-shape',
|
|
)!;
|
|
assert.deepEqual(
|
|
authorityLabelsForEdge({
|
|
file: 'src/core/live-state-consumer.ts',
|
|
target: 'src/daemon/session-state.ts',
|
|
spec: './session-state.ts',
|
|
dynamic: false,
|
|
typeOnly: true,
|
|
line: 1,
|
|
symbols: [...sessionState.exports],
|
|
fromZone: 'core',
|
|
toZone: 'daemon-server',
|
|
}),
|
|
['ordinary'],
|
|
);
|
|
});
|
|
|
|
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('flags only value edges whose target is already reachable at distance >= 2', () => {
|
|
const edges = collapseEdges(
|
|
resolveImportEdges(
|
|
sources({
|
|
// a -> b -> c means c is reachable from a at distance 2, so the direct a -> c edge is
|
|
// FLAGGED. Note it is not removable: `b` re-exports c's binding under a different name,
|
|
// so deleting a -> c would break a's `c` import. That gap is the point of the rename —
|
|
// this measures module reachability, not safe removal. 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;',
|
|
}),
|
|
),
|
|
);
|
|
markTransitivelyReachableEdges(edges);
|
|
|
|
const flagged = edges
|
|
.filter((edge) => edge.transitivelyReachable)
|
|
.map((edge) => `${edge.from} -> ${edge.to}`);
|
|
assert.deepEqual(flagged, ['src/core/a.ts -> src/core/c.ts']);
|
|
});
|
|
|
|
test('a type-only shortcut is never flagged 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;',
|
|
}),
|
|
),
|
|
);
|
|
markTransitivelyReachableEdges(edges);
|
|
|
|
assert.deepEqual(
|
|
edges.filter((edge) => edge.transitivelyReachable),
|
|
[],
|
|
);
|
|
});
|
|
|
|
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({
|
|
'packages/kernel/src/errors.ts': 'export const fail = 1;\n',
|
|
'src/core/interactors/tap.ts': "import { fail } from '@agent-device/kernel/errors';\n",
|
|
'src/commands/tap.ts': [
|
|
"import { fail } from '@agent-device/kernel/errors';",
|
|
"import '../core/interactors/tap.ts';",
|
|
].join('\n'),
|
|
});
|
|
const graph = buildGraph(files, resolveImportEdges(files));
|
|
|
|
const kernel = graph.nodes.find((node) => node.id === 'packages/kernel/src/errors.ts')!;
|
|
assert.equal(kernel.zone, 'kernel');
|
|
assert.equal(kernel.fanIn, 2);
|
|
assert.equal(kernel.fanOut, 0);
|
|
|
|
// A non-root zone member resolves to its folder, not to `(root)`.
|
|
const interactor = graph.nodes.find((node) => node.id === 'src/core/interactors/tap.ts')!;
|
|
assert.equal(interactor.zone, 'core');
|
|
|
|
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.id}:${zone.classification}`),
|
|
['kernel:unranked', 'core:ranked', 'commands:ranked'],
|
|
);
|
|
});
|
|
|
|
// Two-sources-of-truth check, run by the Layering Guard job.
|
|
//
|
|
// The report and the gate read the same model, so their inversion counts must agree. This locks
|
|
// that: if the tree changes and only one side is updated, or if the report's extraction diverges
|
|
// from what the gate sees, this fails and names the difference.
|
|
//
|
|
// What it proves precisely: the report's own graph build, over the real tree, reproduces
|
|
// TYPE_INVERSION_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 so the numbers cannot differ for a reason unrelated to layering. The gate stays the
|
|
// authority; if these disagree, the baseline or the tree is wrong, never this test.
|
|
test("the report's inversion count reproduces the gate's TYPE_INVERSION_BASELINE", () => {
|
|
const files = listSourceFiles();
|
|
const sources = new Map(files.map((file) => [file, readFileSync(file, 'utf8')]));
|
|
const actual = typeInversionsByPair(resolveImportEdges(sources));
|
|
|
|
assert.deepEqual(
|
|
actual,
|
|
// Object key order differs between the two literals; compare as sorted entries.
|
|
Object.fromEntries(
|
|
Object.entries(TYPE_INVERSION_BASELINE).sort(([left], [right]) => left.localeCompare(right)),
|
|
),
|
|
'depgraph and scripts/layering/check.ts disagree about type-only spine inversions. ' +
|
|
'Regenerate with `pnpm depgraph` and update TYPE_INVERSION_BASELINE, or fix the edge.',
|
|
);
|
|
});
|
|
|
|
// A raw NUL byte in a source file makes Git classify it as binary, which hides the whole diff
|
|
// behind `- -` and leaves the file unreviewable. This module used a literal NUL as a map-key
|
|
// delimiter and shipped that way through a review; it is now the escape sequence, identical at
|
|
// runtime and textual on disk. Guarded repo-wide rather than for this one file, because nothing
|
|
// else would catch a recurrence and the failure mode is silent: the code works, the review does not.
|
|
test('no tracked TypeScript source contains a raw NUL byte', () => {
|
|
const tracked = execFileSync('git', ['ls-files', 'src/*.ts', 'src/**/*.ts', 'scripts/**/*.ts'], {
|
|
encoding: 'utf8',
|
|
})
|
|
.split('\n')
|
|
.filter(Boolean);
|
|
|
|
const binary = tracked.filter((file) => readFileSync(file).includes(0));
|
|
assert.deepEqual(
|
|
binary,
|
|
[],
|
|
'these files contain a raw NUL byte, so Git treats them as binary and hides their diff. ' +
|
|
'Use a unicode escape instead of a literal control character.',
|
|
);
|
|
});
|
|
|
|
// build.ts had no coverage at all: every test above exercises model.ts, so the CLI could break its
|
|
// output path, JSON shape or summary without anything failing. These run it as a subprocess, which
|
|
// is the only way to cover argument handling and the file it actually writes.
|
|
|
|
function runBuild(args: readonly string[]): {
|
|
status: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
} {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
['--experimental-strip-types', 'scripts/depgraph/build.ts', ...args],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
}
|
|
|
|
test('build.ts writes the default path and a summary consistent with the JSON', () => {
|
|
const { status, stdout } = runBuild([]);
|
|
assert.equal(status, 0, stdout);
|
|
|
|
const payload = JSON.parse(readFileSync('.tmp/depgraph/graph.json', 'utf8')) as {
|
|
generated: { commit: string; files: number; edges: number };
|
|
zones: { id: string; rank: number | null }[];
|
|
nodes: unknown[];
|
|
edges: [number, number, number, number][];
|
|
edgeAuthorities: string[][];
|
|
authorityCounts: Record<string, number>;
|
|
typeInversions: Record<string, number>;
|
|
};
|
|
|
|
// Wire shape: the fields a consumer queries. A rename here is a breaking change for any script
|
|
// following README.md, so it is pinned rather than assumed.
|
|
for (const field of [
|
|
'generated',
|
|
'zones',
|
|
'zoneEdges',
|
|
'nodes',
|
|
'edges',
|
|
'cycles',
|
|
'typeInversions',
|
|
]) {
|
|
assert.ok(field in payload, `legacy payload field ${field} disappeared`);
|
|
}
|
|
assert.equal(payload.nodes.length, payload.generated.files);
|
|
assert.equal(payload.edges.length, payload.generated.edges);
|
|
assert.equal(payload.edgeAuthorities.length, payload.edges.length);
|
|
assert.equal(
|
|
payload.edges.every((edge) => edge.length === 4),
|
|
true,
|
|
);
|
|
assert.deepEqual(Object.keys(payload.authorityCounts), AUTHORITY_LABELS);
|
|
assert.ok(payload.zones.length > 0);
|
|
assert.ok(Object.keys(payload.typeInversions).length > 0);
|
|
|
|
// The printed summary must agree with the payload it was derived from.
|
|
const inversions = Object.values(payload.typeInversions).reduce((sum, n) => sum + n, 0);
|
|
assert.match(
|
|
stdout,
|
|
new RegExp(`${payload.generated.files} files, ${payload.generated.edges} edges`),
|
|
);
|
|
assert.match(stdout, new RegExp(`type-only spine inversions \\(R6\\): ${inversions}`));
|
|
const reachable = payload.edges.filter(([, , , flags]) => (flags & 2) !== 0).length;
|
|
assert.match(stdout, new RegExp(`reachable at distance >= 2: ${reachable}`));
|
|
});
|
|
|
|
test('build.ts honours --out and reports the path it wrote', () => {
|
|
const out = join(mkdtempSync(join(tmpdir(), 'depgraph-')), 'custom.json');
|
|
const { status, stdout } = runBuild(['--out', out]);
|
|
assert.equal(status, 0, stdout);
|
|
assert.ok(existsSync(out), `expected ${out} to exist`);
|
|
JSON.parse(readFileSync(out, 'utf8'));
|
|
assert.ok(stdout.includes('custom.json'), stdout);
|
|
});
|
|
|
|
test('build.ts falls back to the default path when --out has no value', () => {
|
|
// Not an error path today: a trailing `--out` is ignored rather than rejected. Pinned so the
|
|
// behaviour is a decision rather than an accident, and so changing it is a visible diff.
|
|
const { status, stdout } = runBuild(['--out']);
|
|
assert.equal(status, 0, stdout);
|
|
assert.ok(stdout.includes('.tmp/depgraph/graph.json'), stdout);
|
|
});
|