Files
callstack__agent-device/scripts/mutation/envelope.test.ts
Michał Pierzchała 76453add71 refactor: pnpm workspace + @agent-device/kernel pilot (#1490 W0) (#1494)
* refactor: pnpm workspace + @agent-device/kernel pilot (#1490 W0)

Extend the workspace with packages/* and move the kernel behind an
enforced public API: packages/kernel with nine consumer-earned subpath
exports (errors, device, snapshot, contracts, collections, rect,
redaction, daemon-error, bounds — the last absorbed from utils as Rect
vocabulary). Every kernel import repo-wide becomes the
@agent-device/kernel/<sub> specifier; kernel tests move to
src/__tests__/kernel/ and exercise the package surface. The root
declares the package in devDependencies (workspace:*), tsdown bundles
it (noExternal) so the published artifact and its runtime dependency
manifest are unchanged.

Gate rewiring in the same change, per the W0 brief:
- R1 kernel-sink retires (physically subsumed); new R11
  package-boundaries guards no-root-back-imports, relative tunnelling
  past exports maps, undeclared workspace deps, and non-exported
  subpaths, with runtime resolution pins via import.meta.resolve.
- resolveImportEdges and mutation ownership follow workspace
  specifiers through exports maps, keeping R4 cycle checks, depgraph,
  and derived test ownership connected across the seam (kernel-errors
  still owns 495 tests). listSourceFiles includes packages/*/src.
- kernel becomes an unranked zone; mutation registry, stryker mutate
  globs, and the mutation-affected workflow path filter move to
  packages/kernel/src/errors.ts.
- check:affected gains packages/ ownership (manifests fail open);
  vitest and coverage include packages/*/src; fallow ignores
  packages/** (its resolver cannot follow workspace specifiers).
- The affected-selector CI job installs dependencies: its closure now
  crosses workspace specifiers, and the R8 relative exception is
  unsafe for production src files (Node ESM does not realpath, so dual
  specifier/relative loads would instantiate modules twice). The R8
  zero-dep set is pinned empty with that rationale.

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

* fix: address W0 review — mutation sandbox, exports-map resolution, tsc -b

Review findings on #1494, all five:

1. contracts-schema-public.test.ts reads the kernel source at its
   packages/ path (fs access invisible to the codemod and typecheck).
2. Mutation lane: Stryker sandboxes the tree but pnpm's node_modules
   symlink resolves @agent-device/* back to the real repo, so mutants
   in the sandbox never load and vitest.related finds no tests.
   vitest.mutation.config.ts now aliases each EXPORTED specifier to
   its source (derived from exports maps, never a wildcard), keeping
   resolution inside the mutated tree. Validated: kernel-errors module
   runs end to end (dry run 3,984 tests, mutants killed, exit 0).
3. Layering/depgraph resolve workspace specifiers through the
   exports-derived map (workspaceSpecifierTargets) instead of
   reconstructing paths, so '.'-facade packages resolve; the
   positional fallback remains only for map-less fixtures (P0 pin).
4. Per-package project references implemented: packages/kernel is
   composite (emitDeclarationOnly -> dist-types, gitignored), the root
   references it, and typecheck becomes tsc -b — probed to catch type
   errors on both sides under TypeScript 7 native.
5. R11's relative-route exception now requires membership in an actual
   R8 zero-dep job closure (zeroDepClosureFiles walks entries), not
   mere scripts/ placement — closing the dual-instantiation bypass.

Also from review discussion: daemon-error moves out of the kernel
package to src/client/ — its consumers (cli, client facade) rehydrate
wire DaemonErrors client-side; the daemon only produces them. Kernel
drops to 8 exported subpaths before any of them ship.

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

* refactor: one exports-map reader for mutation alias and ownership

Fallow flagged workspaceExportAliases (cognitive 15, CRAP 90). The
manifest-reading logic already exists as workspaceSpecifierTargets in
scripts/layering/package-boundaries.ts, so both the Stryker sandbox
alias table and the mutation ownership walker now consume it instead
of carrying near-clones. Behavior unchanged; mutation suite 45/45 and
changed-code fallow green.

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

* fix: composite kernel without a root references edge

FreeRange runs plain `tsc -p tsconfig.json`, and a root `references`
entry makes non-build-mode TypeScript demand the referenced project's
built declarations (TS6305) — a standing "build first" tax on every
plain -p consumer (fr, editors). Keep the per-package composite
project and build it in typecheck (`tsc -b packages/kernel` before the
root and examples/sdk passes), but drop the root references edge: root
consumption resolves through exports to source, identical to runtime
and to the bundler. Probed: plain -p green with no prebuilt output;
kernel-side type errors still caught by its own build.

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

* fix: R11 uses the layering parser; mutation config is a fallow entry

Review blockers on #1494:

- R11's private single-quote regex could miss a double-quoted or
  re-export route into packages/*/src. specifierSites now delegates to
  the layering model's parseImports (both quote styles, side-effect
  imports, re-exports, dynamic imports), with direct regressions for
  each formerly-invisible form.
- vitest.mutation.config.ts becomes a declared fallow entry instead of
  a tolerated unused-file finding: the full-repo audit now reports it
  reachable (unused files 2 -> 1; the remainder predates this PR).

FreeRange clean-checkout evidence: with packages/kernel/dist-types and
every *.tsbuildinfo deleted, `pnpm check:freerange` reports 0 findings
on this head — the TS6305 topology died with the root references edge
in the previous commit; check:freerange has no build precondition.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-30 12:12:46 +02:00

281 lines
11 KiB
TypeScript

// The envelope is the only thing a downloaded scheduled-lane artifact can be
// interpreted from months later (#1430), so its required fields are asserted
// rather than assumed: a lane that stops emitting one of them makes freshness
// and tool-drift monitoring silently useless.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import { runCmdSync } from '../../src/utils/exec.ts';
import { laneEnvelope, LANE_ENVELOPE_SCHEMA_VERSION } from '../lib/lane-envelope.ts';
const repoRoot = path.resolve(import.meta.dirname, '../..');
function workflow(name: string): string {
return fs.readFileSync(path.join(repoRoot, '.github/workflows', name), 'utf8');
}
test('the envelope carries schema, commit, tool/config provenance, duration and result', () => {
const envelope = laneEnvelope({
lane: 'mutation-decision-kernels',
commit: 'a'.repeat(40),
tool: { stryker: '9.6.1' },
configHash: 'sha256:abcdef123456',
startedAtMs: 1_000,
now: 61_000,
result: 'pass',
data: { scope: 'full-sweep' },
});
assert.equal(envelope.schemaVersion, LANE_ENVELOPE_SCHEMA_VERSION);
assert.equal(envelope.lane, 'mutation-decision-kernels');
assert.equal(envelope.commit, 'a'.repeat(40));
assert.deepEqual(envelope.tool, { stryker: '9.6.1' });
assert.equal(envelope.configHash, 'sha256:abcdef123456');
// Mutation input is enumerated, not randomized: `null` is an explicit
// "not applicable", not a forgotten field.
assert.equal(envelope.seed, null);
assert.equal(envelope.durationMs, 60_000);
assert.equal(envelope.finishedAt, '1970-01-01T00:01:01.000Z');
assert.equal(envelope.result, 'pass');
assert.deepEqual(envelope.data, { scope: 'full-sweep' });
});
test('a failed ratchet is recorded as a failed lane run', () => {
const envelope = laneEnvelope({
lane: 'mutation-decision-kernels',
commit: 'b'.repeat(40),
tool: { stryker: '9.6.1' },
configHash: 'sha256:abcdef123456',
startedAtMs: 0,
now: 0,
result: 'fail',
data: {},
});
assert.equal(envelope.result, 'fail');
assert.equal(envelope.durationMs, 0);
});
// A lane that crashes before it can measure anything is the dark-lane case: an
// absent envelope is indistinguishable from a lane that never ran, so the run
// script must emit one from its failure path too.
test('a crashed run still writes an envelope naming the stage it died in', () => {
const envelopePath = path.join(repoRoot, '.tmp/mutation/lane-envelope.json');
fs.rmSync(envelopePath, { force: true });
const result = runCmdSync(
'node',
[
'--experimental-strip-types',
'scripts/mutation/run.ts',
'--report',
'.tmp/mutation/absent-report.json',
'--modules',
'kernel-errors',
],
{ cwd: repoRoot, allowFailure: true },
);
assert.notEqual(result.exitCode, 0, 'a missing report must fail the run');
assert.ok(fs.existsSync(envelopePath), 'no envelope written for a crashed run');
const envelope = JSON.parse(fs.readFileSync(envelopePath, 'utf8')) as {
result: string;
tool: Record<string, string>;
configHash: string;
data: { stage: string; error: string | null };
};
assert.equal(envelope.result, 'fail');
assert.equal(envelope.data.stage, 'report');
assert.match(envelope.data.error ?? '', /absent-report\.json/);
// Provenance is read before the work, so a crash still reports its tool/config.
assert.ok(envelope.tool.stryker);
assert.match(envelope.configHash, /^sha256:/);
});
// Shard artifacts carry the envelope next to the report, so merging "every JSON
// under the shard directory" fed the envelope to the report parser and crashed
// the ratchet job after the mutants had already run.
test('merging shard reports ignores the envelope sitting beside them', () => {
const shards = path.join(repoRoot, '.tmp/mutation/envelope-test-shards/shard-a');
fs.mkdirSync(shards, { recursive: true });
fs.writeFileSync(
path.join(shards, 'mutation.json'),
JSON.stringify({
files: {
'packages/kernel/src/errors.ts': {
mutants: [{ status: 'Killed' }, { status: 'Survived' }],
},
},
}),
);
fs.writeFileSync(
path.join(shards, 'lane-envelope.json'),
JSON.stringify(
laneEnvelope({
lane: 'mutation-decision-kernels',
commit: 'c'.repeat(40),
tool: { stryker: '9.6.1' },
configHash: 'sha256:abcdef123456',
startedAtMs: 0,
now: 0,
result: 'pass',
data: {},
}),
),
);
const result = runCmdSync(
'node',
[
'--experimental-strip-types',
'scripts/mutation/run.ts',
'--report-dir',
'.tmp/mutation/envelope-test-shards',
'--modules',
'kernel-errors',
],
{ cwd: repoRoot, allowFailure: true },
);
assert.match(result.stdout, /merging 1 shard report\(s\)/);
assert.match(result.stdout, /kernel-errors/);
assert.doesNotMatch(result.stderr, /Cannot convert undefined or null to object/);
fs.rmSync(path.join(repoRoot, '.tmp/mutation/envelope-test-shards'), {
recursive: true,
force: true,
});
});
function runMutation(args: readonly string[]): {
exitCode: number;
stdout: string;
stderr: string;
} {
const result = runCmdSync(
'node',
['--experimental-strip-types', 'scripts/mutation/run.ts', ...args],
{
cwd: repoRoot,
allowFailure: true,
},
);
return { exitCode: result.exitCode ?? 1, stdout: result.stdout, stderr: result.stderr };
}
type Envelope = {
result: string;
data: { stage: string; error: string | null; modules: readonly { id: string }[] };
};
function readEnvelope(): Envelope {
return JSON.parse(
fs.readFileSync(path.join(repoRoot, '.tmp/mutation/lane-envelope.json'), 'utf8'),
) as Envelope;
}
// A merged shard set is only a sweep if every requested module actually reported.
// summarizeReport scores an absent module as 0, and while the lane is non-gating a
// 0 only *reports* a regression — so a dead matrix shard would otherwise be
// aggregated into a passing "complete" envelope claiming the sweep happened.
test('an incomplete shard set fails instead of scoring the missing module as zero', () => {
const shards = path.join(repoRoot, '.tmp/mutation/partial-shards/shard-kernel-errors');
fs.mkdirSync(shards, { recursive: true });
fs.writeFileSync(
path.join(shards, 'mutation.json'),
JSON.stringify({
files: { 'packages/kernel/src/errors.ts': { mutants: [{ status: 'Killed' }] } },
}),
);
const result = runMutation([
'--report-dir',
'.tmp/mutation/partial-shards',
'--modules',
'kernel-errors,daemon-ref-frame',
]);
assert.notEqual(result.exitCode, 0, 'a missing shard must fail the aggregate');
assert.match(result.stderr, /Incomplete shard set/);
assert.match(result.stderr, /daemon-ref-frame/);
const envelope = readEnvelope();
assert.equal(envelope.result, 'fail');
assert.equal(envelope.data.stage, 'ratchet');
fs.rmSync(path.join(repoRoot, '.tmp/mutation/partial-shards'), { recursive: true, force: true });
});
// Argument parsing and the provenance/baseline reads used to sit outside the
// envelope boundary, so the lane could exit without declaring itself at all.
test('a malformed invocation still writes an envelope', () => {
fs.rmSync(path.join(repoRoot, '.tmp/mutation/lane-envelope.json'), { force: true });
const result = runMutation(['--modules', 'not-a-kernel']);
assert.notEqual(result.exitCode, 0);
const envelope = readEnvelope();
assert.equal(envelope.result, 'fail');
assert.equal(envelope.data.stage, 'setup');
assert.match(envelope.data.error ?? '', /Unknown mutation module/);
});
// The weekly self-test and the affected-selection job run before any mutant, so
// their failure has to be declared by the lane rather than only by the job log.
test('--fail-envelope declares a step that failed before the sweep', () => {
fs.rmSync(path.join(repoRoot, '.tmp/mutation/lane-envelope.json'), { force: true });
const result = runMutation(['--fail-envelope', 'self-test failed']);
assert.notEqual(result.exitCode, 0, 'a pre-run failure must not report success');
const envelope = readEnvelope();
assert.equal(envelope.result, 'fail');
assert.equal(envelope.data.stage, 'setup');
assert.equal(envelope.data.error, 'self-test failed');
});
// The workflows run it from `if: failure()`, which also fires when the ratchet
// itself failed — a generic reason must never displace the specific one.
test('--fail-envelope keeps a failure the run already reported', () => {
fs.rmSync(path.join(repoRoot, '.tmp/mutation/lane-envelope.json'), { force: true });
runMutation(['--fail-envelope', 'the real failure']);
runMutation(['--fail-envelope', 'a later generic failure']);
assert.equal(readEnvelope().data.error, 'the real failure');
});
// A pass is not a verdict worth preserving: the weekly job copies the proposed
// baseline and restores the committed one *after* the ratchet passed, so a failure
// there would otherwise publish the failed scheduled job as a passing lane.
test('--fail-envelope downgrades a passing envelope when a later step fails', () => {
const shards = path.join(repoRoot, '.tmp/mutation/pass-then-fail/shard-kernel-errors');
fs.mkdirSync(shards, { recursive: true });
// A perfect shard so the ratchet passes: the score can only rise from the
// committed kernel-errors baseline.
fs.writeFileSync(
path.join(shards, 'mutation.json'),
JSON.stringify({
files: {
'packages/kernel/src/errors.ts': { mutants: [{ status: 'Killed' }, { status: 'Killed' }] },
},
}),
);
const passing = runMutation([
'--report-dir',
'.tmp/mutation/pass-then-fail',
'--modules',
'kernel-errors',
]);
assert.equal(passing.exitCode, 0, passing.stderr);
assert.equal(readEnvelope().result, 'pass');
runMutation(['--fail-envelope', 'the baseline copy step failed']);
const envelope = readEnvelope();
assert.equal(envelope.result, 'fail', 'a failed job must not publish a passing envelope');
assert.equal(envelope.data.error, 'the baseline copy step failed');
fs.rmSync(path.join(repoRoot, '.tmp/mutation/pass-then-fail'), { recursive: true, force: true });
});
test('both mutation lanes record an envelope for failures before the sweep', () => {
for (const name of ['mutation-weekly.yml', 'mutation-affected.yml']) {
assert.match(workflow(name), /--fail-envelope/, `${name} can fail without an envelope`);
}
});
test('both mutation lanes publish the envelope', () => {
for (const name of ['mutation-weekly.yml', 'mutation-affected.yml']) {
assert.match(
workflow(name),
/\.tmp\/mutation\/lane-envelope\.json/,
`${name} does not upload the lane envelope`,
);
}
assert.match(workflow('mutation-weekly.yml'), /Lane envelope/);
});