mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
c5d9707196
* refactor(daemon): move the device selection cluster into a workspace package
git-rename src/core/dispatch-resolve.ts, src/core/device-selection-resolver.ts and src/request/device-inventory-context.ts (plus their tests and fixtures) into a new private package @agent-device/device-selection (deps: contracts, host-kit, kernel). One subpath per module points straight at the moved file; no index.ts, no re-export at the old path. Consumers switch to the owning specifier in the follow-up commit.
* refactor(daemon): install the installed-app probe on the inventory gateways and switch consumers to the device-selection package
The composition root (src/platform-runtime-device-inventory.ts) now attaches an optional findInstalledApp probe to the composed device inventory gateways, lazily importing the Apple simulator app-resolution mechanics. Device selection reads the probe from the request context instead of importing a platform package directly, so absent probes fall back to the ordinary inventory rules. All consumers switch to the @agent-device/device-selection/{dispatch-resolve,device-selection-resolver,device-inventory-context} subpaths; the moved tests carry a package-local inventory test util.
* chore(gates): re-point the layering gates at the device-selection zone
TARGET_DAG_RANK gains the device-selection leaf zone and drops the retired request zone; the back-edge fixture and the capture-kit ALS substrate fixture move to the package path.
* test(daemon): prove the factory-installed app probe narrows simulator selection
The moved selection tests inject their own probe, so nothing covered the
composition root actually attaching findInstalledApp to the gateways:
omitting it would leave those tests green while app-based selection
silently fell back to the generic local rules.
The new case runs two booted simulators through
createComposedDeviceInventoryGateways and the request context, fakes
only the leaf xcrun spawn (core tool-provider), and asserts the
single-app-installed-local selection plus both probe consults.
548 lines
22 KiB
TypeScript
548 lines
22 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { test } from 'node:test';
|
|
import { listSourceFiles } from './check.ts';
|
|
import {
|
|
fieldClassificationDrift,
|
|
findSessionStateWrites,
|
|
sessionStateFields,
|
|
SESSION_STATE_FIELD_OWNERS,
|
|
STORE_OWNED_SESSION_STATE_FIELDS,
|
|
} from './session-state.ts';
|
|
import {
|
|
largestTypeCycleMembers,
|
|
largestTypeCycleSize,
|
|
RANKED_ZONES,
|
|
typeInversionPair,
|
|
UNRANKED_ZONES,
|
|
classifyZone,
|
|
collectBackEdges,
|
|
collectZones,
|
|
findValueImportCycles,
|
|
parseImports,
|
|
resolveImportEdges,
|
|
unclassifiedZones,
|
|
} from './model.ts';
|
|
|
|
test('parseImports distinguishes value, type-only, dynamic, and value re-export edges', () => {
|
|
const edges = parseImports(
|
|
[
|
|
"import value from './value.ts';",
|
|
"import type { TypeA } from './types.ts';",
|
|
"import { type TypeB, type TypeC } from './more-types.ts';",
|
|
"import { type TypeD, runtime } from './mixed.ts';",
|
|
"export { runtimeExport } from './exported.ts';",
|
|
"export type { ExportedType } from './exported-types.ts';",
|
|
"void import('./dynamic.ts');",
|
|
].join('\n'),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
edges.map(({ spec, dynamic, typeOnly }) => ({ spec, dynamic, typeOnly })),
|
|
[
|
|
{ spec: './value.ts', dynamic: false, typeOnly: false },
|
|
{ spec: './types.ts', dynamic: false, typeOnly: true },
|
|
{ spec: './more-types.ts', dynamic: false, typeOnly: true },
|
|
{ spec: './mixed.ts', dynamic: false, typeOnly: false },
|
|
{ spec: './exported.ts', dynamic: false, typeOnly: false },
|
|
{ spec: './exported-types.ts', dynamic: false, typeOnly: true },
|
|
{ spec: './dynamic.ts', dynamic: true, typeOnly: false },
|
|
],
|
|
);
|
|
});
|
|
|
|
test('parseImports detects multiline dynamic imports', () => {
|
|
const edges = parseImports(['void import(', " '../multiline.ts'", ');'].join('\n'));
|
|
|
|
assert.deepEqual(edges, [
|
|
{
|
|
spec: '../multiline.ts',
|
|
dynamic: true,
|
|
typeOnly: false,
|
|
line: 1,
|
|
symbols: [],
|
|
bindingResidue: false,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('parseImports resolves constant-template dynamic imports', () => {
|
|
const edges = parseImports('void import(`../template.ts`);');
|
|
|
|
assert.deepEqual(edges, [
|
|
{
|
|
spec: '../template.ts',
|
|
dynamic: true,
|
|
typeOnly: false,
|
|
line: 1,
|
|
symbols: [],
|
|
bindingResidue: false,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('parseImports captures destructured named bindings of dynamic imports, keyed by export name', () => {
|
|
const edges = parseImports(
|
|
[
|
|
"const { a, 'b': c } = await import('./dyn.ts');",
|
|
"const mod = await import('./dyn.ts');",
|
|
"const wrapped = (await import('./dyn.ts')) as Mod;",
|
|
'const { a, ...rest } = await import("./dyn.ts");',
|
|
'const { [keyExpr]: named } = await import("./dyn.ts");',
|
|
].join('\n'),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
edges.map(({ spec, symbols, bindingResidue }) => ({ spec, symbols, bindingResidue })),
|
|
[
|
|
{ spec: './dyn.ts', symbols: ['a', 'b'], bindingResidue: false },
|
|
{ spec: './dyn.ts', symbols: [], bindingResidue: false },
|
|
{ spec: './dyn.ts', symbols: [], bindingResidue: false },
|
|
{ spec: './dyn.ts', symbols: ['a'], bindingResidue: true },
|
|
{ spec: './dyn.ts', symbols: [], bindingResidue: true },
|
|
],
|
|
);
|
|
});
|
|
|
|
test('parseImports retains named source symbols without changing edge-kind detection', () => {
|
|
const edges = parseImports(
|
|
[
|
|
"import { value as localValue, type TypeA } from './named.ts';",
|
|
"import type { TypeB as RenamedType } from './types.ts';",
|
|
"export { reExport as publicName } from './re-export.ts';",
|
|
"export type { ExportedType } from './exported-types.ts';",
|
|
"import * as namespace from './namespace.ts';",
|
|
"void import('./dynamic.ts');",
|
|
].join('\n'),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
edges.map(({ spec, dynamic, typeOnly, symbols }) => ({ spec, dynamic, typeOnly, symbols })),
|
|
[
|
|
{
|
|
spec: './named.ts',
|
|
dynamic: false,
|
|
typeOnly: false,
|
|
symbols: ['value', 'TypeA'],
|
|
},
|
|
{ spec: './types.ts', dynamic: false, typeOnly: true, symbols: ['TypeB'] },
|
|
{ spec: './re-export.ts', dynamic: false, typeOnly: false, symbols: ['reExport'] },
|
|
{ spec: './exported-types.ts', dynamic: false, typeOnly: true, symbols: ['ExportedType'] },
|
|
{ spec: './namespace.ts', dynamic: false, typeOnly: false, symbols: [] },
|
|
{ spec: './dynamic.ts', dynamic: true, typeOnly: false, symbols: [] },
|
|
],
|
|
);
|
|
});
|
|
|
|
test('parseImports ignores comments inside named bindings', () => {
|
|
const edges = parseImports(
|
|
[
|
|
"import { /* exact, declared */ SessionStore /* authority */ } from './store.ts';",
|
|
'import /* shape */ {',
|
|
' // exact declaration',
|
|
' type SessionState as State,',
|
|
"} from './types.ts';",
|
|
].join('\n'),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
edges.map(({ spec, typeOnly, symbols }) => ({ spec, typeOnly, symbols })),
|
|
[
|
|
{ spec: './store.ts', typeOnly: false, symbols: ['SessionStore'] },
|
|
{ spec: './types.ts', typeOnly: true, symbols: ['SessionState'] },
|
|
],
|
|
);
|
|
});
|
|
|
|
test('value cycles fail while type-only and dynamic cycles stay outside the graph', () => {
|
|
const valueCycle = resolveImportEdges(
|
|
new Map([
|
|
['src/core/a.ts', "import '../commands/b.ts';"],
|
|
['src/commands/b.ts', "export { a } from '../core/a.ts';"],
|
|
]),
|
|
);
|
|
assert.deepEqual(findValueImportCycles(valueCycle), [
|
|
['src/commands/b.ts', 'src/core/a.ts', 'src/commands/b.ts'],
|
|
]);
|
|
|
|
const nonValueCycle = resolveImportEdges(
|
|
new Map([
|
|
['src/core/a.ts', "import type { B } from '../commands/b.ts';"],
|
|
['src/commands/b.ts', "void import('../core/a.ts');"],
|
|
]),
|
|
);
|
|
assert.deepEqual(findValueImportCycles(nonValueCycle), []);
|
|
});
|
|
|
|
test('back-edge identities follow the documented target spine', () => {
|
|
const edges = resolveImportEdges(
|
|
new Map([
|
|
['src/contracts/result.ts', "import '../core/platform-plugin.ts';"],
|
|
['src/core/platform-plugin.ts', 'export const plugin = true;'],
|
|
['src/commands/help.ts', "import '../cli/parser.ts';"],
|
|
['src/cli/parser.ts', 'export const parser = true;'],
|
|
['src/(root-fixture)/shared.ts', "import './core/platform-plugin.ts';"],
|
|
]),
|
|
);
|
|
const actual = collectBackEdges(edges);
|
|
assert.deepEqual(actual, {
|
|
'commands -> cli': ['src/commands/help.ts -> src/cli/parser.ts'],
|
|
'contracts -> core': ['src/contracts/result.ts -> src/core/platform-plugin.ts'],
|
|
});
|
|
});
|
|
|
|
test('neutral ownership zones reject value imports into higher layers', () => {
|
|
const edges = resolveImportEdges(
|
|
new Map([
|
|
['src/contracts/result.ts', "import '../core/result.ts';"],
|
|
['src/core/result.ts', 'export const result = true;'],
|
|
['packages/device-selection/src/selection.ts', "import '@agent-device/commands/cancel';"],
|
|
['src/commands/cancel.ts', 'export const cancel = true;'],
|
|
['packages/selectors/src/internal/parse.ts', "import '../../../../src/client/client.ts';"],
|
|
['src/client/client.ts', 'export const client = true;'],
|
|
['src/cli-schema/schema.ts', "import '../cli/parser.ts';"],
|
|
['src/cli/parser.ts', 'export const parser = true;'],
|
|
]),
|
|
);
|
|
|
|
assert.deepEqual(collectBackEdges(edges), {
|
|
'cli-schema -> cli': ['src/cli-schema/schema.ts -> src/cli/parser.ts'],
|
|
'contracts -> core': ['src/contracts/result.ts -> src/core/result.ts'],
|
|
'device-selection -> commands': [
|
|
'packages/device-selection/src/selection.ts -> src/commands/cancel.ts',
|
|
],
|
|
'selectors -> client': ['packages/selectors/src/internal/parse.ts -> src/client/client.ts'],
|
|
});
|
|
});
|
|
|
|
test('a relative import resolves within its own workspace package src/, but not past it', () => {
|
|
const edges = resolveImportEdges(
|
|
new Map([
|
|
['packages/contracts/src/facades/device.ts', "import '../device.ts';"],
|
|
['packages/contracts/src/device.ts', 'export const device = true;'],
|
|
// A value cycle closed entirely inside one package must be as visible to R4 as
|
|
// one closed inside src/ — this is what #1781 A9-2 found invisible: `resolved`
|
|
// landed under `packages/<name>/src/`, so the old `resolved.startsWith('src/')`
|
|
// check dropped both edges and the reverse-reachability graph stopped at the
|
|
// package facade.
|
|
['packages/contracts/src/cycle-a.ts', "import '../src/cycle-b.ts';"],
|
|
['packages/contracts/src/cycle-b.ts', "import '../src/cycle-a.ts';"],
|
|
// A relative path that climbs out of any package's src/ (landing under a bare
|
|
// `packages/<name>/` with no `src/` segment) must still be refused by the
|
|
// model — R11 owns rejecting that import outright, but the graph itself must
|
|
// not silently resolve a target outside src/ and packages/*/src/.
|
|
['packages/contracts/src/escape.ts', "import '../../outside-src.ts';"],
|
|
['packages/outside-src.ts', 'export const outsideSrc = true;'],
|
|
]),
|
|
);
|
|
|
|
assert.deepEqual(edges.map(({ file, target }) => `${file} -> ${target}`).sort(), [
|
|
'packages/contracts/src/cycle-a.ts -> packages/contracts/src/cycle-b.ts',
|
|
'packages/contracts/src/cycle-b.ts -> packages/contracts/src/cycle-a.ts',
|
|
'packages/contracts/src/facades/device.ts -> packages/contracts/src/device.ts',
|
|
]);
|
|
assert.deepEqual(findValueImportCycles(edges), [
|
|
[
|
|
'packages/contracts/src/cycle-a.ts',
|
|
'packages/contracts/src/cycle-b.ts',
|
|
'packages/contracts/src/cycle-a.ts',
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('type-only edges are ranked by R6 and ignored by R5, and vice versa', () => {
|
|
const edges = resolveImportEdges(
|
|
new Map([
|
|
['src/commands/surface.ts', "import type { Shape } from '../client/client-types.ts';"],
|
|
['src/client/client-types.ts', 'export type Shape = { a: 1 };'],
|
|
['src/contracts/value.ts', "import '../core/logic.ts';"],
|
|
['src/core/logic.ts', 'export const logic = true;'],
|
|
['src/contracts/lazy.ts', "void import('../commands/surface.ts');"],
|
|
]),
|
|
);
|
|
|
|
// The type-only inversion is invisible to R5 and caught by R6.
|
|
assert.deepEqual(collectBackEdges(edges), {
|
|
'contracts -> core': ['src/contracts/value.ts -> src/core/logic.ts'],
|
|
});
|
|
assert.deepEqual(edges.map(typeInversionPair).filter(Boolean), ['commands -> client']);
|
|
|
|
// Neither rule ranks a dynamic import: it is a deliberate cold-start seam.
|
|
assert.equal(
|
|
edges.filter((edge) => edge.dynamic).every((edge) => typeInversionPair(edge) === null),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('ranked and unranked zones are disjoint and both non-empty', () => {
|
|
assert.ok(RANKED_ZONES.size > 0);
|
|
assert.ok(UNRANKED_ZONES.size > 0);
|
|
const overlap = [...RANKED_ZONES].filter((zone) => UNRANKED_ZONES.has(zone));
|
|
assert.deepEqual(overlap, [], 'a zone cannot be both ranked and intentionally unranked');
|
|
});
|
|
|
|
test('classifyZone separates the ranked spine from intentionally-unranked zones', () => {
|
|
assert.equal(classifyZone('contracts'), 'ranked');
|
|
assert.equal(classifyZone('daemon-server'), 'ranked');
|
|
assert.equal(classifyZone('(root)'), 'unranked');
|
|
assert.equal(classifyZone('platform-runtime'), 'unranked');
|
|
assert.equal(classifyZone('platforms'), 'unclassified');
|
|
assert.equal(classifyZone('utils'), 'unclassified');
|
|
// Every satellite zone joined the spine; only the composition root stays out, because R2
|
|
// forbids daemon/ from importing commands/ so the files that wire them cannot be ranked.
|
|
assert.equal(classifyZone('mcp'), 'ranked');
|
|
assert.equal(classifyZone('screenshot-diff'), 'ranked');
|
|
// A zone that is neither ranked nor listed peripheral must be flagged, never
|
|
// silently treated as back-edge-free.
|
|
assert.equal(classifyZone('not-a-real-zone'), 'unclassified');
|
|
});
|
|
|
|
test('every production zone is deliberately classified as ranked or unranked', () => {
|
|
// Drift guard: a new src/<folder>/ (or a daemon-client/server split) forces a
|
|
// deliberate ranked-vs-peripheral decision here instead of silently escaping
|
|
// spine back-edge detection. If this fails, add the new zone to TARGET_DAG_RANK
|
|
// (ranked spine) or UNRANKED_ZONES (root/peripheral) in model.ts.
|
|
const productionFiles = listSourceFiles();
|
|
assert.deepEqual(unclassifiedZones(productionFiles), []);
|
|
|
|
// The classification must also stay honest to the tree: every zone the model
|
|
// names is a real production zone, so the docs cannot list a spine or peripheral
|
|
// zone that no longer exists.
|
|
const presentZones = collectZones(listSourceFiles());
|
|
const namedZones = new Set([...RANKED_ZONES, ...UNRANKED_ZONES]);
|
|
const staleNamedZones = [...namedZones].filter((zone) => !presentZones.has(zone)).sort();
|
|
assert.deepEqual(staleNamedZones, []);
|
|
});
|
|
|
|
test('listSourceFiles includes root-level src/*.ts production files', () => {
|
|
const files = new Set(listSourceFiles());
|
|
for (const rootFile of ['src/cli.ts', 'src/runtime.ts', 'src/backend.ts']) {
|
|
assert.ok(files.has(rootFile), `expected ${rootFile} in analyzed source files`);
|
|
}
|
|
assert.ok(![...files].some((file) => file.endsWith('.test.ts')));
|
|
});
|
|
|
|
test('SessionState field names come from the declaration, not a hand-kept list', () => {
|
|
const fields = sessionStateFields(
|
|
[
|
|
'export type SessionState = {',
|
|
' name: string;',
|
|
' sessionScope?: {',
|
|
" kind: 'cwd';",
|
|
' id: string;',
|
|
' };',
|
|
' refFrame?: RefFrame;',
|
|
'};',
|
|
'',
|
|
'export type Other = { notAField: string };',
|
|
].join('\n'),
|
|
);
|
|
// Nested object members are not session fields, and neighbouring types are not scanned.
|
|
assert.deepEqual(fields, ['name', 'sessionScope', 'refFrame']);
|
|
});
|
|
|
|
test('session-state writes are found by field, and non-daemon or undeclared names are not', () => {
|
|
const writes = findSessionStateWrites(
|
|
new Map([
|
|
['src/daemon/ref-frame.ts', "session.refFrame = 'active';"],
|
|
['src/daemon/session-snapshot.ts', 'session.snapshotGeneration += 1;'],
|
|
// the store owns the record and may write anything on it
|
|
['src/daemon/session-store.ts', "session.refFrame = 'expired';"],
|
|
// a runner session outside the daemon is a different type that happens to share a name
|
|
['src/platforms/apple/runner-session.ts', 'session.refFrame = 1;'],
|
|
// a local that is not a declared SessionState field
|
|
['src/daemon/session-observability/internal/session-audio.ts', 'session.somethingElse = 1;'],
|
|
// reads and comparisons are not writes
|
|
['src/daemon/interaction/internal/find.ts', "if (session.refFrame === 'active') return;"],
|
|
// a write into a sub-object is not a write to the field itself
|
|
['src/daemon/handlers/session-probe.ts', 'session.refFrame.inner = 1;'],
|
|
// a different binding that happens to have a matching property
|
|
['src/daemon/session-lifecycle/internal/session-close.ts', "other.refFrame = 'expired';"],
|
|
]),
|
|
['refFrame', 'snapshotGeneration'],
|
|
);
|
|
|
|
assert.deepEqual(
|
|
writes.map(({ file, field }) => `${file}:${field}`),
|
|
['src/daemon/ref-frame.ts:refFrame', 'src/daemon/session-snapshot.ts:snapshotGeneration'],
|
|
);
|
|
});
|
|
|
|
test('every assignment form is a write, including the ones a regex forgets', () => {
|
|
// A line-based matcher has to enumerate operators, and the ones it misses are the natural
|
|
// ways to write these: `??=` for a default on an optional field, `||=`/`&&=` for a flag.
|
|
const forms = [
|
|
'session.refFrame = 1;',
|
|
'session.refFrame ??= 1;',
|
|
'session.refFrame ||= 1;',
|
|
'session.refFrame &&= 1;',
|
|
'session.refFrame += 1;',
|
|
'session.refFrame -= 1;',
|
|
'session.refFrame++;',
|
|
'--session.refFrame;',
|
|
'session\n .refFrame = 1;',
|
|
];
|
|
for (const form of forms) {
|
|
const writes = findSessionStateWrites(new Map([['src/daemon/probe.ts', form]]), ['refFrame']);
|
|
assert.deepEqual(
|
|
writes.map(({ field }) => field),
|
|
['refFrame'],
|
|
`expected ${JSON.stringify(form)} to count as a write`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('a computed session write is reported rather than silently unattributed', () => {
|
|
const writes = findSessionStateWrites(
|
|
new Map([['src/daemon/probe.ts', 'session[key] = 1;\nsession[`refFrame`] = 2;']]),
|
|
['refFrame'],
|
|
);
|
|
// `[computed]` has no entry in SESSION_STATE_FIELD_OWNERS, so R7 fails on it by
|
|
// construction — a computed write can never pass as an owned one.
|
|
assert.deepEqual(
|
|
writes.map(({ field }) => field),
|
|
['[computed]', '[computed]'],
|
|
);
|
|
assert.equal(SESSION_STATE_FIELD_OWNERS['[computed]'], undefined);
|
|
});
|
|
|
|
test('every declared session-state owner is a real file path under src/daemon', () => {
|
|
for (const [field, owners] of Object.entries(SESSION_STATE_FIELD_OWNERS)) {
|
|
assert.ok(owners.length > 0, `${field} must name at least one owner`);
|
|
for (const owner of owners) {
|
|
assert.match(
|
|
owner,
|
|
/^src\/daemon\/.+\.ts$/,
|
|
`${field} owner ${owner} must be a daemon module`,
|
|
);
|
|
}
|
|
assert.deepEqual([...owners], [...owners].sort(), `${field} owners must be sorted`);
|
|
}
|
|
});
|
|
|
|
test('a session write counts through an aliased binding, not only one named `session`', () => {
|
|
// The daemon names these records by role: nextSession, provisionalSession, completedSession,
|
|
// preRunSession, preEntrySession, activeSession. Matching only the literal name `session` hid
|
|
// three real foreign writes — nextSession.snapshotGeneration in snapshot-runtime.ts among them
|
|
// — while the gate reported that every write was inside its owner.
|
|
const writes = findSessionStateWrites(
|
|
new Map([
|
|
[
|
|
'src/daemon/probe.ts',
|
|
[
|
|
'nextSession.snapshotGeneration = 3;',
|
|
'preEntrySession.refFrame = "active";',
|
|
'completedSession.saveScriptComplete = true;',
|
|
// Not a session binding, and not a session write.
|
|
'result.snapshotGeneration = 9;',
|
|
'flags.refFrame = "x";',
|
|
].join('\n'),
|
|
],
|
|
]),
|
|
['snapshotGeneration', 'refFrame', 'saveScriptComplete'],
|
|
);
|
|
assert.deepEqual(
|
|
writes.map(({ field, line }) => `${line}:${field}`),
|
|
['1:snapshotGeneration', '2:refFrame', '3:saveScriptComplete'],
|
|
);
|
|
});
|
|
|
|
test('every SessionState field is classified exactly once', () => {
|
|
// Exhaustiveness is the point: without this, a new field with no direct write would satisfy
|
|
// R7 by being invisible to the scan, and the rule would silently stop covering part of the
|
|
// type it claims to cover.
|
|
const fields = sessionStateFields(
|
|
readFileSync(path.resolve(import.meta.dirname, '../../src/daemon/session-state.ts'), 'utf8'),
|
|
);
|
|
assert.deepEqual(fieldClassificationDrift(fields), []);
|
|
assert.equal(
|
|
Object.keys(SESSION_STATE_FIELD_OWNERS).length + STORE_OWNED_SESSION_STATE_FIELDS.size,
|
|
fields.length,
|
|
);
|
|
});
|
|
|
|
test('classification drift is reported in all three directions', () => {
|
|
const declared = sessionStateFields(
|
|
readFileSync(path.resolve(import.meta.dirname, '../../src/daemon/session-state.ts'), 'utf8'),
|
|
);
|
|
|
|
// Unclassified: a field added to SessionState and to neither table. This is the case the
|
|
// reviewer's finding was about — before parity, such a field passed the gate unnoticed.
|
|
assert.deepEqual(fieldClassificationDrift([...declared, 'brandNewField']), [
|
|
{ field: 'brandNewField', problem: 'unclassified' },
|
|
]);
|
|
|
|
// Stale: a table names a field SessionState no longer declares. Dropping one declared field
|
|
// makes exactly that name stale.
|
|
assert.deepEqual(fieldClassificationDrift(declared.filter((field) => field !== 'trace')), [
|
|
{ field: 'trace', problem: 'not-a-field' },
|
|
]);
|
|
|
|
// Contradictory: a field cannot be both store-established and owned by a writer. The real
|
|
// tables must never overlap, which is what makes the `both` branch unreachable in practice.
|
|
const inBoth = [...STORE_OWNED_SESSION_STATE_FIELDS].filter(
|
|
(field) => field in SESSION_STATE_FIELD_OWNERS,
|
|
);
|
|
assert.deepEqual(inBoth, [], 'the real tables must not overlap');
|
|
});
|
|
|
|
// R9 shipped its first revision with no test — every other rule here has one, and the only
|
|
// verification was a manual injection CI cannot repeat. These pin the three distinctions the rule
|
|
// depends on: which edge kinds count, and that an acyclic graph reports 1 rather than 0.
|
|
test('largestTypeCycleSize counts type-only cycles and ignores dynamic ones', () => {
|
|
// Acyclic: every component is a single file, so the largest is 1 (not 0).
|
|
const acyclic = resolveImportEdges(
|
|
new Map(
|
|
Object.entries({
|
|
'src/core/a.ts': "import type { B } from '@agent-device/contracts/b';",
|
|
'src/contracts/b.ts': 'export type B = 1;',
|
|
}),
|
|
),
|
|
);
|
|
assert.equal(largestTypeCycleSize(acyclic), 1);
|
|
|
|
// A three-file loop closed by type-only imports is exactly what R4 permits and R9 measures.
|
|
const typeCycle = resolveImportEdges(
|
|
new Map(
|
|
Object.entries({
|
|
'src/core/a.ts': "import type { B } from './b.ts';",
|
|
'src/core/b.ts': "import type { C } from './c.ts';\nexport type B = 1;",
|
|
'src/core/c.ts': "import type { A } from './a.ts';\nexport type C = 1;",
|
|
}),
|
|
),
|
|
);
|
|
assert.equal(largestTypeCycleSize(typeCycle), 3);
|
|
assert.deepEqual(largestTypeCycleMembers(typeCycle), [
|
|
'src/core/a.ts',
|
|
'src/core/b.ts',
|
|
'src/core/c.ts',
|
|
]);
|
|
|
|
// A loop closed through a DYNAMIC import is excluded on purpose: a lazy seam is not a
|
|
// comprehension barrier. With no non-dynamic edge at
|
|
// all no file enters the walk, so the floor here is 0 rather than 1 — specified, not incidental.
|
|
const dynamicCycle = resolveImportEdges(
|
|
new Map(
|
|
Object.entries({
|
|
'src/core/a.ts': "void import('./b.ts');",
|
|
'src/core/b.ts': "void import('./a.ts');",
|
|
}),
|
|
),
|
|
);
|
|
assert.equal(largestTypeCycleSize(dynamicCycle), 0);
|
|
assert.deepEqual(largestTypeCycleMembers(dynamicCycle), []);
|
|
|
|
// A value cycle counts too — R4 rejects it separately, so R9 must not be the thing that
|
|
// notices, but it must not under-report either.
|
|
const valueCycle = resolveImportEdges(
|
|
new Map(
|
|
Object.entries({
|
|
'src/core/a.ts': "export { b } from './b.ts';",
|
|
'src/core/b.ts': "export { a } from './a.ts';",
|
|
}),
|
|
),
|
|
);
|
|
assert.equal(largestTypeCycleSize(valueCycle), 2);
|
|
});
|