Files
callstack__agent-device/scripts/layering/model.test.ts
Michał Pierzchała 4f8dc3f31e refactor: move selector engine into workspace package (#1589)
* refactor: move selector engine into workspace package

* refactor(selectors): trim the package façade to its real consumers

Follow-up to the selector-package cutover, from a structural review of it.

- Drop 15 façade symbols with no consumer anywhere in the repo:
  selectorUsesKey (added by the cutover, never called), isNodeVisible /
  isNodeEditable (the real helpers are contracts/snapshot's), normalizeText,
  splitIsSelectorArgs, IS_PREDICATE_REQUIRED_MESSAGE, four nested Replay
  types, SelectorDisambiguationDisclosure, and the four kernel type
  re-exports every consumer already imports from kernel directly.
- Delete SelectorCapturePolicyInput.selectorExpression, which
  deriveSelectorCapturePolicy never read; the policy varies only by
  predicate, so it takes one now. Two of the four tests asserted that the
  unread parameter had no effect and could not fail; they go with it.
- Return the Maestro export vocabulary to the maestro package. The cutover
  inlined MAESTRO_TEXT/STATE_SELECTOR_KEYS' values into the CLI call site,
  leaving both constants dead in the package that owns the concept and no
  gate over the two copies. MAESTRO_SELECTOR_PROJECTION is now the one
  statement of it.
- Dedupe SelectorDiagnostics and SelectorDisambiguationDisclosure, declared
  character-for-character twice across the AST/string seam, and name the two
  shared option shapes once instead of five inline copies. The parser-side
  resolution types take an Ast prefix so the twins read as twins.
- Delete three identity wrappers: parsePrivateSelector,
  selectorExpressionToMaestro, and the formatSelectorFailure forwarder —
  nothing passes it a chain any more, so the SelectorChain | string union
  and its branch go too.
- Delete internal/index.ts, an AST barrel whose only consumer was one test
  in the same directory (renamed to engine.test.ts), and the match.ts
  pass-through that existed to feed it.
- ReplaySelectorGrammar had three variants for two behaviors; 'wait' and
  'ordinary' were the same path. It is 'is' | 'positional' now.
- Drop the deleted src/sdk/selectors.ts from .fallowrc.json's entry list.

Behavior unchanged. pnpm check green: 598 unit files / 5278 tests, smoke
35 passed / 3 live skipped, layering 71/71, depgraph 22/22, mutation config
45/45, fallow clean, package smoke sound. Counterfactual: pointing
MAESTRO_SELECTOR_PROJECTION.textKeys at the state keys turns three
replay-maestro-export cells red; restored before commit.

* test(selectors): split the engine aggregation test by source concept

`internal/index.test.ts` (renamed `engine.test.ts` when its barrel went away)
was a 708-line aggregation over the whole engine — past the 500-line tripwire
and mirroring no source module, so it also ran as one serial unit.

It becomes five files that each mirror what they test, plus the parser cells
folded into the existing parse test:

  resolve.test.ts                 alternative fallback, strict uniqueness,
                                  first-match existence
  resolve-disambiguation.test.ts  ADR 0012 ranking: deepest, smallest-area,
                                  winner-vs-challenger disclosure, tie fallback
  resolve-viewport.test.ts        the visibility half: on-screen beats
                                  off-screen, including inside an off-screen
                                  scroll container
  match.test.ts                   per-key matching semantics (text, role,
                                  focused, appname/windowtitle, decoded
                                  newline labels)
  arguments.test.ts               where the selector ends and the command's
                                  positionals begin, both grammars
  parse.test.ts                   +6 grammar/escape cells beside the existing
                                  property tests

The login-form tree shared by resolve.test.ts and match.test.ts moves to
`__tests__/login-form-nodes.ts` rather than being copied into both.

All 27 cells are carried over unchanged and still pass; no file now exceeds
224 lines. pnpm check green: 602 unit files / 5278 tests, layering 71/71,
depgraph 22/22, mutation config 45/45, fallow clean over 127 changed files.

* revert(selectors): keep agent-device/selectors public, behind one AST subpath

The cutover removed the `agent-device/selectors` public subpath as part of
tightening the API. It is in use, so the removal is reverted: the subpath ships
the same ten symbols v0.20.5 shipped, with the same signatures.

That has to coexist with the reason the package façade is string-only, so the
AST leaves through one named door instead of the main one:

  @agent-device/selectors        string-in/string-out; every in-repo consumer
  @agent-device/selectors/ast    the published parser surface; one consumer,
                                 src/sdk/selectors.ts

`packages/selectors/src/ast.ts` re-exports parseSelectorChain,
tryParseSelectorChain, isSelectorToken, the AST-taking findSelectorChainMatch
and resolveSelectorChain, isNodeVisible, isNodeEditable, and types
SelectorChain / SelectorDiagnostics. `formatSelectorFailure` keeps its
published `SelectorChain | string` first parameter as a shim here rather than
widening internal/resolve.ts back to a union — the compatibility obligation
sits at the boundary that owes it.

This is strictly narrower than main, where the AST was reachable from anywhere
in src/ via src/selectors/*. Two gates hold it there: facade-symbols.ts pins
./ast to exactly the v0.20.5 list, and package-boundaries.test.ts asserts
src/sdk/selectors.ts is the only file outside the package that imports it.

Restored alongside: the ./selectors export and tsdown entry/chunk group, the
.fallowrc.json entry, the package-exports supported-subpath list, and both
client-api.md sections. No CHANGELOG entry — nothing is removed any more.

pnpm check green: 602 unit files / 5278 tests, smoke 35 passed / 3 live
skipped, layering 71/71 (10 packages, 32 subpaths), depgraph 22/22, mutation
config 45/45, fallow clean over 129 changed files, package smoke imported all
12 published entry points with publint and attw passing. Verified functionally
against the built dist: the doc's parse -> findSelectorChainMatch example
returns the same shapes as before, resolveSelectorChain still returns an AST
`selector`, and formatSelectorFailure still accepts a chain.

* fix(selectors): correct the two expectations that still assume the removal

Review P1s on a792415a: restoring the public subpath left two gates asserting
it was gone.

- installed-package-metro.test.ts moved `agent-device/selectors` into the
  blocked-specifier list. It goes back to the subpath smoke set, running the
  same `isSelectorToken('||')` + `parseSelectorChain` check it ran before the
  removal, so the file's only remaining delta from main is a formatter reflow.
- owner-files-no-leak.test.ts asserted `dist/src/sdk-selectors.js` was absent.
  It requires the stable named chunk again, and still rejects an auto-numbered
  `selectors2.js` fallback — the pair is what proves the restored tsdown chunk
  group is doing its job, verified against a clean build.

PR body corrected: the removal is no longer described as intentional API
tightening.

* refactor(selectors): satisfy the widened fallow scope after rebase

main's #1591 (the follow-up filed from this review) removed `packages/**` from
.fallowrc.json's ignorePatterns, so the new package is audited for the first
time. Everything below is a finding fallow could not previously see.

Dead surface, all confirmed consumer-free:

- 12 type re-exports from the `.` façade whose shapes consumers only ever
  reach structurally.
- MAESTRO_TEXT_SELECTOR_KEYS / MAESTRO_STATE_SELECTOR_KEYS, orphaned by this
  branch's own MAESTRO_SELECTOR_PROJECTION change, and the test-util
  SELECTOR_VALUE_HAZARDS. All three are module-local now.
- IS_PREDICATE_USAGE_HINT fails --production because its only consumer is the
  is-argument-surface parity test. It gets a commented `ignoreExports` entry
  rather than deletion: the constant is what makes the daemon and CLI raise
  ONE hint instead of two copied strings (ADR 0010), so the test asserting
  that is the point, not an accident.

`fast-check` is now declared by the package that imports it.

Duplication, split by what could be proven:

- `isUsefulVisibilityAnchor` existed character-for-character in both
  packages/selectors and packages/maestro. Moved to
  @agent-device/contracts/snapshot, which both already depend on and which
  already owns this vocabulary. Safe because the `normalizeType` each copy
  called is itself character-identical to the contracts one — checked before
  moving, since a different normalizer would have silently changed which
  nodes anchor.
- maestro additionally reimplemented `normalizeType`, `buildSnapshotNodeMap`
  (as `buildSnapshotNodeByIndex`) and `findSnapshotAncestor`, all
  character-identical to contracts'. Deleted in favour of the shared ones.
- The three scroll-ancestor walks are NOT deduped. They are structurally the
  same walk but each uses a different scrollable predicate, and I have no
  evidence the three agree; collapsing them would be a Maestro-conformance
  change, not a cleanup. Both maestro sites now say so, and the work is filed
  separately.

`projectSelectorExpression` (15 cyclomatic / 22 cognitive, written by the
cutover) splits into a dispatcher plus `readAgreedTextValue` and
`projectSelectorTerms`; all three are under threshold.

Rebase note: the one conflict, in package-boundaries.test.ts, resolved to
NEITHER side — #1591 had already deleted `AdReplayVerifiedTargetGuard` as an
unused export, and this branch deletes the seven ReplaySelectorPort names, so
the conflicting block is empty.

* build: record fast-check for packages/selectors in the lockfile

Declaring the dependency in packages/selectors/package.json without
regenerating pnpm-lock.yaml made every CI job fail in its install step with
ERR_PNPM_OUTDATED_LOCKFILE. My local `pnpm install --frozen-lockfile` printed
"+ 1 dependencies were added: fast-check@^4.9.0" and exited 0, which read as
success but was the same mismatch CI refuses.

Regenerated with the pinned pnpm 11.17.0, not the 11.5.3 on this machine:
11.5.3 rewrites peer-dependency resolution keys repo-wide (dropping
`(supports-color@7.2.0)` suffixes) and produced a 222-line diff. With the
pinned version the diff is the 4 lines this change actually needs, plus
pnpm's alphabetical re-sort of the root selectors entry.
2026-08-04 19:06:39 +02:00

678 lines
27 KiB
TypeScript

import assert from 'node:assert/strict';
import { existsSync, readFileSync, statSync } 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 { uninstallableImports, zeroDepJobs } from './zero-dep-jobs.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('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/platforms/apple.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'],
'platforms -> core': ['src/platforms/apple.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;'],
['src/request/cancel.ts', "import '../commands/cancel.ts';"],
['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'],
'request -> commands': ['src/request/cancel.ts -> src/commands/cancel.ts'],
'selectors -> client': ['packages/selectors/src/internal/parse.ts -> src/client/client.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('utils'), 'ranked');
// 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('snapshot'), '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.
assert.deepEqual(unclassifiedZones(listSourceFiles()), []);
// 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/command-catalog.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;',
' };',
' refFrameState?: RefFrameState;',
'};',
'',
'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', 'refFrameState']);
});
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.refFrameState = '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.refFrameState = 'expired';"],
// a runner session outside the daemon is a different type that happens to share a name
['src/platforms/apple/runner-session.ts', 'session.refFrameState = 1;'],
// a local that is not a declared SessionState field
['src/daemon/audio-probe.ts', 'session.somethingElse = 1;'],
// reads and comparisons are not writes
['src/daemon/handlers/find.ts', "if (session.refFrameState === 'active') return;"],
// a write into a sub-object is not a write to the field itself
['src/daemon/handlers/session-open.ts', 'session.refFrameState.inner = 1;'],
// a different binding that happens to have a matching property
['src/daemon/handlers/session-close.ts', "other.refFrameState = 'expired';"],
]),
['refFrameState', 'snapshotGeneration'],
);
assert.deepEqual(
writes.map(({ file, field }) => `${file}:${field}`),
['src/daemon/ref-frame.ts:refFrameState', '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.refFrameState = 1;',
'session.refFrameState ??= 1;',
'session.refFrameState ||= 1;',
'session.refFrameState &&= 1;',
'session.refFrameState += 1;',
'session.refFrameState -= 1;',
'session.refFrameState++;',
'--session.refFrameState;',
'session\n .refFrameState = 1;',
];
for (const form of forms) {
const writes = findSessionStateWrites(new Map([['src/daemon/probe.ts', form]]), [
'refFrameState',
]);
assert.deepEqual(
writes.map(({ field }) => field),
['refFrameState'],
`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[`refFrameState`] = 2;']]),
['refFrameState'],
);
// `[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`);
}
});
// R8: the zero-dep CI job contract. These tests use synthetic workflows and a synthetic tree,
// because the point of the rule is to catch a shape that does not exist in the repo yet.
const ZERO_DEP_WORKFLOW = `
name: CI
jobs:
installs-deps:
steps:
- uses: ./.github/actions/setup-node-pnpm
- run: node scripts/needs-packages/entry.ts
zero-dep:
steps:
- uses: ./.github/actions/setup-node-pnpm
with:
install-deps: false
- run: |
node --experimental-strip-types --test scripts/probe/entry.test.ts
node --experimental-strip-types scripts/probe/entry.ts
`;
test('a zero-dep job is discovered from the workflow, and a dep-installing one is not', () => {
const present = new Set(['scripts/probe/entry.ts', 'scripts/probe/entry.test.ts']);
const jobs = zeroDepJobs(new Map([['.github/workflows/probe.yml', ZERO_DEP_WORKFLOW]]), (file) =>
present.has(file),
);
assert.deepEqual(jobs, [
{
workflow: '.github/workflows/probe.yml',
job: 'zero-dep',
// Sorted, deduplicated, and filtered to paths that exist — `scripts/needs-packages`
// belongs to the job that installs deps and must not leak in.
entries: ['scripts/probe/entry.test.ts', 'scripts/probe/entry.ts'],
},
]);
});
test('install-deps: false counts whether YAML parsed it as a boolean or a string', () => {
const quoted = ZERO_DEP_WORKFLOW.replace('install-deps: false', "install-deps: 'false'");
const jobs = zeroDepJobs(new Map([['w.yml', quoted]]), () => true);
assert.deepEqual(
jobs.map(({ job }) => job),
['zero-dep'],
);
});
test('a job with no recognizable entry script is reported rather than exempted', () => {
// Fail-closed: `entries: []` is what check.ts turns into a violation, so a job that
// invokes its script in some way the scan cannot read never escapes the rule silently.
const jobs = zeroDepJobs(new Map([['w.yml', ZERO_DEP_WORKFLOW]]), () => false);
assert.deepEqual(jobs, [{ workflow: 'w.yml', job: 'zero-dep', entries: [] }]);
});
test('a bare pnpm script name resolves through package.json to its entry scripts', () => {
// The workflow names no path at all — only the pnpm script name package.json maps to the
// real command. A zero-dep job may call its script this way (#1462) without R8 losing the
// entries it needs to check: the resolution reads the same paths out of the mapped command.
const workflow = `
name: CI
jobs:
zero-dep:
steps:
- uses: ./.github/actions/setup-node-pnpm
with:
install-deps: false
- run: pnpm check:affected:test
`;
const present = new Set([
'scripts/check-affected/model.test.ts',
'scripts/check-affected/run.test.ts',
]);
const packageScripts = new Map([
[
'check:affected:test',
'node --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts',
],
]);
const jobs = zeroDepJobs(
new Map([['w.yml', workflow]]),
(file) => present.has(file),
packageScripts,
);
assert.deepEqual(jobs, [
{
workflow: 'w.yml',
job: 'zero-dep',
entries: ['scripts/check-affected/model.test.ts', 'scripts/check-affected/run.test.ts'],
},
]);
});
test('a resolved script that itself runs a named script is expanded too', () => {
// A chained alias (`outer` runs `pnpm inner`) is one hop further from the workflow text
// than the direct case above. If resolution stopped at one level, inner's entry would be
// invisible to R8 even though the job genuinely depends on it at runtime.
const workflow = `
name: CI
jobs:
zero-dep:
steps:
- uses: ./.github/actions/setup-node-pnpm
with:
install-deps: false
- run: pnpm outer
`;
const present = new Set(['scripts/outer/entry.ts', 'scripts/inner/entry.ts']);
const packageScripts = new Map([
['outer', 'node scripts/outer/entry.ts && pnpm inner'],
['inner', 'node scripts/inner/entry.ts'],
]);
const jobs = zeroDepJobs(
new Map([['w.yml', workflow]]),
(file) => present.has(file),
packageScripts,
);
assert.deepEqual(jobs, [
{
workflow: 'w.yml',
job: 'zero-dep',
entries: ['scripts/inner/entry.ts', 'scripts/outer/entry.ts'],
},
]);
});
test('an alias cycle does not hang, and still collects every non-cyclic entry', () => {
// `a` runs `pnpm b`, `b` runs `pnpm a` back — resolution must stop re-expanding a name it
// has already walked on this chain, not recurse until the stack overflows. Each script's
// own direct entry is still found before the cycle closes.
const workflow = `
name: CI
jobs:
zero-dep:
steps:
- uses: ./.github/actions/setup-node-pnpm
with:
install-deps: false
- run: pnpm a
`;
const present = new Set(['scripts/a/entry.ts', 'scripts/b/entry.ts']);
const packageScripts = new Map([
['a', 'node scripts/a/entry.ts && pnpm b'],
['b', 'node scripts/b/entry.ts && pnpm a'],
]);
const jobs = zeroDepJobs(
new Map([['w.yml', workflow]]),
(file) => present.has(file),
packageScripts,
);
assert.deepEqual(jobs, [
{
workflow: 'w.yml',
job: 'zero-dep',
entries: ['scripts/a/entry.ts', 'scripts/b/entry.ts'],
},
]);
});
test('a pnpm word that names no real package.json script resolves to nothing', () => {
// `pnpm install` (or any other non-script pnpm subcommand) must not be treated as a script
// name just because it follows `pnpm` — it is absent from packageScripts, same as a shell
// word that merely looks like a path is absent from the tree.
const workflow = `
name: CI
jobs:
zero-dep:
steps:
- uses: ./.github/actions/setup-node-pnpm
with:
install-deps: false
- run: pnpm install --frozen-lockfile
`;
const jobs = zeroDepJobs(
new Map([['w.yml', workflow]]),
() => true,
new Map([['check:affected:test', 'node scripts/check-affected/run.test.ts']]),
);
assert.deepEqual(jobs, [{ workflow: 'w.yml', job: 'zero-dep', entries: [] }]);
});
test('a package import anywhere in a zero-dep closure is rejected, builtins are not', () => {
const tree = new Map([
[
'scripts/probe/entry.ts',
"import fs from 'node:fs';\nimport path from 'path';\nimport { helper } from './helper.ts';\n",
],
// One hop deeper than the entry: the failure that motivated R8 was exactly this shape —
// the entry script itself imported nothing external, its helper did.
['scripts/probe/helper.ts', "import { parseSync } from 'oxc-parser';\nimport './deep.js';\n"],
['scripts/probe/deep.ts', "const lazy = await import('yaml');\n"],
]);
const found = uninstallableImports(
{ workflow: 'w.yml', job: 'zero-dep', entries: ['scripts/probe/entry.ts'] },
(file) => tree.get(file) ?? null,
(file) => tree.has(file),
);
assert.deepEqual(
found.map(({ file, spec }) => `${file}:${spec}`),
// `node:fs` and bare `path` are builtins; `./helper.ts` and `./deep.js` resolve into the
// tree (including the .js -> .ts rewrite); a dynamic package import fails just the same.
['scripts/probe/deep.ts:yaml', 'scripts/probe/helper.ts:oxc-parser'],
);
});
test('an import written inside a string is not a package import', () => {
// A zero-dep job runs test files, and a test about imports naturally embeds import syntax as
// a fixture string. R8 parses instead of scanning lines precisely so those stay invisible —
// this file itself contains such fixtures, and reported two phantom violations before the
// switch. A type-only package import, by contrast, is still a resolve at runtime under
// --experimental-strip-types only because the type is erased; it is listed to prove the
// parser sees it, since erasure is a compiler detail and not something to lean on.
const tree = new Map([
[
'scripts/probe/entry.ts',
[
'const fixture = "import real from \'not-a-package\'";',
"const also = ['export { x } from \\'nope\\''];",
"import type { T } from 'is-a-package';",
'export type Alias = T;',
].join('\n'),
],
]);
const found = uninstallableImports(
{ workflow: 'w.yml', job: 'zero-dep', entries: ['scripts/probe/entry.ts'] },
(file) => tree.get(file) ?? null,
(file) => tree.has(file),
);
assert.deepEqual(
found.map(({ spec, line }) => `${line}:${spec}`),
['3:is-a-package'],
);
});
test("the repo's own zero-dep jobs resolve without node_modules", () => {
const repoRoot = path.resolve(import.meta.dirname, '../..');
const read = (file: string): string | null => {
const absolute = path.join(repoRoot, file);
return existsSync(absolute) && statSync(absolute).isFile()
? readFileSync(absolute, 'utf8')
: null;
};
const exists = (file: string): boolean => read(file) !== null;
const packageJson = JSON.parse(read('package.json')!) as { scripts?: Record<string, string> };
const packageScripts = new Map(Object.entries(packageJson.scripts ?? {}));
const jobs = zeroDepJobs(
new Map([['.github/workflows/ci.yml', read('.github/workflows/ci.yml')!]]),
exists,
packageScripts,
);
// #1490 W0 removed the last zero-dep job: affected-selector's entry closure
// reaches `@agent-device/kernel` workspace specifiers through src/utils, and
// the R8 relative-import exception is unsafe for production src files (Node's
// ESM loader does not realpath, so a file loaded both relatively and via its
// package specifier would instantiate twice in one process — duplicate
// AppError, broken instanceof). Pin the empty set deliberately: a NEW
// zero-dep job re-engages R8 automatically and must update this expectation.
assert.deepEqual(
jobs.map((job) => job.job),
[],
'zero-dep jobs changed: verify the new job satisfies R8 and update this pin',
);
for (const job of jobs) {
assert.ok(job.entries.length > 0, `${job.job} must name an entry script`);
assert.deepEqual(
uninstallableImports(job, read, exists),
[],
`${job.job} must reach no package`,
);
}
});
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.refFrameState = "active";',
'completedSession.saveScriptComplete = true;',
// Not a session binding, and not a session write.
'result.snapshotGeneration = 9;',
'flags.refFrameState = "x";',
].join('\n'),
],
]),
['snapshotGeneration', 'refFrameState', 'saveScriptComplete'],
);
assert.deepEqual(
writes.map(({ field, line }) => `${line}:${field}`),
['1:snapshotGeneration', '2:refFrameState', '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/types.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/types.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, and R3 relies on dynamic imports existing. 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);
});