Files
callstack__agent-device/scripts/mutation/test-scope.ts
Michał Pierzchała e832325e87 refactor(substrate): split host mechanics into @agent-device/host-kit capability ports (#2088)
* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1)

The shared src/utils closure that blocked the platform-family moves lands
on declared owners: generic host mechanics form a new private
@agent-device/host-kit package between kernel and capture-kit, and
capture-kit keeps capture, snapshot, and recording behavior, depending on
host-kit for the mechanics it needs. tar-stream and yauzl move with the
archive code.

Every seam's exported subpaths are pinned in package-boundaries.test.ts,
the layering model ranks the new zone, R13's allow-list names it, and each
seam carries an exact eager-closure row. ADR-0019's substrate amendment
describes the layout.

Tests that mocked two of the moved modules separately became duplicate
same-seam vi.mock factories, where the second silently replaced the first;
those are merged, and the mocks that production code reaches past are
pinned at their injection points instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* refactor(host-kit): one narrow capability port per export

The four technical barrels (exec/fs/values/request) grouped by category
rather than by capability, so a consumer needing one mechanic evaluated
unrelated ones. Each export is now a single capability over the host
machine: command, process, diagnostics, retry, archive, file, request,
version. A port re-exports only what a consumer of that capability uses,
and every port carries its own eager-closure row.

Most of the old values barrel was never host mechanics. Pure record
readers, config-source values, result text, memoization, async scoping,
coordinate validation, and device-scope parsing touch no process, file, or
environment, so they join kernel's other primitives instead.

Closures fall accordingly: capture-kit's png-worker-client from 20 to 10,
png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from
386 to 380.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* chore: drop the migration inventories and trim the touched comments

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* docs: trim the touched host-kit and mutation-lane comments

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* docs: keep tool directives only in the touched files

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* docs: keep tool directives only across the touched tree

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* fix: point the Swift parity comment at the real TS twin and test

The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts,
which does not exist: the module went to capture-kit while isTapPointInsideViewport itself
went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at
the pre-move path. Both now resolve.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* fix: repoint comment citations at the homes this refactor moved them to

