mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
76453add71
* 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>
216 lines
9.2 KiB
TypeScript
216 lines
9.2 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'vitest';
|
|
import { CASES } from '../help-conformance-cases.mjs';
|
|
import type { CapturedSample } from '../help-conformance-sample-outputs.mjs';
|
|
import * as samples from '../help-conformance-sample-outputs.mjs';
|
|
import { SAMPLE_PRODUCERS } from './help-conformance-sample-producers.ts';
|
|
import {
|
|
defaultHintForCode,
|
|
KNOWN_APP_ERROR_CODES,
|
|
retriableForErrorCode,
|
|
} from '@agent-device/kernel/errors';
|
|
|
|
// "What enumerates N" for the error surface. The benchmark's recovery quizzes
|
|
// (a pinned rendered error plus "what command runs next?") are keyed to the
|
|
// error registry itself, so the two error-side policies cannot drift from the
|
|
// cases silently:
|
|
// - a code retriableForErrorCode classifies is one an agent is told to retry,
|
|
// so the bench must show whether a model actually recovers from it;
|
|
// - a code with no specific defaultHintForCode entry falls back to generic
|
|
// "retry with --debug" guidance, which is a decision, not a default.
|
|
// Uncovered codes need a named waiver below, and a waiver fails once it goes
|
|
// stale — the same shape as the help-topic gate.
|
|
|
|
/** Recovery-quiz waivers, keyed by error code: why the code is unbenchmarked. */
|
|
const WAIVED_RECOVERY_QUIZ_CODES: Record<string, string> = {};
|
|
|
|
/**
|
|
* Codes that intentionally resolve the generic fallback hint instead of a
|
|
* code-specific one, keyed by code with the reason the generic text is right.
|
|
*/
|
|
const WAIVED_GENERIC_HINT_CODES: Record<string, string> = {
|
|
COMMAND_FAILED:
|
|
'Catch-all wrap of an underlying failure: the specific "what failed" lives in the message, so the generic diagnostics guidance IS its recovery step.',
|
|
};
|
|
|
|
// Human error rendering (src/utils/output.ts printHumanError) opens with
|
|
// `Error (CODE): message`, so any rendered error text names its own code.
|
|
const RENDERED_ERROR_CODE = /(?:^|\n)Error \(([A-Z_]+)\):/g;
|
|
|
|
type RecoveryCase = {
|
|
id: string;
|
|
task: string;
|
|
expectations?: string[];
|
|
matchers?: { id: string; pattern: RegExp }[];
|
|
forbidden?: { id: string; pattern: RegExp }[];
|
|
recovery: { code: string; sample: CapturedSample };
|
|
};
|
|
|
|
function renderedErrorCodes(text: string): string[] {
|
|
return [...`\n${text}`.matchAll(RENDERED_ERROR_CODE)].map((match) => match[1]!);
|
|
}
|
|
|
|
/** The cases that declare themselves recovery quizzes (see the cases module). */
|
|
const RECOVERY_CASES: RecoveryCase[] = CASES.filter(
|
|
(testCase: { recovery?: unknown }): testCase is RecoveryCase => testCase.recovery !== undefined,
|
|
);
|
|
|
|
const QUIZZED_CODES = new Set(RECOVERY_CASES.map((testCase) => testCase.recovery.code));
|
|
|
|
test('every code with a retriability verdict has a recovery quiz or an explicit waiver', () => {
|
|
const classified = KNOWN_APP_ERROR_CODES.filter(
|
|
(code) => retriableForErrorCode(code) !== undefined,
|
|
);
|
|
assert.ok(classified.length > 0, 'retriableForErrorCode classifies at least one code');
|
|
const uncovered = classified.filter(
|
|
(code) => !QUIZZED_CODES.has(code) && !(code in WAIVED_RECOVERY_QUIZ_CODES),
|
|
);
|
|
assert.deepEqual(
|
|
uncovered,
|
|
[],
|
|
'a code retriableForErrorCode classifies needs a recovery-quiz case in scripts/help-conformance-cases.mjs (declaring recovery: { code, sample }) or a waiver above',
|
|
);
|
|
});
|
|
|
|
test('retriability cannot be claimed for a code outside the enumeration', () => {
|
|
// Otherwise a new retriable code could be classified in production and still
|
|
// escape the coverage gate above by never entering KNOWN_APP_ERROR_CODES.
|
|
for (const unenumerated of ['RUNNER_BUSY', 'DEVICE_IN_USE_SOON', 'device_in_use', '']) {
|
|
assert.equal(
|
|
retriableForErrorCode(unenumerated),
|
|
undefined,
|
|
`"${unenumerated}" is not in KNOWN_APP_ERROR_CODES, so it must not carry a retriability verdict — add it to the enumeration (and quiz it) instead`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('recovery-quiz waivers only name real, classified, unquizzed codes', () => {
|
|
const known = new Set<string>(KNOWN_APP_ERROR_CODES);
|
|
for (const [code, reason] of Object.entries(WAIVED_RECOVERY_QUIZ_CODES)) {
|
|
assert.ok(known.has(code), `waived code "${code}" no longer exists — remove the waiver`);
|
|
assert.ok(
|
|
retriableForErrorCode(code) !== undefined,
|
|
`waived code "${code}" is no longer classified by retriableForErrorCode — remove the waiver`,
|
|
);
|
|
assert.ok(
|
|
!QUIZZED_CODES.has(code),
|
|
`waived code "${code}" now has a recovery quiz — remove the waiver`,
|
|
);
|
|
assert.ok(reason.trim().length > 0, `waiver for "${code}" needs a reason`);
|
|
}
|
|
});
|
|
|
|
test('every recovery case quizzes a real code through the pinned sample that renders it', () => {
|
|
const known = new Set<string>(KNOWN_APP_ERROR_CODES);
|
|
const exported = new Map<CapturedSample, string>(
|
|
Object.entries(samples)
|
|
.filter((entry): entry is [string, CapturedSample] => typeof entry[1] === 'object')
|
|
.map(([name, sample]) => [sample, name]),
|
|
);
|
|
for (const testCase of RECOVERY_CASES) {
|
|
const { code, sample } = testCase.recovery;
|
|
assert.ok(
|
|
known.has(code),
|
|
`case "${testCase.id}" claims recovery from "${code}", which is not in KNOWN_APP_ERROR_CODES — a renamed or invented code`,
|
|
);
|
|
assert.ok(
|
|
exported.has(sample),
|
|
`case "${testCase.id}" must quote a sample exported from scripts/help-conformance-sample-outputs.mjs, never an inline literal`,
|
|
);
|
|
assert.deepEqual(
|
|
renderedErrorCodes(sample.output),
|
|
[code],
|
|
`case "${testCase.id}" declares recovery from "${code}" but its sample renders a different error`,
|
|
);
|
|
assert.equal(
|
|
testCase.task.split(samples.sampleText(sample)).length,
|
|
2,
|
|
`case "${testCase.id}" must quote ${exported.get(sample)} verbatim, exactly once`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('a recovery case scores the recovery command, not just plan validity', () => {
|
|
for (const testCase of RECOVERY_CASES) {
|
|
assert.ok(
|
|
testCase.expectations?.includes('validPlanCommands'),
|
|
`case "${testCase.id}" must expect validPlanCommands`,
|
|
);
|
|
// Without these, "recovery" would be satisfied by any parseable plan — the
|
|
// point of an error quiz is which next command the error's hint implies,
|
|
// and which retry the error rules out.
|
|
assert.ok(
|
|
(testCase.matchers ?? []).length > 0,
|
|
`recovery case "${testCase.id}" needs at least one matcher asserting the recovery command`,
|
|
);
|
|
assert.ok(
|
|
(testCase.forbidden ?? []).length > 0,
|
|
`recovery case "${testCase.id}" needs at least one forbidden pattern for the failure mode this error rules out`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('no case quotes rendered error text outside a declared pinned sample', () => {
|
|
for (const testCase of CASES as {
|
|
id: string;
|
|
task: string;
|
|
recovery?: RecoveryCase['recovery'];
|
|
}[]) {
|
|
const pinned = testCase.recovery ? samples.sampleText(testCase.recovery.sample) : undefined;
|
|
const outsidePinned = pinned ? testCase.task.split(pinned).join('\n') : testCase.task;
|
|
assert.deepEqual(
|
|
renderedErrorCodes(outsidePinned),
|
|
[],
|
|
`case "${testCase.id}" quotes rendered error text that is not part of its declared pinned sample — quoted output must come from scripts/help-conformance-sample-outputs.mjs, never hand-transcribed`,
|
|
);
|
|
assert.doesNotMatch(
|
|
outsidePinned,
|
|
/(?:^|\n)Hint: /,
|
|
`case "${testCase.id}" quotes a rendered error hint outside its declared pinned sample`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('every captured sample is rebuilt through a real producer', () => {
|
|
const pinned = new Set(SAMPLE_PRODUCERS.map((producer) => producer.sample));
|
|
for (const [name, sample] of Object.entries(samples)) {
|
|
if (typeof sample !== 'object') continue;
|
|
assert.ok(
|
|
pinned.has(sample),
|
|
`sample ${name} has no entry in help-conformance-sample-producers.ts — captured output must be rebuilt through the real producer, never trusted as a transcription`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('every error code resolves a hint, specific or explicitly waived as generic', () => {
|
|
// An unlisted code falls through defaultHintForCode's default branch, so the
|
|
// fallback text identifies codes with no entry of their own.
|
|
const genericHint = defaultHintForCode('NOT_A_REAL_ERROR_CODE');
|
|
assert.ok(genericHint && genericHint.length > 0, 'the fallback hint must not be empty');
|
|
const unspecific: string[] = [];
|
|
for (const code of KNOWN_APP_ERROR_CODES) {
|
|
const hint = defaultHintForCode(code);
|
|
assert.ok(hint && hint.trim().length > 0, `code "${code}" resolves an empty hint`);
|
|
if (hint === genericHint && !(code in WAIVED_GENERIC_HINT_CODES)) unspecific.push(code);
|
|
}
|
|
assert.deepEqual(
|
|
unspecific,
|
|
[],
|
|
'a new error code needs its own defaultHintForCode entry (errors say how to recover) or a generic-hint waiver above',
|
|
);
|
|
});
|
|
|
|
test('generic-hint waivers only name real codes that still resolve the generic hint', () => {
|
|
const known = new Set<string>(KNOWN_APP_ERROR_CODES);
|
|
const genericHint = defaultHintForCode('NOT_A_REAL_ERROR_CODE');
|
|
for (const [code, reason] of Object.entries(WAIVED_GENERIC_HINT_CODES)) {
|
|
assert.ok(known.has(code), `waived code "${code}" no longer exists — remove the waiver`);
|
|
assert.equal(
|
|
defaultHintForCode(code),
|
|
genericHint,
|
|
`waived code "${code}" now has a code-specific hint — remove the waiver`,
|
|
);
|
|
assert.ok(reason.trim().length > 0, `waiver for "${code}" needs a reason`);
|
|
}
|
|
});
|