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>
441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
// Derived, fail-open check selector for `pnpm check:affected --base <ref>`.
|
|
//
|
|
// The model turns a set of changed paths into a plan of local checks with
|
|
// stable, machine-readable reasoning. It is intentionally source-of-truth
|
|
// derived rather than a hand-maintained path-to-check registry (issue #1181):
|
|
//
|
|
// - Vitest owns affected-test discovery through its native `related`
|
|
// command and static module graph; this model only decides when that
|
|
// existing tool applies;
|
|
// - the lint/typecheck/layering/fallow gates are always-on for their input
|
|
// categories, so they are never silently skipped (issue constraint);
|
|
// - a small explicit build-ownership layer covers Swift, Android helpers,
|
|
// the macOS helper, MCP metadata, and the public package surface — the
|
|
// only paths whose owning build the sources of truth cannot derive.
|
|
//
|
|
// Anything the model cannot confidently classify fails open to the full check
|
|
// set: unknown paths, workflow/tooling, the selector's own sources, and
|
|
// ambiguous files under an owned root that only resolve to `format` (e.g. a
|
|
// non-.ts fixture whose owning suite cannot be derived). Existing GitHub CI
|
|
// remains authoritative; this only optimizes local/agent feedback.
|
|
|
|
export type CheckId =
|
|
| 'format'
|
|
| 'lint'
|
|
| 'typecheck'
|
|
| 'test-app-typecheck'
|
|
| 'layering'
|
|
| 'fallow'
|
|
| 'mcp-metadata'
|
|
| 'build'
|
|
| 'vitest-related'
|
|
| 'unit'
|
|
| 'coverage'
|
|
| 'provider-integration'
|
|
| 'integration-node'
|
|
| 'integration-progress'
|
|
| 'swift-runner'
|
|
| 'android-helpers'
|
|
| 'macos-helper'
|
|
| 'web-smoke'
|
|
| 'replay-compat';
|
|
|
|
// The complete local check universe. A fail-open plan selects all of these;
|
|
// keep it in sync with the catalog in checks.ts (asserted by the self-test).
|
|
export const ALL_CHECKS: readonly CheckId[] = [
|
|
'format',
|
|
'lint',
|
|
'typecheck',
|
|
'test-app-typecheck',
|
|
'layering',
|
|
'fallow',
|
|
'mcp-metadata',
|
|
'build',
|
|
'vitest-related',
|
|
'unit',
|
|
'coverage',
|
|
'provider-integration',
|
|
'integration-node',
|
|
'integration-progress',
|
|
'swift-runner',
|
|
'android-helpers',
|
|
'macos-helper',
|
|
'web-smoke',
|
|
'replay-compat',
|
|
];
|
|
|
|
export type SelectionReason = {
|
|
check: CheckId;
|
|
path: string;
|
|
rule: string;
|
|
detail: string;
|
|
};
|
|
|
|
export type FailOpenReason = {
|
|
path: string;
|
|
rule: 'workflow-tooling' | 'selector-owning' | 'unknown-path' | 'ambiguous-path';
|
|
detail: string;
|
|
};
|
|
|
|
export type CheckPlan = {
|
|
failOpen: boolean;
|
|
checks: CheckId[];
|
|
reasons: SelectionReason[];
|
|
failOpenReasons: FailOpenReason[];
|
|
docsOnlyPaths: string[];
|
|
};
|
|
|
|
export type SelectInput = {
|
|
changedFiles: readonly string[];
|
|
// Public package entry source files, derived from package.json `exports`.
|
|
packageEntryFiles?: readonly string[];
|
|
};
|
|
|
|
// --- Path classification helpers -------------------------------------------
|
|
const ROOT_TOOLING = new Set([
|
|
'package.json',
|
|
'pnpm-lock.yaml',
|
|
'pnpm-workspace.yaml',
|
|
'tsconfig.json',
|
|
'tsconfig.lib.json',
|
|
'tsdown.config.ts',
|
|
'vitest.config.ts',
|
|
'.oxlintrc.json',
|
|
'.oxfmtrc.json',
|
|
'.npmrc',
|
|
]);
|
|
|
|
// Prose that specifies this selector's own behavior — the Testing Matrix these
|
|
// ownership rules mirror. It is docs by path, but editing it can invalidate the
|
|
// derivation below, and the selector cannot tell whether it did. Keep this in
|
|
// sync when the matrix moves; the docs short-circuit would otherwise treat it as
|
|
// inert Markdown.
|
|
const SELECTOR_OWNING_DOCS = new Set(['docs/agents/testing.md']);
|
|
|
|
function isSelectorOwning(file: string): boolean {
|
|
return (
|
|
SELECTOR_OWNING_DOCS.has(file) ||
|
|
(file.startsWith('scripts/check-affected/') && !file.endsWith('.md'))
|
|
);
|
|
}
|
|
|
|
function isWorkflowTooling(file: string): boolean {
|
|
// A workspace package manifest or tsconfig rewires module resolution for
|
|
// every consumer, so it fails open like root tooling does.
|
|
const packageTooling = /^packages\/[^/]+\/(?:package\.json|tsconfig\.json)$/.test(file);
|
|
return (
|
|
file.startsWith('.github/') ||
|
|
file.startsWith('scripts/') ||
|
|
packageTooling ||
|
|
ROOT_TOOLING.has(file)
|
|
);
|
|
}
|
|
|
|
function isDocs(file: string): boolean {
|
|
// skills/ Markdown is agent guidance prose with no owning suite (the
|
|
// SkillGym harness was removed), so it classifies as docs like the rest.
|
|
return (
|
|
file.startsWith('docs/') ||
|
|
file.startsWith('website/') ||
|
|
file === 'README.md' ||
|
|
file === 'LICENSE' ||
|
|
file.endsWith('.md')
|
|
);
|
|
}
|
|
|
|
function isTestPath(file: string): boolean {
|
|
return /\.test\.ts$/.test(file) || /(?:^|\/)__tests__\//.test(file);
|
|
}
|
|
|
|
// --- Ownership rules --------------------------------------------------------
|
|
// Each rule inspects one changed file and returns the reasons it contributes.
|
|
// Splitting the selection into small, independent rules keeps every function
|
|
// simple and makes the derivation self-documenting.
|
|
type FileFacts = {
|
|
file: string;
|
|
isTs: boolean;
|
|
underSrc: boolean;
|
|
underTest: boolean;
|
|
isSrcProd: boolean;
|
|
};
|
|
|
|
type OwnershipRule = (facts: FileFacts, input: SelectInput) => SelectionReason[];
|
|
|
|
function reason(check: CheckId, file: string, rule: string, detail: string): SelectionReason {
|
|
return { check, path: file, rule, detail };
|
|
}
|
|
|
|
const formatGate: OwnershipRule = ({ file, underSrc, underTest }) =>
|
|
underSrc || underTest
|
|
? [reason('format', file, 'gate:format', 'oxfmt covers src/ and test/')]
|
|
: [];
|
|
|
|
const staticTsGates: OwnershipRule = ({ file, isTs, underSrc, underTest }) =>
|
|
isTs && (underSrc || underTest)
|
|
? [
|
|
reason('lint', file, 'gate:lint', 'oxlint covers the source tree'),
|
|
reason('typecheck', file, 'gate:typecheck', 'tsc includes src/ and test/'),
|
|
reason('fallow', file, 'gate:fallow', 'fallow audits changed TypeScript for dead code'),
|
|
]
|
|
: [];
|
|
|
|
const srcProdGate: OwnershipRule = ({ file, isSrcProd }) => {
|
|
if (!isSrcProd) return [];
|
|
const selections = [
|
|
reason('layering', file, 'gate:layering', 'layering guard reads production src/ modules'),
|
|
reason('build', file, 'src-prod', 'production source is compiled by the build'),
|
|
];
|
|
if (file.startsWith('src/platforms/')) {
|
|
selections.push(
|
|
reason(
|
|
'provider-integration',
|
|
file,
|
|
'platform-src',
|
|
'platform source shapes device/provider wire behavior',
|
|
),
|
|
reason(
|
|
'coverage',
|
|
file,
|
|
'platform-src',
|
|
'Testing Matrix requires coverage for platform/device-response changes',
|
|
),
|
|
);
|
|
}
|
|
return selections;
|
|
};
|
|
|
|
function isNodeIntegrationPath(file: string): boolean {
|
|
return (
|
|
file.startsWith('test/integration/') &&
|
|
!file.slice('test/integration/'.length).includes('/') &&
|
|
file.endsWith('.ts')
|
|
);
|
|
}
|
|
|
|
const vitestRelatedOwnership: OwnershipRule = ({ file, isTs, underSrc, underTest }) =>
|
|
isTs && (underSrc || underTest) && !isNodeIntegrationPath(file)
|
|
? [
|
|
reason(
|
|
'vitest-related',
|
|
file,
|
|
'vitest:related',
|
|
'Vitest resolves affected tests through its static module graph',
|
|
),
|
|
]
|
|
: [];
|
|
|
|
// Workspace package source (#1490 W0): bundled into the published artifact,
|
|
// type-checked in the root graph, covered by Vitest's module graph, and
|
|
// guarded by layering R11. Fallow deliberately ignores packages/** (its
|
|
// resolver cannot follow workspace specifiers), so fallow is not selected.
|
|
const workspacePackageOwnership: OwnershipRule = ({ file, isTs }) => {
|
|
if (!isTs || !/^packages\/[^/]+\/src\//.test(file)) return [];
|
|
const selections = [
|
|
reason('format', file, 'gate:format', 'oxfmt covers packages/'),
|
|
reason('lint', file, 'gate:lint', 'oxlint covers packages/'),
|
|
reason('typecheck', file, 'gate:typecheck', 'tsc includes packages/'),
|
|
reason('layering', file, 'package-src', 'layering R11 guards workspace package boundaries'),
|
|
reason(
|
|
'vitest-related',
|
|
file,
|
|
'vitest:related',
|
|
'Vitest resolves affected tests through its static module graph',
|
|
),
|
|
];
|
|
if (!isTestPath(file)) {
|
|
selections.push(
|
|
reason('build', file, 'package-src', 'package source is bundled into the published artifact'),
|
|
);
|
|
}
|
|
return selections;
|
|
};
|
|
|
|
const nodeIntegrationOwnership: OwnershipRule = ({ file }) =>
|
|
isNodeIntegrationPath(file)
|
|
? [reason('integration-node', file, 'node-integration', 'node --test integration smoke')]
|
|
: [];
|
|
|
|
const testAppOwnership: OwnershipRule = ({ file }) => {
|
|
if (!file.startsWith('examples/test-app/')) return [];
|
|
if (!/\.(?:[cm]?[jt]sx?|json)$/.test(file)) return [];
|
|
return [
|
|
reason('format', file, 'gate:format', 'oxfmt covers the Expo test app'),
|
|
reason('lint', file, 'gate:lint', 'oxlint covers the Expo test app'),
|
|
reason(
|
|
'test-app-typecheck',
|
|
file,
|
|
'own:test-app',
|
|
'the Expo test app has an isolated TypeScript dependency graph',
|
|
),
|
|
];
|
|
};
|
|
|
|
// The frozen replay-compat corpus (#1417). `.ad` fixture data would otherwise
|
|
// fail open on its extension: its only consumer is the unit-lane corpus test.
|
|
// Any corpus change — script or manifest — also runs the history-backed
|
|
// provenance verifier, which is the only gate that can prove an entry's blob
|
|
// really came from the release tag it names.
|
|
const replayCompatOwnership: OwnershipRule = ({ file }) => {
|
|
if (!file.startsWith('test/replay-compat/')) return [];
|
|
const selections = [
|
|
reason(
|
|
'replay-compat',
|
|
file,
|
|
'own:replay-compat-provenance',
|
|
'corpus provenance is re-derived from released git blobs',
|
|
),
|
|
];
|
|
if (file.endsWith('.ad')) {
|
|
selections.push(
|
|
reason(
|
|
'unit',
|
|
file,
|
|
'own:replay-compat',
|
|
'frozen replay-compat corpus is asserted by the unit-lane corpus test',
|
|
),
|
|
);
|
|
}
|
|
return selections;
|
|
};
|
|
|
|
const BUILD_OWNERSHIP: ReadonlyArray<{
|
|
check: CheckId;
|
|
rule: string;
|
|
detail: string;
|
|
owns: (file: string) => boolean;
|
|
}> = [
|
|
{
|
|
check: 'swift-runner',
|
|
rule: 'own:swift',
|
|
detail: 'Swift runner sources require the XCUITest build',
|
|
owns: (file) => file.startsWith('apple/runner/') || file.endsWith('.swift'),
|
|
},
|
|
{
|
|
check: 'android-helpers',
|
|
rule: 'own:android-helpers',
|
|
detail: 'Android helper packages have their own build',
|
|
owns: (file) =>
|
|
file.startsWith('android/snapshot-helper/') || file.startsWith('android/ime-helper/'),
|
|
},
|
|
{
|
|
check: 'macos-helper',
|
|
rule: 'own:macos-helper',
|
|
detail: 'macOS helper is a separate Swift package build',
|
|
owns: (file) => file.startsWith('apple/macos-helper/'),
|
|
},
|
|
{
|
|
check: 'mcp-metadata',
|
|
rule: 'own:mcp',
|
|
detail: 'MCP registry metadata must stay in sync',
|
|
owns: (file) => file === 'server.json' || file === 'smithery.yaml',
|
|
},
|
|
];
|
|
|
|
const buildOwnership: OwnershipRule = ({ file }, input) => {
|
|
const selections = BUILD_OWNERSHIP.filter((entry) => entry.owns(file)).map((entry) =>
|
|
reason(entry.check, file, entry.rule, entry.detail),
|
|
);
|
|
if ((input.packageEntryFiles ?? []).includes(file)) {
|
|
selections.push(
|
|
reason('build', file, 'own:public-surface', 'public package entry affects declarations'),
|
|
);
|
|
}
|
|
return selections;
|
|
};
|
|
|
|
const OWNERSHIP_RULES: readonly OwnershipRule[] = [
|
|
formatGate,
|
|
staticTsGates,
|
|
srcProdGate,
|
|
vitestRelatedOwnership,
|
|
workspacePackageOwnership,
|
|
nodeIntegrationOwnership,
|
|
testAppOwnership,
|
|
replayCompatOwnership,
|
|
buildOwnership,
|
|
];
|
|
|
|
function fileFacts(file: string): FileFacts {
|
|
const isTs = file.endsWith('.ts') && !file.endsWith('.d.ts');
|
|
const underSrc = file.startsWith('src/');
|
|
return {
|
|
file,
|
|
isTs,
|
|
underSrc,
|
|
underTest: file.startsWith('test/'),
|
|
isSrcProd: underSrc && isTs && !isTestPath(file),
|
|
};
|
|
}
|
|
|
|
function failOpenFor(file: string): FailOpenReason | null {
|
|
if (isSelectorOwning(file)) {
|
|
return {
|
|
path: file,
|
|
rule: 'selector-owning',
|
|
detail: 'change to the affected-check selector cannot be trusted to select itself',
|
|
};
|
|
}
|
|
if (isWorkflowTooling(file)) {
|
|
return {
|
|
path: file,
|
|
rule: 'workflow-tooling',
|
|
detail: 'workflow/tooling change can alter any gate',
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- Selection --------------------------------------------------------------
|
|
export function selectChecks(input: SelectInput): CheckPlan {
|
|
const reasons: SelectionReason[] = [];
|
|
const failOpenReasons: FailOpenReason[] = [];
|
|
const docsOnlyPaths: string[] = [];
|
|
|
|
for (const file of input.changedFiles) {
|
|
const failOpen = failOpenFor(file);
|
|
if (failOpen) {
|
|
failOpenReasons.push(failOpen);
|
|
continue;
|
|
}
|
|
if (isDocs(file)) {
|
|
docsOnlyPaths.push(file);
|
|
continue;
|
|
}
|
|
const facts = fileFacts(file);
|
|
const selections = OWNERSHIP_RULES.flatMap((rule) => rule(facts, input));
|
|
if (selections.length === 0) {
|
|
failOpenReasons.push({
|
|
path: file,
|
|
rule: 'unknown-path',
|
|
detail: 'path has no derivable owner; run the full set to stay safe',
|
|
});
|
|
continue;
|
|
}
|
|
// `format` is an always-on gate, not evidence of test/build ownership. A
|
|
// file we can only route to formatting (e.g. a non-.ts fixture under
|
|
// test/) has no derivable suite owner, so treat it as ambiguous and fail
|
|
// open rather than silently narrowing to just `format`.
|
|
if (!selections.some((selection) => selection.check !== 'format')) {
|
|
failOpenReasons.push({
|
|
path: file,
|
|
rule: 'ambiguous-path',
|
|
detail: 'only formatting is derivable; no test/build owner, so run the full set',
|
|
});
|
|
continue;
|
|
}
|
|
reasons.push(...selections);
|
|
}
|
|
|
|
if (failOpenReasons.length > 0) {
|
|
return { failOpen: true, checks: [...ALL_CHECKS], reasons, failOpenReasons, docsOnlyPaths };
|
|
}
|
|
const selected = new Set(reasons.map((entry) => entry.check));
|
|
return {
|
|
failOpen: false,
|
|
checks: ALL_CHECKS.filter((check) => selected.has(check)),
|
|
reasons,
|
|
failOpenReasons,
|
|
docsOnlyPaths,
|
|
};
|
|
}
|