The W1 move left ~20 comment citations pointing at src/utils/*.ts and
src/request/*.ts paths that no longer exist. Each now names the capability
port that owns the symbol, which survives further file moves:

  exec -> host-kit/command          host-process, owner-identity -> host-kit/process
  diagnostics -> host-kit/diagnostics   atomic-file, process-lock -> host-kit/file
  retry -> host-kit/retry           request progress/cancel -> host-kit/request
  version -> host-kit/version       ttl-memo, source-value, parsing, device-isolation,
                                    keyed-lock, success-text -> kernel subpaths

Comment-only; no closure, budget, or behavior change. ADR citations are left
as written, being dated records of the decision rather than live references.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-28 07:46:48 +02:00

155 lines
6.6 KiB
TypeScript

// Which tests the mutation lane runs, derived from Vitest's module graph.
//
// Stryker replays the configured suite for every mutant, so the suite it is
// pointed at decides whether the weekly sweep fits its 30-minute budget. Pointing
// it at the whole unit suite (487 files) makes the initial dry run alone cost
// minutes; hand-listing per-kernel test files would be a second source of truth
// that silently rots. So the scope is derived the same way `pnpm check:affected`
// derives affected tests: `vitest related` over the mutated files, i.e. Vitest's
// own static module graph.
//
// Two files are removed from whatever Vitest returns:
// - the real-subprocess-spawn tests (SUBPROCESS_STUB_TESTS in vitest.config.ts —
// spawns stubbed binaries and waits real subprocess/retry/poll time, out of scope
// by the issue's constraint, and thousands of mutant runs would turn it into
// timeout noise regardless of whether Vitest itself still serializes it, #1823);
// - tests that cannot run in the thread pool Stryker's vitest runner forces:
// the in-process CLI-capture tests (`process.chdir` throws in a worker
// thread) and the `node:worker_threads` PNG pipeline tests (a worker inside
// a worker raises uncaught MessagePort errors that kill the runner);
// - anything outside `src/` or a workspace package's `src/`: the unit suite
// also hosts the help-conformance gates from `scripts/__tests__`, which
// assert over the repo's own registries rather than any decision kernel
// and own their CI job.
//
// Nothing here inflates the score: a mutant only an excluded test could kill
// shows up as a survivor — visible work, never a silent pass.
import fs from 'node:fs';
import path from 'node:path';
import { runCmdSync } from '@agent-device/host-kit/command';
import { readWorkspacePackages } from '../layering/package-boundaries.ts';
import { walkFiles } from '../lib/walk-files.ts';
import { isKernelTestFile, normalizePath } from './modules.ts';
/** Env var carrying the resolved scope file to `vitest.mutation.config.ts`. */
export const TEST_SCOPE_ENV = 'AGENT_DEVICE_MUTATION_TEST_FILES';
const CLI_CAPTURE_HARNESS = 'src/__tests__/cli-capture.ts';
/**
* Every directory the mutation lane can find a test file under: root `src/`
* plus each workspace package's `src/` (`unit-core`'s own `include` list in
* `vitest.config.ts`). A kernel living in `packages/*` — `kernel-errors`
* reaches its tests only indirectly, but `target-annotation-serde` is tested
* directly from inside its package — must not lose its owning tests to a
* root-only walk.
*/
function testFileRoots(repoRoot: string): string[] {
return [
path.join(repoRoot, 'src'),
...readWorkspacePackages(repoRoot).map((pkg) => path.join(repoRoot, pkg.dir, 'src')),
];
}
/** Source modules that own a `node:worker_threads` worker. */
function workerThreadModules(repoRoot: string): string[] {
return testFileRoots(repoRoot)
.flatMap((root) =>
walkFiles(root, (file) => file.endsWith('.ts') && !file.endsWith('.test.ts')),
)
.filter((file) => fs.readFileSync(file, 'utf8').includes('node:worker_threads'))
.map((file) => path.basename(file, '.ts'));
}
/**
* Repo-relative test files that cannot survive Stryker's thread pool, derived
* from what they import rather than listed: the chdir-using CLI capture harness
* and any module that itself starts a worker thread.
*/
export function threadHostileTestFiles(repoRoot: string): string[] {
const modules = [path.basename(CLI_CAPTURE_HARNESS, '.ts'), ...workerThreadModules(repoRoot)];
const importsHostileModule = new RegExp(`from '[^']*/(${modules.join('|')})(\\.ts)?'`);
return testFileRoots(repoRoot)
.flatMap((root) => walkFiles(root, (file) => file.endsWith('.test.ts')))
.filter((file) => importsHostileModule.test(fs.readFileSync(file, 'utf8')))
.map((file) => normalizePath(path.relative(repoRoot, file)))
.sort();
}
/**
* Concrete source files behind a module's mutate globs. Stryker's `!`-prefixed
* exclusions are applied here too — `fs.globSync` has no notion of them, and
* dropping them would feed selector *tests* into the scope derivation.
*/
export function expandMutateFiles(globs: readonly string[], repoRoot: string): string[] {
const negated = globs.filter((glob) => glob.startsWith('!')).map((glob) => glob.slice(1));
const excluded = new Set(fs.globSync(negated, { cwd: repoRoot }).map(normalizePath));
return fs
.globSync(
globs.filter((glob) => !glob.startsWith('!')),
{ cwd: repoRoot },
)
.map(normalizePath)
.filter((file) => !excluded.has(file))
.sort();
}
type VitestJsonReport = { testResults?: readonly { name: string }[] };
/**
* Test files Vitest considers related to `sourceFiles`, minus the groups this
* lane cannot run. `vitest related` executes them once (seconds), which also
* proves the scope is green before Stryker's dry run depends on it.
*/
export function relatedTestFiles(
sourceFiles: readonly string[],
repoRoot: string,
excluded: readonly string[] = threadHostileTestFiles(repoRoot),
): string[] {
const reportFile = path.join(repoRoot, '.tmp/mutation/related-tests.json');
fs.mkdirSync(path.dirname(reportFile), { recursive: true });
fs.rmSync(reportFile, { force: true });
runCmdSync(
'pnpm',
[
'exec',
'vitest',
'related',
...sourceFiles,
'--project',
'unit-core',
'--run',
'--reporter=json',
`--outputFile=${reportFile}`,
],
{ cwd: repoRoot, allowFailure: true },
);
if (!fs.existsSync(reportFile)) {
throw new Error(
`vitest related produced no report at ${reportFile} — cannot derive the mutation test scope.`,
);
}
const report = JSON.parse(fs.readFileSync(reportFile, 'utf8')) as VitestJsonReport;
const excludedSet = new Set(excluded);
return [
...new Set(
(report.testResults ?? [])
.map((result) => normalizePath(path.relative(repoRoot, result.name)))
.filter((file) => isKernelTestFile(file) && !excludedSet.has(file)),
),
].sort();
}
/** Read the scope Stryker was handed, or `undefined` for "whole unit suite". */
export function readTestScope(): string[] | undefined {
const file = process.env[TEST_SCOPE_ENV];
if (!file || !fs.existsSync(file)) return undefined;
const files = JSON.parse(fs.readFileSync(file, 'utf8')) as string[];
return files.length > 0 ? files : undefined;
}
export function writeTestScope(files: readonly string[], file: string): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(files, null, 2)}\n`);
}