From bc53e38037037113a46589ea492e92ee4d11d971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 10:11:14 +0000 Subject: [PATCH 1/8] feat: add derived fail-open check:affected selector Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 19 ++ package.json | 2 + scripts/check-affected/checks.ts | 193 ++++++++++++++++ scripts/check-affected/model.test.ts | 197 ++++++++++++++++ scripts/check-affected/model.ts | 330 +++++++++++++++++++++++++++ scripts/check-affected/run.ts | 193 ++++++++++++++++ 6 files changed, 934 insertions(+) create mode 100644 scripts/check-affected/checks.ts create mode 100644 scripts/check-affected/model.test.ts create mode 100644 scripts/check-affected/model.ts create mode 100644 scripts/check-affected/run.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52d9747b6b..f4664341ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,25 @@ jobs: node --experimental-strip-types --test scripts/layering/model.test.ts node --experimental-strip-types scripts/layering/check.ts --base "$LAYERING_BASE" + affected-selector: + name: Affected-check Selector + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + with: + install-deps: false + + # The selector is fail-open and advisory (GitHub CI stays authoritative), + # so the gate only guards the derivation model. Invoked directly with no + # deps, mirroring the layering guard. + - name: Check affected-selector model + run: node --experimental-strip-types --test scripts/check-affected/model.test.ts + packaged-cli-node-22-12: name: Packaged CLI Node 22.12 runs-on: ubuntu-latest diff --git a/package.json b/package.json index cd6ca93e58..ab4a38f254 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,8 @@ "fallow:all": "fallow --summary", "fallow:baseline": "(fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary || true) && (fallow health --save-baseline fallow-baselines/health.json --summary || true)", "check:fallow": "fallow audit", + "check:affected": "node --experimental-strip-types scripts/check-affected/run.ts", + "check:affected:test": "node --experimental-strip-types --test scripts/check-affected/model.test.ts", "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts && node --experimental-strip-types scripts/layering/check.ts", "check:layering:baseline": "node --experimental-strip-types scripts/layering/check.ts --update-baseline", "check:quick": "pnpm lint && pnpm typecheck", diff --git a/scripts/check-affected/checks.ts b/scripts/check-affected/checks.ts new file mode 100644 index 0000000000..93245cf62f --- /dev/null +++ b/scripts/check-affected/checks.ts @@ -0,0 +1,193 @@ +// Catalog for the check-affected selector: how each derived CheckId maps to a +// runnable command and the authoritative GitHub CI job(s) it mirrors. +// +// Commands are resolved from real package.json scripts (or a plain Vitest +// project invocation) so this file stays a thin projection over existing +// aggregate checks rather than a second source of truth for how to run them. + +import { ALL_CHECKS, type CheckId } from './model.ts'; + +export type CheckKind = + | { readonly type: 'script'; readonly script: string } + | { readonly type: 'vitest-project'; readonly project: string }; + +export type CheckSpec = { + readonly id: CheckId; + readonly label: string; + readonly kind: CheckKind; + readonly ciJobs: readonly string[]; + // Whether `--run` should attempt the check locally. Device/emulator lanes and + // network/toolchain-gated lanes stay authoritative on GitHub CI. + readonly localRunnable: boolean; +}; + +export const CHECK_CATALOG: readonly CheckSpec[] = [ + { + id: 'format', + label: 'Formatting (oxfmt)', + kind: { type: 'script', script: 'format:check' }, + ciJobs: ['Lint & Format'], + localRunnable: true, + }, + { + id: 'lint', + label: 'Lint (oxlint)', + kind: { type: 'script', script: 'lint' }, + ciJobs: ['Lint & Format'], + localRunnable: true, + }, + { + id: 'typecheck', + label: 'Typecheck (tsc)', + kind: { type: 'script', script: 'typecheck' }, + ciJobs: ['Typecheck'], + localRunnable: true, + }, + { + id: 'layering', + label: 'Import-direction layering guard', + kind: { type: 'script', script: 'check:layering' }, + ciJobs: ['Layering Guard'], + localRunnable: true, + }, + { + id: 'fallow', + label: 'Fallow code-quality audit', + kind: { type: 'script', script: 'check:fallow' }, + ciJobs: ['Fallow Code Quality'], + localRunnable: true, + }, + { + id: 'mcp-metadata', + label: 'MCP registry metadata sync', + kind: { type: 'script', script: 'check:mcp-metadata' }, + ciJobs: ['Typecheck'], + localRunnable: true, + }, + { + id: 'build', + label: 'Build (tsdown + declarations)', + kind: { type: 'script', script: 'build' }, + ciJobs: ['Packaged CLI Node 22.12'], + localRunnable: true, + }, + { + id: 'unit', + label: 'Unit suite', + kind: { type: 'script', script: 'test:unit' }, + ciJobs: ['Coverage'], + localRunnable: true, + }, + { + id: 'output-economy', + label: 'Output-economy suite', + kind: { type: 'script', script: 'test:output-economy' }, + ciJobs: ['Coverage'], + localRunnable: true, + }, + { + id: 'provider-integration', + label: 'Provider-backed integration suite', + kind: { type: 'script', script: 'test:integration:provider' }, + ciJobs: ['Integration Tests', 'Coverage'], + localRunnable: true, + }, + { + id: 'interaction-contract', + label: 'Interaction-contract suite', + kind: { type: 'vitest-project', project: 'interaction-contract' }, + ciJobs: ['Coverage'], + localRunnable: true, + }, + { + id: 'integration-node', + label: 'Node integration smoke', + kind: { type: 'script', script: 'test:integration:node' }, + ciJobs: ['Integration Tests'], + localRunnable: true, + }, + { + id: 'integration-progress', + label: 'Integration architecture-progress gate', + kind: { type: 'script', script: 'test:integration:progress:check' }, + ciJobs: ['Integration Tests'], + localRunnable: true, + }, + { + id: 'swift-runner', + label: 'Swift runner build', + kind: { type: 'script', script: 'build:xcuitest' }, + ciJobs: ['Swift Runner Unit Compile', 'iOS / Smoke Tests', 'macOS / Smoke Tests'], + localRunnable: false, + }, + { + id: 'android-helpers', + label: 'Android helper builds', + kind: { type: 'script', script: 'build:android-snapshot-helper' }, + ciJobs: ['Android / Smoke Tests'], + localRunnable: false, + }, + { + id: 'macos-helper', + label: 'macOS helper build', + kind: { type: 'script', script: 'build:macos-helper' }, + ciJobs: ['macOS / Smoke Tests'], + localRunnable: false, + }, + { + id: 'web-smoke', + label: 'Live web platform smoke', + kind: { type: 'script', script: 'test:smoke:web' }, + ciJobs: ['Web Platform Smoke'], + localRunnable: false, + }, + { + id: 'skillgym', + label: 'SkillGym command-planning suite', + kind: { type: 'script', script: 'test:skillgym' }, + ciJobs: ['SkillGym'], + localRunnable: false, + }, +]; + +export function getCheckSpec(id: CheckId): CheckSpec { + const spec = CHECK_CATALOG.find((entry) => entry.id === id); + if (!spec) throw new Error(`No catalog entry for check "${id}".`); + return spec; +} + +// Resolve the runnable command for a check. Script-backed checks are validated +// against package.json so a renamed/removed script fails loudly instead of +// silently skipping a gate. `fallow` threads the same --base the audit uses. +export function resolveCommand( + spec: CheckSpec, + scripts: Readonly>, + base: string, +): string[] { + if (spec.kind.type === 'vitest-project') { + return ['pnpm', 'exec', 'vitest', 'run', '--project', spec.kind.project]; + } + const { script } = spec.kind; + if (!(script in scripts)) { + throw new Error( + `Check "${spec.id}" references package.json script "${script}", which does not exist.`, + ); + } + const command = ['pnpm', 'run', script]; + if (spec.id === 'fallow') command.push('--base', base); + return command; +} + +// Guard: the catalog must cover exactly the CheckId universe. The self-test +// asserts this so a new check cannot ship half-wired. +export function assertCatalogComplete(): void { + const catalogIds = new Set(CHECK_CATALOG.map((entry) => entry.id)); + const missing = ALL_CHECKS.filter((id) => !catalogIds.has(id)); + const extra = CHECK_CATALOG.filter((entry) => !ALL_CHECKS.includes(entry.id)).map((e) => e.id); + if (missing.length > 0 || extra.length > 0) { + throw new Error( + `Check catalog out of sync with ALL_CHECKS. Missing: [${missing.join(', ')}]; ` + + `extra: [${extra.join(', ')}].`, + ); + } +} diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts new file mode 100644 index 0000000000..0e93d7d350 --- /dev/null +++ b/scripts/check-affected/model.test.ts @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { assertCatalogComplete, CHECK_CATALOG, resolveCommand } from './checks.ts'; +import { + ALL_CHECKS, + globToRegExp, + selectChecks, + type CheckId, + type SelectInput, + type VitestProject, +} from './model.ts'; + +// Mirrors the real vitest.config.ts projects so tests exercise the same +// include/exclude ownership the runner loads at runtime. +const VITEST_PROJECTS: VitestProject[] = [ + { + name: 'unit-core', + include: ['src/**/*.test.ts', 'scripts/__tests__/help-conformance-bench.test.ts'], + exclude: [ + 'src/platforms/android/__tests__/{app-lifecycle-install,app-lifecycle-open,device-input-state,input-actions,notifications,settings}.test.ts', + ], + }, + { + name: 'android-adb', + include: [ + 'src/platforms/android/__tests__/{app-lifecycle-install,app-lifecycle-open,device-input-state,input-actions,notifications,settings}.test.ts', + ], + }, + { name: 'provider-integration', include: ['test/integration/provider-scenarios/**/*.test.ts'] }, + { name: 'interaction-contract', include: ['test/integration/interaction-contract/**/*.test.ts'] }, + { name: 'output-economy', include: ['test/output-economy/**/*.test.ts'] }, +]; + +function plan(changedFiles: string[], extra: Partial = {}) { + return selectChecks({ + changedFiles, + vitestProjects: VITEST_PROJECTS, + packageEntryFiles: ['src/index.ts', 'src/selectors.ts'], + ...extra, + }); +} + +function ids(changedFiles: string[]): CheckId[] { + return plan(changedFiles).checks; +} + +test('glob matcher handles **, *, ?, and brace groups like the vitest config', () => { + assert.ok(globToRegExp('src/**/*.test.ts').test('src/a/b/c.test.ts')); + assert.ok(globToRegExp('src/**/*.test.ts').test('src/a.test.ts')); + assert.ok(!globToRegExp('src/**/*.test.ts').test('src/a.ts')); + assert.ok(globToRegExp('test/output-economy/**/*.test.ts').test('test/output-economy/x.test.ts')); + assert.ok( + globToRegExp('src/platforms/android/__tests__/{notifications,settings}.test.ts').test( + 'src/platforms/android/__tests__/settings.test.ts', + ), + ); + assert.ok(!globToRegExp('a/*.ts').test('a/b/c.ts')); +}); + +test('production source change selects gates + build + unit, with reasons', () => { + const result = plan(['src/daemon/selectors.ts']); + assert.equal(result.failOpen, false); + for (const id of ['format', 'lint', 'typecheck', 'layering', 'fallow', 'build', 'unit'] as const) { + assert.ok(result.checks.includes(id), `expected ${id}`); + } + assert.ok(!result.checks.includes('provider-integration')); + // Every selected check documents why it was chosen. + for (const id of result.checks) { + assert.ok(result.reasons.some((reason) => reason.check === id)); + } +}); + +test('platform source additionally selects provider-integration', () => { + const result = ids(['src/platforms/apple/core/apps.ts']); + assert.ok(result.includes('provider-integration')); + assert.ok(result.includes('unit')); +}); + +test('unit test file selects only the unit suite (+ gates), not integration projects', () => { + const result = ids(['src/daemon/selectors.test.ts']); + assert.ok(result.includes('unit')); + assert.ok(!result.includes('provider-integration')); + assert.ok(!result.includes('output-economy')); +}); + +test('vitest project ownership routes each integration test to its project', () => { + assert.ok( + ids(['test/integration/provider-scenarios/foo.test.ts']).includes('provider-integration'), + ); + assert.ok( + ids(['test/integration/interaction-contract/bar.test.ts']).includes('interaction-contract'), + ); + assert.ok(ids(['test/output-economy/baz.test.ts']).includes('output-economy')); +}); + +test('android-adb stub test routes to the unit suite via its own project', () => { + const result = ids(['src/platforms/android/__tests__/notifications.test.ts']); + assert.ok(result.includes('unit')); +}); + +test('Swift runner change selects the swift-runner build', () => { + assert.deepEqual(ids(['apple-runner/Sources/Runner/Main.swift']), ['swift-runner']); + assert.ok(ids(['src/platforms/apple/core/runner/Support.swift']).includes('swift-runner')); +}); + +test('Android helper change selects the android-helpers build', () => { + assert.deepEqual(ids(['android-snapshot-helper/src/Main.kt']), ['android-helpers']); + assert.deepEqual(ids(['android-multitouch-helper/build.gradle']), ['android-helpers']); +}); + +test('MCP metadata change selects the mcp-metadata check', () => { + assert.deepEqual(ids(['server.json']), ['mcp-metadata']); +}); + +test('public package surface change selects the build via exports', () => { + const result = ids(['src/index.ts']); + assert.ok(result.includes('build')); +}); + +test('docs-only change selects no checks and records the docs paths', () => { + const result = plan(['docs/adr/0011.md', 'README.md', 'website/page.mdx.md']); + assert.equal(result.failOpen, false); + assert.deepEqual(result.checks, []); + assert.equal(result.docsOnlyPaths.length, 3); +}); + +test('unknown path fails open to the full check set', () => { + const result = plan(['examples/test-app/App.tsx']); + assert.equal(result.failOpen, true); + assert.deepEqual(result.checks, [...ALL_CHECKS]); + assert.equal(result.failOpenReasons[0]?.rule, 'unknown-path'); +}); + +test('workflow/tooling and selector-owning changes fail open', () => { + assert.equal(plan(['.github/workflows/ci.yml']).failOpenReasons[0]?.rule, 'workflow-tooling'); + assert.equal(plan(['package.json']).failOpenReasons[0]?.rule, 'workflow-tooling'); + assert.equal(plan(['vitest.config.ts']).failOpenReasons[0]?.rule, 'workflow-tooling'); + assert.equal( + plan(['scripts/check-affected/model.ts']).failOpenReasons[0]?.rule, + 'selector-owning', + ); +}); + +test('a fail-open path in a mixed changeset forces the full set', () => { + const result = plan(['src/daemon/selectors.ts', 'bin/agent-device.mjs']); + assert.equal(result.failOpen, true); + assert.deepEqual(result.checks, [...ALL_CHECKS]); +}); + +test('empty changeset selects nothing', () => { + const result = plan([]); + assert.equal(result.failOpen, false); + assert.deepEqual(result.checks, []); +}); + +test('catalog covers exactly the CheckId universe', () => { + assert.doesNotThrow(assertCatalogComplete); +}); + +test('every catalog command resolves against package scripts', () => { + const scripts: Record = { + 'format:check': 'x', + lint: 'x', + typecheck: 'x', + 'check:layering': 'x', + 'check:fallow': 'x', + 'check:mcp-metadata': 'x', + build: 'x', + 'test:unit': 'x', + 'test:output-economy': 'x', + 'test:integration:provider': 'x', + 'test:integration:node': 'x', + 'test:integration:progress:check': 'x', + 'build:xcuitest': 'x', + 'build:android-snapshot-helper': 'x', + 'build:macos-helper': 'x', + 'test:smoke:web': 'x', + 'test:skillgym': 'x', + }; + for (const spec of CHECK_CATALOG) { + const command = resolveCommand(spec, scripts, 'origin/main'); + assert.ok(command.length >= 2); + } + const fallow = CHECK_CATALOG.find((spec) => spec.id === 'fallow')!; + assert.deepEqual(resolveCommand(fallow, scripts, 'origin/dev'), [ + 'pnpm', + 'run', + 'check:fallow', + '--base', + 'origin/dev', + ]); +}); + +test('a missing package script makes command resolution throw', () => { + const spec = CHECK_CATALOG.find((entry) => entry.id === 'lint')!; + assert.throws(() => resolveCommand(spec, {}, 'origin/main'), /does not exist/); +}); diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts new file mode 100644 index 0000000000..117813eb47 --- /dev/null +++ b/scripts/check-affected/model.ts @@ -0,0 +1,330 @@ +// Derived, fail-open check selector for `pnpm check:affected --base `. +// +// 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): +// +// - test ownership comes from the Vitest project include/exclude globs +// (passed in from vitest.config.ts) — a changed test file selects the +// project that owns it, and a changed production source file selects the +// unit suite that mirrors it (AGENTS.md: test topology mirrors src 1:1); +// - 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 (unknown, ambiguous, +// workflow/tooling, or the selector's own sources) fails open to the full +// check set. Existing GitHub CI remains authoritative; this only optimizes +// local/agent feedback. + +export type CheckId = + | 'format' + | 'lint' + | 'typecheck' + | 'layering' + | 'fallow' + | 'mcp-metadata' + | 'build' + | 'unit' + | 'output-economy' + | 'provider-integration' + | 'interaction-contract' + | 'integration-node' + | 'integration-progress' + | 'swift-runner' + | 'android-helpers' + | 'macos-helper' + | 'web-smoke' + | 'skillgym'; + +// 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', + 'layering', + 'fallow', + 'mcp-metadata', + 'build', + 'unit', + 'output-economy', + 'provider-integration', + 'interaction-contract', + 'integration-node', + 'integration-progress', + 'swift-runner', + 'android-helpers', + 'macos-helper', + 'web-smoke', + 'skillgym', +]; + +export type VitestProject = { + name: string; + include: readonly string[]; + exclude?: readonly string[]; +}; + +export type SelectionReason = { + check: CheckId; + path: string; + rule: string; + detail: string; +}; + +export type FailOpenReason = { + path: string; + rule: 'workflow-tooling' | 'selector-owning' | 'unknown-path'; + detail: string; +}; + +export type CheckPlan = { + failOpen: boolean; + checks: CheckId[]; + reasons: SelectionReason[]; + failOpenReasons: FailOpenReason[]; + docsOnlyPaths: string[]; +}; + +export type SelectInput = { + changedFiles: readonly string[]; + vitestProjects: readonly VitestProject[]; + // Public package entry source files, derived from package.json `exports`. + packageEntryFiles?: readonly string[]; +}; + +// --- Minimal glob matcher (supports the subset Vitest configs use) ---------- +// Handles `**`, `*`, `?`, and non-nested `{a,b,c}` brace groups. +export function globToRegExp(glob: string): RegExp { + let out = '^'; + for (let i = 0; i < glob.length; i++) { + const char = glob[i]!; + if (char === '*') { + if (glob[i + 1] === '*') { + i++; + if (glob[i + 1] === '/') { + i++; + out += '(?:[^/]+/)*'; + } else { + out += '.*'; + } + } else { + out += '[^/]*'; + } + } else if (char === '?') { + out += '[^/]'; + } else if (char === '{') { + const end = glob.indexOf('}', i); + const body = glob.slice(i + 1, end); + out += `(?:${body.split(',').map(escapeRegExp).join('|')})`; + i = end; + } else { + out += escapeRegExp(char); + } + } + return new RegExp(`${out}$`); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.+^${}()|[\]\\]/g, '\\$&'); +} + +function matchesAny(file: string, globs: readonly string[]): boolean { + return globs.some((glob) => globToRegExp(glob).test(file)); +} + +// --- 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', +]); + +function isSelectorOwning(file: string): boolean { + return file.startsWith('scripts/check-affected/') && !file.endsWith('.md'); +} + +function isWorkflowTooling(file: string): boolean { + return file.startsWith('.github/') || file.startsWith('scripts/') || ROOT_TOOLING.has(file); +} + +function isDocs(file: string): boolean { + 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); +} + +function vitestCheckId(project: string): CheckId | null { + switch (project) { + case 'unit-core': + case 'android-adb': + return 'unit'; + case 'provider-integration': + return 'provider-integration'; + case 'interaction-contract': + return 'interaction-contract'; + case 'output-economy': + return 'output-economy'; + default: + return null; + } +} + +// --- Selection -------------------------------------------------------------- +export function selectChecks(input: SelectInput): CheckPlan { + const packageEntryFiles = new Set(input.packageEntryFiles ?? []); + const reasons: SelectionReason[] = []; + const failOpenReasons: FailOpenReason[] = []; + const docsOnlyPaths: string[] = []; + const selected = new Set(); + + const add = (check: CheckId, path: string, rule: string, detail: string): void => { + selected.add(check); + reasons.push({ check, path, rule, detail }); + }; + + for (const file of input.changedFiles) { + if (isSelectorOwning(file)) { + failOpenReasons.push({ + path: file, + rule: 'selector-owning', + detail: 'change to the affected-check selector cannot be trusted to select itself', + }); + continue; + } + if (isWorkflowTooling(file)) { + failOpenReasons.push({ + path: file, + rule: 'workflow-tooling', + detail: 'workflow/tooling change can alter any gate', + }); + continue; + } + if (isDocs(file)) { + docsOnlyPaths.push(file); + continue; + } + + let classified = false; + const isTs = file.endsWith('.ts') && !file.endsWith('.d.ts'); + const underSrc = file.startsWith('src/'); + const underTest = file.startsWith('test/'); + const underSkills = file.startsWith('skills/'); + const isTest = isTestPath(file); + const isSrcProd = underSrc && isTs && !isTest; + + // Always-on gates (never silently skipped when their inputs may change). + if (underSrc || underTest || underSkills) { + add('format', file, 'gate:format', 'oxfmt covers src/, test/, and skills/'); + classified = true; + } + if (isTs && (underSrc || underTest)) { + add('lint', file, 'gate:lint', 'oxlint covers the source tree'); + add('typecheck', file, 'gate:typecheck', 'tsc includes src/ and test/'); + add('fallow', file, 'gate:fallow', 'fallow audits changed TypeScript for dead code/complexity'); + classified = true; + } + if (isSrcProd) { + add('layering', file, 'gate:layering', 'layering guard reads production src/ modules'); + add('build', file, 'src-prod', 'production source is compiled by the build'); + add('unit', file, 'src-prod', 'unit suite mirrors production source 1:1'); + if (file.startsWith('src/platforms/')) { + add( + 'provider-integration', + file, + 'platform-src', + 'platform source shapes device/provider wire behavior', + ); + } + classified = true; + } + + // Vitest-derived test ownership: a changed test file selects its project. + for (const project of input.vitestProjects) { + const excluded = project.exclude ? matchesAny(file, project.exclude) : false; + if (!excluded && matchesAny(file, project.include)) { + const check = vitestCheckId(project.name); + if (check) { + add(check, file, `vitest:${project.name}`, `owned by the ${project.name} Vitest project`); + classified = true; + } + } + } + if (underTest && isTs && isTest && !selected.has('provider-integration')) { + // Node-runner integration tests live outside the Vitest projects. + if (matchesAny(file, ['test/integration/*.test.ts'])) { + add('integration-node', file, 'node-integration', 'node --test integration smoke owns this file'); + classified = true; + } + } + + // Small explicit build-ownership layer. + if (file.startsWith('apple-runner/') || file.endsWith('.swift')) { + add('swift-runner', file, 'own:swift', 'Swift runner sources require the XCUITest build'); + classified = true; + } + if ( + file.startsWith('android-snapshot-helper/') || + file.startsWith('android-multitouch-helper/') + ) { + add('android-helpers', file, 'own:android-helpers', 'Android helper packages have their own build'); + classified = true; + } + if (file.startsWith('macos-helper/')) { + add('macos-helper', file, 'own:macos-helper', 'macOS helper is a separate Swift package build'); + classified = true; + } + if (file === 'server.json' || file === 'smithery.yaml') { + add('mcp-metadata', file, 'own:mcp', 'MCP registry metadata must stay in sync'); + classified = true; + } + if (packageEntryFiles.has(file)) { + add('build', file, 'own:public-surface', 'public package entry point affects declaration output'); + classified = true; + } + + if (!classified) { + failOpenReasons.push({ + path: file, + rule: 'unknown-path', + detail: 'path has no derivable owner; run the full set to stay safe', + }); + } + } + + if (failOpenReasons.length > 0) { + return { + failOpen: true, + checks: [...ALL_CHECKS], + reasons, + failOpenReasons, + docsOnlyPaths, + }; + } + + return { + failOpen: false, + checks: ALL_CHECKS.filter((check) => selected.has(check)), + reasons, + failOpenReasons, + docsOnlyPaths, + }; +} diff --git a/scripts/check-affected/run.ts b/scripts/check-affected/run.ts new file mode 100644 index 0000000000..72ca472295 --- /dev/null +++ b/scripts/check-affected/run.ts @@ -0,0 +1,193 @@ +// Entry point for `pnpm check:affected --base `. +// +// Derives the affected local check set from the diff against , prints a +// stable machine-readable plan (with per-check reasoning), and optionally runs +// the locally-runnable checks. Fails open to the full set on anything it cannot +// classify. Existing GitHub CI stays authoritative. + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + assertCatalogComplete, + CHECK_CATALOG, + getCheckSpec, + resolveCommand, + type CheckSpec, +} from './checks.ts'; +import { ALL_CHECKS, selectChecks, type CheckPlan, type VitestProject } from './model.ts'; + +type Args = { base: string; head: string; json: boolean; run: boolean }; + +const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { + encoding: 'utf8', +}).trim(); + +function parseArgs(argv: readonly string[]): Args { + const args: Args = { base: 'origin/main', head: 'HEAD', json: false, run: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--base') args.base = argv[++i] ?? args.base; + else if (arg === '--head') args.head = argv[++i] ?? args.head; + else if (arg === '--json') args.json = true; + else if (arg === '--run') args.run = true; + else if (arg === '--help' || arg === '-h') { + process.stdout.write( + 'Usage: pnpm check:affected [--base ] [--head ] [--json] [--run]\n', + ); + process.exit(0); + } else throw new Error(`Unknown argument: ${arg}`); + } + return args; +} + +function readChangedFiles(base: string, head: string): string[] { + const out = execFileSync('git', ['diff', '--name-only', '--merge-base', base, head], { + cwd: repoRoot, + encoding: 'utf8', + }); + return out.split('\n').filter(Boolean); +} + +async function loadVitestProjects(): Promise { + const module = (await import(pathToFileURL(path.join(repoRoot, 'vitest.config.ts')).href)) as { + default: { test?: { projects?: Array<{ test?: VitestProject }> } }; + }; + const projects = module.default.test?.projects ?? []; + return projects + .map((project) => project.test) + .filter((project): project is VitestProject => Boolean(project?.name && project.include)); +} + +type PackageJson = { + scripts: Record; + exports?: Record; +}; + +function loadPackageJson(): PackageJson { + return JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as PackageJson; +} + +// Public package surface = the source files behind package.json `exports`. +function packageEntryFiles(pkg: PackageJson): string[] { + return Object.values(pkg.exports ?? {}) + .map((entry) => entry.import) + .filter((target): target is string => typeof target === 'string') + .map((target) => target.replace(/^\.\/dist\//, '').replace(/\.js$/, '.ts')); +} + +function printPlanJson(plan: CheckPlan, args: Args): void { + const checks = plan.checks.map((id) => { + const spec = getCheckSpec(id); + return { + id, + label: spec.label, + ciJobs: spec.ciJobs, + localRunnable: spec.localRunnable, + reasons: plan.reasons.filter((reason) => reason.check === id), + }; + }); + const notSelected = ALL_CHECKS.filter((id) => !plan.checks.includes(id)); + process.stdout.write( + `${JSON.stringify( + { + base: args.base, + head: args.head, + failOpen: plan.failOpen, + failOpenReasons: plan.failOpenReasons, + docsOnlyPaths: plan.docsOnlyPaths, + checks, + notSelected, + }, + null, + 2, + )}\n`, + ); +} + +function printPlanHuman(plan: CheckPlan, args: Args): void { + const write = (line: string): void => void process.stdout.write(`${line}\n`); + write(`check:affected — diff ${args.base}...${args.head}`); + if (plan.failOpen) { + write('Fail-open: selecting the full check set.'); + for (const reason of plan.failOpenReasons) { + write(` ! ${reason.path} [${reason.rule}] — ${reason.detail}`); + } + } + if (plan.checks.length === 0) { + write('No local checks selected.'); + if (plan.docsOnlyPaths.length > 0) { + write(` Docs-only changes: ${plan.docsOnlyPaths.length} file(s).`); + } + return; + } + write(`Selected ${plan.checks.length} check(s):`); + for (const id of plan.checks) { + const spec = getCheckSpec(id); + const local = spec.localRunnable ? '' : ' (GitHub-authoritative; not run locally)'; + write(` - ${id}: ${spec.label}${local}`); + if (!plan.failOpen) { + for (const reason of plan.reasons.filter((entry) => entry.check === id)) { + write(` · ${reason.path} [${reason.rule}] — ${reason.detail}`); + } + } + } + if (plan.docsOnlyPaths.length > 0) { + write(`Docs-only changes ignored: ${plan.docsOnlyPaths.length} file(s).`); + } +} + +function runChecks(plan: CheckPlan, pkg: PackageJson, args: Args): number { + const runnable = plan.checks.map(getCheckSpec).filter((spec: CheckSpec) => spec.localRunnable); + const skipped = plan.checks.map(getCheckSpec).filter((spec: CheckSpec) => !spec.localRunnable); + for (const spec of skipped) { + process.stdout.write( + `\n[skip] ${spec.id} — GitHub-authoritative (jobs: ${spec.ciJobs.join(', ')})\n`, + ); + } + for (const spec of runnable) { + const command = resolveCommand(spec, pkg.scripts, args.base); + process.stdout.write(`\n[run] ${spec.id}: ${command.join(' ')}\n`); + try { + execFileSync(command[0]!, command.slice(1), { cwd: repoRoot, stdio: 'inherit' }); + } catch { + process.stderr.write(`\ncheck:affected: ${spec.id} failed.\n`); + return 1; + } + } + process.stdout.write('\ncheck:affected: all runnable checks passed.\n'); + return 0; +} + +async function main(argv = process.argv.slice(2)): Promise { + assertCatalogComplete(); + const args = parseArgs(argv); + const pkg = loadPackageJson(); + // Validate every catalog command resolves before selecting, so a broken + // catalog fails loudly rather than silently dropping a gate. + for (const spec of CHECK_CATALOG) resolveCommand(spec, pkg.scripts, args.base); + const vitestProjects = await loadVitestProjects(); + const changedFiles = readChangedFiles(args.base, args.head); + const plan = selectChecks({ + changedFiles, + vitestProjects, + packageEntryFiles: packageEntryFiles(pkg), + }); + + if (args.json) printPlanJson(plan, args); + else printPlanHuman(plan, args); + + if (args.run) return runChecks(plan, pkg, args); + return 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + main().then( + (code) => process.exit(code), + (error: unknown) => { + process.stderr.write(`check:affected: ${error instanceof Error ? error.message : error}\n`); + process.exit(1); + }, + ); +} From 49863f34b02285f0b3c9535d6d97db7e1a7d2b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 10:18:39 +0000 Subject: [PATCH 2/8] refactor: simplify selector for complexity gate; add docs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 1 + docs/agents/testing.md | 34 ++++ scripts/check-affected/model.test.ts | 10 +- scripts/check-affected/model.ts | 282 ++++++++++++++++----------- scripts/check-affected/run.ts | 92 +++++---- 5 files changed, 271 insertions(+), 148 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0fcbdc1a3f..f978d0b436 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,6 +202,7 @@ This repo encodes invariants as self-declaring gates. The correct response to a - Do not duplicate session/store/device helpers when a shared helper already exists; if a helper is missing, add it near the concept it serves and export it through the barrel. ## Testing Matrix +- Derive the local check set with `pnpm check:affected --base origin/main` (`--json` for a machine-readable plan). It is a fail-open advisory that mirrors this matrix from repository sources of truth; GitHub CI stays authoritative. See `docs/agents/testing.md`. - Docs/skills only: no tests required unless a more specific rule below applies. - CLI help/guidance changes in `src/cli/parser/cli-help.ts`, `src/utils/cli-command-overrides.ts`, or `src/utils/command-schema.ts`: run `pnpm exec vitest run src/cli/parser/__tests__ src/utils/__tests__/command-schema-guards.test.ts`. - SkillGym prompt/assertion changes: run `pnpm test:skillgym:case `; the script builds local CLI help first. For broad validation, use `pnpm test:skillgym`; append `-- --tag fixture-smoke` or `-- --tag skill-guidance` when validating one suite group. diff --git a/docs/agents/testing.md b/docs/agents/testing.md index b369135322..1e90532a91 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -1,5 +1,39 @@ # Testing Notes +## Affected-check selector (`pnpm check:affected`) + +`pnpm check:affected --base ` derives which local checks a diff needs, so +agents stop interpreting the testing matrix by hand. It is a **fail-open +advisory**: existing GitHub CI stays authoritative and required, and this only +narrows the *local* feedback loop. + +```sh +pnpm check:affected --base origin/main # human-readable plan +pnpm check:affected --base origin/main --json # stable machine-readable plan +pnpm check:affected --base origin/main --run # also run the local checks +``` + +The selection is derived from repository sources of truth rather than a +hand-maintained path map: + +- **Test ownership** comes from the Vitest project `include`/`exclude` globs in + `vitest.config.ts`. A changed test file selects the project that owns it; a + changed production `src/**` file selects the unit suite that mirrors it. +- **Always-on gates** (`lint`, `typecheck`, `layering`, `fallow`, `format`) fire + for their input categories and are never silently skipped. +- **Commands** are resolved from real `package.json` scripts, so a renamed + script fails loudly instead of dropping a gate. +- A **small explicit build-ownership layer** covers the paths whose owning build + cannot be derived: Swift runner, Android helpers, macOS helper, MCP metadata, + and the public package surface (itself derived from `package.json` `exports`). + +Anything the selector cannot classify — unknown, ambiguous, workflow/tooling, or +a change to the selector's own sources — **fails open to the full check set**. +The plan documents the rule and changed path behind every selected check. + +Model and catalog live under `scripts/check-affected/`; the derivation is guarded +by `pnpm check:affected:test` (the `Affected-check Selector` CI job). + ## Live web smoke The live web platform smoke runs the public built CLI against a local fixture page through the managed web backend: diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 0e93d7d350..6bfda10584 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -60,7 +60,15 @@ test('glob matcher handles **, *, ?, and brace groups like the vitest config', ( test('production source change selects gates + build + unit, with reasons', () => { const result = plan(['src/daemon/selectors.ts']); assert.equal(result.failOpen, false); - for (const id of ['format', 'lint', 'typecheck', 'layering', 'fallow', 'build', 'unit'] as const) { + for (const id of [ + 'format', + 'lint', + 'typecheck', + 'layering', + 'fallow', + 'build', + 'unit', + ] as const) { assert.ok(result.checks.includes(id), `expected ${id}`); } assert.ok(!result.checks.includes('provider-integration')); diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index 117813eb47..1a257284b9 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -188,138 +188,202 @@ function vitestCheckId(project: string): CheckId | null { } } +// --- 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; + underSkills: 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, underSkills }) => + underSrc || underTest || underSkills + ? [reason('format', file, 'gate:format', 'oxfmt covers src/, test/, and skills/')] + : []; + +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'), + reason('unit', file, 'src-prod', 'unit suite mirrors production source 1:1'), + ]; + if (file.startsWith('src/platforms/')) { + selections.push( + reason( + 'provider-integration', + file, + 'platform-src', + 'platform source shapes device/provider wire behavior', + ), + ); + } + return selections; +}; + +const vitestOwnership: OwnershipRule = ({ file }, input) => { + const selections: SelectionReason[] = []; + for (const project of input.vitestProjects) { + const excluded = project.exclude ? matchesAny(file, project.exclude) : false; + if (excluded || !matchesAny(file, project.include)) continue; + const check = vitestCheckId(project.name); + if (check) { + selections.push( + reason( + check, + file, + `vitest:${project.name}`, + `owned by the ${project.name} Vitest project`, + ), + ); + } + } + return selections; +}; + +const nodeIntegrationOwnership: OwnershipRule = ({ file }) => + matchesAny(file, ['test/integration/*.test.ts']) + ? [reason('integration-node', file, 'node-integration', 'node --test integration smoke')] + : []; + +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-multitouch-helper/'), + }, + { + check: 'macos-helper', + rule: 'own:macos-helper', + detail: 'macOS helper is a separate Swift package build', + owns: (file) => file.startsWith('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, + vitestOwnership, + nodeIntegrationOwnership, + 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/'), + underSkills: file.startsWith('skills/'), + 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 packageEntryFiles = new Set(input.packageEntryFiles ?? []); const reasons: SelectionReason[] = []; const failOpenReasons: FailOpenReason[] = []; const docsOnlyPaths: string[] = []; - const selected = new Set(); - - const add = (check: CheckId, path: string, rule: string, detail: string): void => { - selected.add(check); - reasons.push({ check, path, rule, detail }); - }; for (const file of input.changedFiles) { - if (isSelectorOwning(file)) { - failOpenReasons.push({ - path: file, - rule: 'selector-owning', - detail: 'change to the affected-check selector cannot be trusted to select itself', - }); - continue; - } - if (isWorkflowTooling(file)) { - failOpenReasons.push({ - path: file, - rule: 'workflow-tooling', - detail: 'workflow/tooling change can alter any gate', - }); + const failOpen = failOpenFor(file); + if (failOpen) { + failOpenReasons.push(failOpen); continue; } if (isDocs(file)) { docsOnlyPaths.push(file); continue; } - - let classified = false; - const isTs = file.endsWith('.ts') && !file.endsWith('.d.ts'); - const underSrc = file.startsWith('src/'); - const underTest = file.startsWith('test/'); - const underSkills = file.startsWith('skills/'); - const isTest = isTestPath(file); - const isSrcProd = underSrc && isTs && !isTest; - - // Always-on gates (never silently skipped when their inputs may change). - if (underSrc || underTest || underSkills) { - add('format', file, 'gate:format', 'oxfmt covers src/, test/, and skills/'); - classified = true; - } - if (isTs && (underSrc || underTest)) { - add('lint', file, 'gate:lint', 'oxlint covers the source tree'); - add('typecheck', file, 'gate:typecheck', 'tsc includes src/ and test/'); - add('fallow', file, 'gate:fallow', 'fallow audits changed TypeScript for dead code/complexity'); - classified = true; - } - if (isSrcProd) { - add('layering', file, 'gate:layering', 'layering guard reads production src/ modules'); - add('build', file, 'src-prod', 'production source is compiled by the build'); - add('unit', file, 'src-prod', 'unit suite mirrors production source 1:1'); - if (file.startsWith('src/platforms/')) { - add( - 'provider-integration', - file, - 'platform-src', - 'platform source shapes device/provider wire behavior', - ); - } - classified = true; - } - - // Vitest-derived test ownership: a changed test file selects its project. - for (const project of input.vitestProjects) { - const excluded = project.exclude ? matchesAny(file, project.exclude) : false; - if (!excluded && matchesAny(file, project.include)) { - const check = vitestCheckId(project.name); - if (check) { - add(check, file, `vitest:${project.name}`, `owned by the ${project.name} Vitest project`); - classified = true; - } - } - } - if (underTest && isTs && isTest && !selected.has('provider-integration')) { - // Node-runner integration tests live outside the Vitest projects. - if (matchesAny(file, ['test/integration/*.test.ts'])) { - add('integration-node', file, 'node-integration', 'node --test integration smoke owns this file'); - classified = true; - } - } - - // Small explicit build-ownership layer. - if (file.startsWith('apple-runner/') || file.endsWith('.swift')) { - add('swift-runner', file, 'own:swift', 'Swift runner sources require the XCUITest build'); - classified = true; - } - if ( - file.startsWith('android-snapshot-helper/') || - file.startsWith('android-multitouch-helper/') - ) { - add('android-helpers', file, 'own:android-helpers', 'Android helper packages have their own build'); - classified = true; - } - if (file.startsWith('macos-helper/')) { - add('macos-helper', file, 'own:macos-helper', 'macOS helper is a separate Swift package build'); - classified = true; - } - if (file === 'server.json' || file === 'smithery.yaml') { - add('mcp-metadata', file, 'own:mcp', 'MCP registry metadata must stay in sync'); - classified = true; - } - if (packageEntryFiles.has(file)) { - add('build', file, 'own:public-surface', 'public package entry point affects declaration output'); - classified = true; - } - - if (!classified) { + 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; } + reasons.push(...selections); } if (failOpenReasons.length > 0) { - return { - failOpen: true, - checks: [...ALL_CHECKS], - reasons, - failOpenReasons, - docsOnlyPaths, - }; + 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)), diff --git a/scripts/check-affected/run.ts b/scripts/check-affected/run.ts index 72ca472295..ee97f81e88 100644 --- a/scripts/check-affected/run.ts +++ b/scripts/check-affected/run.ts @@ -9,6 +9,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { parseArgs as parseNodeArgs } from 'node:util'; import { assertCatalogComplete, CHECK_CATALOG, @@ -24,22 +25,30 @@ const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', }).trim(); +const USAGE = 'Usage: pnpm check:affected [--base ] [--head ] [--json] [--run]\n'; + function parseArgs(argv: readonly string[]): Args { - const args: Args = { base: 'origin/main', head: 'HEAD', json: false, run: false }; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg === '--base') args.base = argv[++i] ?? args.base; - else if (arg === '--head') args.head = argv[++i] ?? args.head; - else if (arg === '--json') args.json = true; - else if (arg === '--run') args.run = true; - else if (arg === '--help' || arg === '-h') { - process.stdout.write( - 'Usage: pnpm check:affected [--base ] [--head ] [--json] [--run]\n', - ); - process.exit(0); - } else throw new Error(`Unknown argument: ${arg}`); + const { values } = parseNodeArgs({ + args: [...argv], + options: { + base: { type: 'string', default: 'origin/main' }, + head: { type: 'string', default: 'HEAD' }, + json: { type: 'boolean', default: false }, + run: { type: 'boolean', default: false }, + help: { type: 'boolean', short: 'h', default: false }, + }, + allowPositionals: false, + }); + if (values.help) { + process.stdout.write(USAGE); + process.exit(0); } - return args; + return { + base: values.base ?? 'origin/main', + head: values.head ?? 'HEAD', + json: Boolean(values.json), + run: Boolean(values.run), + }; } function readChangedFiles(base: string, head: string): string[] { @@ -106,35 +115,42 @@ function printPlanJson(plan: CheckPlan, args: Args): void { ); } -function printPlanHuman(plan: CheckPlan, args: Args): void { - const write = (line: string): void => void process.stdout.write(`${line}\n`); - write(`check:affected — diff ${args.base}...${args.head}`); - if (plan.failOpen) { - write('Fail-open: selecting the full check set.'); - for (const reason of plan.failOpenReasons) { - write(` ! ${reason.path} [${reason.rule}] — ${reason.detail}`); - } +function writeLine(line: string): void { + process.stdout.write(`${line}\n`); +} + +function printCheckLine(plan: CheckPlan, id: (typeof plan.checks)[number]): void { + const spec = getCheckSpec(id); + const local = spec.localRunnable ? '' : ' (GitHub-authoritative; not run locally)'; + writeLine(` - ${id}: ${spec.label}${local}`); + if (plan.failOpen) return; + for (const reason of plan.reasons.filter((entry) => entry.check === id)) { + writeLine(` · ${reason.path} [${reason.rule}] — ${reason.detail}`); + } +} + +function printFailOpen(plan: CheckPlan): void { + writeLine('Fail-open: selecting the full check set.'); + for (const reason of plan.failOpenReasons) { + writeLine(` ! ${reason.path} [${reason.rule}] — ${reason.detail}`); } +} + +function printSelected(plan: CheckPlan): void { if (plan.checks.length === 0) { - write('No local checks selected.'); - if (plan.docsOnlyPaths.length > 0) { - write(` Docs-only changes: ${plan.docsOnlyPaths.length} file(s).`); - } + writeLine('No local checks selected.'); return; } - write(`Selected ${plan.checks.length} check(s):`); - for (const id of plan.checks) { - const spec = getCheckSpec(id); - const local = spec.localRunnable ? '' : ' (GitHub-authoritative; not run locally)'; - write(` - ${id}: ${spec.label}${local}`); - if (!plan.failOpen) { - for (const reason of plan.reasons.filter((entry) => entry.check === id)) { - write(` · ${reason.path} [${reason.rule}] — ${reason.detail}`); - } - } - } + writeLine(`Selected ${plan.checks.length} check(s):`); + for (const id of plan.checks) printCheckLine(plan, id); +} + +function printPlanHuman(plan: CheckPlan, args: Args): void { + writeLine(`check:affected — diff ${args.base}...${args.head}`); + if (plan.failOpen) printFailOpen(plan); + printSelected(plan); if (plan.docsOnlyPaths.length > 0) { - write(`Docs-only changes ignored: ${plan.docsOnlyPaths.length} file(s).`); + writeLine(`Docs-only changes ignored: ${plan.docsOnlyPaths.length} file(s).`); } } From a6c6688513f1e286d5d38b3e43a5a1338fb4b77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 11:07:04 +0000 Subject: [PATCH 3/8] fix: fail open on ambiguous non-source fixtures; guard catalog against real package.json/vitest.config Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/check-affected/model.test.ts | 40 ++++++++++++++++++++++++++++ scripts/check-affected/model.ts | 23 ++++++++++++---- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 6bfda10584..6979508df0 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; import { assertCatalogComplete, CHECK_CATALOG, resolveCommand } from './checks.ts'; import { ALL_CHECKS, @@ -139,6 +142,13 @@ test('unknown path fails open to the full check set', () => { assert.equal(result.failOpenReasons[0]?.rule, 'unknown-path'); }); +test('a non-.ts fixture under an owned root fails open (format alone is not ownership)', () => { + const result = plan(['test/integration/provider-scenarios/fixtures/device.json']); + assert.equal(result.failOpen, true); + assert.deepEqual(result.checks, [...ALL_CHECKS]); + assert.equal(result.failOpenReasons[0]?.rule, 'ambiguous-path'); +}); + test('workflow/tooling and selector-owning changes fail open', () => { assert.equal(plan(['.github/workflows/ci.yml']).failOpenReasons[0]?.rule, 'workflow-tooling'); assert.equal(plan(['package.json']).failOpenReasons[0]?.rule, 'workflow-tooling'); @@ -203,3 +213,33 @@ test('a missing package script makes command resolution throw', () => { const spec = CHECK_CATALOG.find((entry) => entry.id === 'lint')!; assert.throws(() => resolveCommand(spec, {}, 'origin/main'), /does not exist/); }); + +// Guards the catalog against reality, not fixtures: the self-test above uses a +// hand-built scripts/projects map, so this resolves every catalog entry against +// the real package.json and checks vitest-project names against the real +// vitest.config.ts. A renamed/removed script or project fails here instead of +// leaving `pnpm check:affected` broken on the exact command the docs advertise. +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +test('catalog resolves against the real package.json + vitest.config.ts', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { + scripts?: Record; + }; + const scripts = pkg.scripts ?? {}; + for (const spec of CHECK_CATALOG) { + assert.doesNotThrow( + () => resolveCommand(spec, scripts, 'origin/main'), + `catalog entry "${spec.id}" must resolve against the real package.json`, + ); + } + + const vitestConfig = fs.readFileSync(path.join(repoRoot, 'vitest.config.ts'), 'utf8'); + for (const spec of CHECK_CATALOG) { + if (spec.kind.type === 'vitest-project') { + assert.ok( + vitestConfig.includes(`name: '${spec.kind.project}'`), + `vitest project "${spec.kind.project}" must exist in vitest.config.ts`, + ); + } + } +}); diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index 1a257284b9..66799c912b 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -14,10 +14,11 @@ // 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 (unknown, ambiguous, -// workflow/tooling, or the selector's own sources) fails open to the full -// check set. Existing GitHub CI remains authoritative; this only optimizes -// local/agent feedback. +// 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' @@ -77,7 +78,7 @@ export type SelectionReason = { export type FailOpenReason = { path: string; - rule: 'workflow-tooling' | 'selector-owning' | 'unknown-path'; + rule: 'workflow-tooling' | 'selector-owning' | 'unknown-path' | 'ambiguous-path'; detail: string; }; @@ -377,6 +378,18 @@ export function selectChecks(input: SelectInput): CheckPlan { }); 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); } From 378fd7c3d36a074f2b4b06a205f2e930a36d3b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 11:11:04 +0000 Subject: [PATCH 4/8] refactor: use src/utils/exec.ts process helpers in check:affected runner Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/check-affected/run.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/scripts/check-affected/run.ts b/scripts/check-affected/run.ts index ee97f81e88..044400e517 100644 --- a/scripts/check-affected/run.ts +++ b/scripts/check-affected/run.ts @@ -5,11 +5,11 @@ // the locally-runnable checks. Fails open to the full set on anything it cannot // classify. Existing GitHub CI stays authoritative. -import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { parseArgs as parseNodeArgs } from 'node:util'; +import { runCmdStreaming, runCmdSync } from '../../src/utils/exec.ts'; import { assertCatalogComplete, CHECK_CATALOG, @@ -21,9 +21,7 @@ import { ALL_CHECKS, selectChecks, type CheckPlan, type VitestProject } from './ type Args = { base: string; head: string; json: boolean; run: boolean }; -const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { - encoding: 'utf8', -}).trim(); +const repoRoot = runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim(); const USAGE = 'Usage: pnpm check:affected [--base ] [--head ] [--json] [--run]\n'; @@ -52,11 +50,10 @@ function parseArgs(argv: readonly string[]): Args { } function readChangedFiles(base: string, head: string): string[] { - const out = execFileSync('git', ['diff', '--name-only', '--merge-base', base, head], { + const { stdout } = runCmdSync('git', ['diff', '--name-only', '--merge-base', base, head], { cwd: repoRoot, - encoding: 'utf8', }); - return out.split('\n').filter(Boolean); + return stdout.split('\n').filter(Boolean); } async function loadVitestProjects(): Promise { @@ -154,7 +151,7 @@ function printPlanHuman(plan: CheckPlan, args: Args): void { } } -function runChecks(plan: CheckPlan, pkg: PackageJson, args: Args): number { +async function runChecks(plan: CheckPlan, pkg: PackageJson, args: Args): Promise { const runnable = plan.checks.map(getCheckSpec).filter((spec: CheckSpec) => spec.localRunnable); const skipped = plan.checks.map(getCheckSpec).filter((spec: CheckSpec) => !spec.localRunnable); for (const spec of skipped) { @@ -165,9 +162,13 @@ function runChecks(plan: CheckPlan, pkg: PackageJson, args: Args): number { for (const spec of runnable) { const command = resolveCommand(spec, pkg.scripts, args.base); process.stdout.write(`\n[run] ${spec.id}: ${command.join(' ')}\n`); - try { - execFileSync(command[0]!, command.slice(1), { cwd: repoRoot, stdio: 'inherit' }); - } catch { + const result = await runCmdStreaming(command[0]!, command.slice(1), { + cwd: repoRoot, + allowFailure: true, + onStdoutChunk: (chunk) => void process.stdout.write(chunk), + onStderrChunk: (chunk) => void process.stderr.write(chunk), + }); + if (result.exitCode !== 0) { process.stderr.write(`\ncheck:affected: ${spec.id} failed.\n`); return 1; } @@ -194,7 +195,7 @@ async function main(argv = process.argv.slice(2)): Promise { if (args.json) printPlanJson(plan, args); else printPlanHuman(plan, args); - if (args.run) return runChecks(plan, pkg, args); + if (args.run) return await runChecks(plan, pkg, args); return 0; } From 5d6227ba0d20aa308626a9b4ebf54d7ec3d9a899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 13:01:42 +0000 Subject: [PATCH 5/8] fix(check:affected): SkillGym ownership, honest catalog, working-tree discovery - Add SkillGym ownership for skills/ and test/skillgym/; stop short-circuiting their Markdown as docs-only (findings 2 & 4). - Drop the fabricated GitHub 'SkillGym' job: it is a local-only gate, now localRunnable with no CI job, guarded by a workflow-existence self-test (3). - Fold working-tree (staged/unstaged/untracked) state into local discovery and disable rename detection so both rename paths classify (1). - Add run.test.ts entrypoint regressions (real diff/status/rename discovery, --run order/skip/stop-on-failure). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- docs/agents/testing.md | 9 +++ package.json | 2 +- scripts/check-affected/checks.ts | 7 +- scripts/check-affected/model.test.ts | 38 +++++++++ scripts/check-affected/model.ts | 24 +++++- scripts/check-affected/run.test.ts | 116 +++++++++++++++++++++++++++ scripts/check-affected/run.ts | 61 +++++++++++--- 8 files changed, 241 insertions(+), 18 deletions(-) create mode 100644 scripts/check-affected/run.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4664341ab..9834a4f5f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,7 @@ jobs: # so the gate only guards the derivation model. Invoked directly with no # deps, mirroring the layering guard. - name: Check affected-selector model - run: node --experimental-strip-types --test scripts/check-affected/model.test.ts + run: node --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts packaged-cli-node-22-12: name: Packaged CLI Node 22.12 diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 1e90532a91..3aced23e88 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -26,6 +26,15 @@ hand-maintained path map: - A **small explicit build-ownership layer** covers the paths whose owning build cannot be derived: Swift runner, Android helpers, macOS helper, MCP metadata, and the public package surface (itself derived from `package.json` `exports`). +- **SkillGym ownership** covers skill guidance (`skills/`) and the SkillGym + harness (`test/skillgym/`) — those changes select the (local-only) SkillGym + suite, and their Markdown is treated as skill/harness input, not inert docs. + +Changed-file discovery folds working-tree state into the local plan: in the +default local mode (`--head HEAD`) it unions the committed `base..HEAD` diff with +staged, unstaged, and untracked files, and disables rename detection so **both** +sides of a rename are classified (a moved file cannot look docs-only by its +destination alone). Anything the selector cannot classify — unknown, ambiguous, workflow/tooling, or a change to the selector's own sources — **fails open to the full check set**. diff --git a/package.json b/package.json index ab4a38f254..b2acc1c031 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,7 @@ "fallow:baseline": "(fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary || true) && (fallow health --save-baseline fallow-baselines/health.json --summary || true)", "check:fallow": "fallow audit", "check:affected": "node --experimental-strip-types scripts/check-affected/run.ts", - "check:affected:test": "node --experimental-strip-types --test scripts/check-affected/model.test.ts", + "check:affected:test": "node --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/run.test.ts", "check:layering": "node --experimental-strip-types --test scripts/layering/model.test.ts && node --experimental-strip-types scripts/layering/check.ts", "check:layering:baseline": "node --experimental-strip-types scripts/layering/check.ts --update-baseline", "check:quick": "pnpm lint && pnpm typecheck", diff --git a/scripts/check-affected/checks.ts b/scripts/check-affected/checks.ts index 93245cf62f..ebed78d469 100644 --- a/scripts/check-affected/checks.ts +++ b/scripts/check-affected/checks.ts @@ -145,8 +145,11 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ id: 'skillgym', label: 'SkillGym command-planning suite', kind: { type: 'script', script: 'test:skillgym' }, - ciJobs: ['SkillGym'], - localRunnable: false, + // No GitHub workflow runs SkillGym; per the AGENTS.md testing matrix it is + // a local-only gate (`pnpm test:skillgym`). Keep it locally runnable rather + // than claiming a CI job that does not exist and silently skipping it. + ciJobs: [], + localRunnable: true, }, ]; diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 6979508df0..0522dfabee 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -149,6 +149,19 @@ test('a non-.ts fixture under an owned root fails open (format alone is not owne assert.equal(result.failOpenReasons[0]?.rule, 'ambiguous-path'); }); +test('skills guidance change selects format + skillgym, not docs-only', () => { + const result = plan(['skills/agent-device/SKILL.md']); + assert.equal(result.failOpen, false); + assert.equal(result.docsOnlyPaths.length, 0); + assert.ok(result.checks.includes('skillgym'), 'skills change must select the SkillGym suite'); + assert.ok(result.checks.includes('format'), 'skills change must still run format'); +}); + +test('SkillGym harness change selects the skillgym suite', () => { + const result = ids(['test/skillgym/suites/agent-device-smoke-suite.ts']); + assert.ok(result.includes('skillgym')); +}); + test('workflow/tooling and selector-owning changes fail open', () => { assert.equal(plan(['.github/workflows/ci.yml']).failOpenReasons[0]?.rule, 'workflow-tooling'); assert.equal(plan(['package.json']).failOpenReasons[0]?.rule, 'workflow-tooling'); @@ -243,3 +256,28 @@ test('catalog resolves against the real package.json + vitest.config.ts', () => } } }); + +test('every catalog CI job maps to a real workflow job (no fabricated checks)', () => { + const workflowsDir = path.join(repoRoot, '.github', 'workflows'); + const workflows = fs + .readdirSync(workflowsDir) + .filter((file) => file.endsWith('.yml') || file.endsWith('.yaml')) + .map((file) => fs.readFileSync(path.join(workflowsDir, file), 'utf8')) + .join('\n'); + for (const spec of CHECK_CATALOG) { + for (const job of spec.ciJobs) { + // GitHub renders check names as " / "; match on the job. + const jobName = job.includes(' / ') ? job.slice(job.lastIndexOf(' / ') + 3) : job; + assert.ok( + workflows.includes(`name: ${jobName}`), + `catalog check "${spec.id}" references CI job "${job}", but no workflow defines "${jobName}"`, + ); + } + } +}); + +test('skillgym is a local-only gate (locally runnable, claims no CI job)', () => { + const skillgym = CHECK_CATALOG.find((spec) => spec.id === 'skillgym')!; + assert.equal(skillgym.localRunnable, true); + assert.deepEqual([...skillgym.ciJobs], []); +}); diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index 66799c912b..1f4a8a8296 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -12,7 +12,10 @@ // 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. +// only paths whose owning build the sources of truth cannot derive; +// - SkillGym owns skill guidance (`skills/`) and its harness +// (`test/skillgym/`): those changes select the SkillGym suite, and their +// Markdown is skill/harness input, not inert docs. // // Anything the model cannot confidently classify fails open to the full check // set: unknown paths, workflow/tooling, the selector's own sources, and @@ -160,6 +163,10 @@ function isWorkflowTooling(file: string): boolean { } function isDocs(file: string): boolean { + // skills/ and the SkillGym harness are validated by the SkillGym suite (and + // formatting), not treated as inert docs — even their Markdown is skill + // guidance or harness input, so let them flow to ownership rules instead. + if (file.startsWith('skills/') || file.startsWith('test/skillgym/')) return false; return ( file.startsWith('docs/') || file.startsWith('website/') || @@ -267,6 +274,20 @@ const nodeIntegrationOwnership: OwnershipRule = ({ file }) => ? [reason('integration-node', file, 'node-integration', 'node --test integration smoke')] : []; +// SkillGym validates skill guidance (`skills/`) and owns its harness +// (`test/skillgym/`); AGENTS.md routes skill-prompt/assertion changes here. +const skillgymOwnership: OwnershipRule = ({ file, underSkills }) => + underSkills || file.startsWith('test/skillgym/') + ? [ + reason( + 'skillgym', + file, + 'own:skillgym', + 'SkillGym suite validates skill guidance and its harness', + ), + ] + : []; + const BUILD_OWNERSHIP: ReadonlyArray<{ check: CheckId; rule: string; @@ -318,6 +339,7 @@ const OWNERSHIP_RULES: readonly OwnershipRule[] = [ srcProdGate, vitestOwnership, nodeIntegrationOwnership, + skillgymOwnership, buildOwnership, ]; diff --git a/scripts/check-affected/run.test.ts b/scripts/check-affected/run.test.ts new file mode 100644 index 0000000000..a0b207d4fe --- /dev/null +++ b/scripts/check-affected/run.test.ts @@ -0,0 +1,116 @@ +// Entrypoint regressions for `pnpm check:affected`: the model self-test covers +// classification, this covers the run.ts seams the model cannot — real git +// change discovery (committed/staged/unstaged/untracked + both rename paths) +// and `--run` propagation (order, GitHub-authoritative skips, stop-on-failure). + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { runCmdSync } from '../../src/utils/exec.ts'; +import { selectChecks } from './model.ts'; +import { type CommandExecutor, readChangedFiles, runChecks } from './run.ts'; + +function git(cwd: string, ...args: string[]): void { + runCmdSync('git', args, { cwd }); +} + +function makeRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-affected-')); + git(dir, 'init', '-q', '-b', 'main'); + git(dir, 'config', 'user.email', 'test@example.com'); + git(dir, 'config', 'user.name', 'Test'); + return dir; +} + +test('readChangedFiles surfaces committed, staged, unstaged, untracked, and both rename paths', () => { + const dir = makeRepo(); + try { + fs.writeFileSync(path.join(dir, 'committed.ts'), 'export const a = 1;\n'); + fs.writeFileSync(path.join(dir, 'to-rename.ts'), 'export const b = 2;\n'); + git(dir, 'add', '-A'); + git(dir, 'commit', '-q', '-m', 'base'); + const base = runCmdSync('git', ['rev-parse', 'HEAD'], { cwd: dir }).stdout.trim(); + + // Committed on top of base: an edit plus a rename (git records it as R100). + fs.writeFileSync(path.join(dir, 'committed.ts'), 'export const a = 2;\n'); + git(dir, 'mv', 'to-rename.ts', 'renamed.ts'); + git(dir, 'add', '-A'); + git(dir, 'commit', '-q', '-m', 'work'); + + // Working-tree state the committed diff cannot see. + fs.writeFileSync(path.join(dir, 'staged.ts'), 'export const c = 3;\n'); + git(dir, 'add', 'staged.ts'); + fs.writeFileSync(path.join(dir, 'committed.ts'), 'export const a = 3;\n'); // unstaged edit + fs.writeFileSync(path.join(dir, 'untracked.ts'), 'export const d = 4;\n'); + + const files = readChangedFiles(base, 'HEAD', dir); + assert.ok(files.includes('committed.ts')); + assert.ok(files.includes('to-rename.ts'), 'rename source path must be preserved'); + assert.ok(files.includes('renamed.ts'), 'rename destination path must be preserved'); + assert.ok(files.includes('staged.ts'), 'staged working-tree file must be included'); + assert.ok(files.includes('untracked.ts'), 'untracked file must be included'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +const ALL_SCRIPTS: Record = { + 'format:check': 'x', + lint: 'x', + typecheck: 'x', + 'check:layering': 'x', + 'check:fallow': 'x', + 'check:mcp-metadata': 'x', + build: 'x', + 'test:unit': 'x', + 'test:output-economy': 'x', + 'test:integration:provider': 'x', + 'test:integration:node': 'x', + 'test:integration:progress:check': 'x', + 'test:skillgym': 'x', +}; + +const ARGS = { base: 'origin/main', head: 'HEAD', json: false, run: true }; + +test('runChecks runs local checks in order and stops on the first failure', async () => { + const executed: string[][] = []; + const execute: CommandExecutor = async (command) => { + executed.push(command); + return command.includes('lint') ? 1 : 0; + }; + const plan = selectChecks({ + changedFiles: ['src/daemon/selectors.ts'], + vitestProjects: [], + packageEntryFiles: [], + }); + const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { execute, cwd: '.' }); + assert.equal(code, 1); + // format then lint, then it stops — nothing after the failing check runs. + assert.deepEqual( + executed.map((command) => command[command.length - 1]), + ['format:check', 'lint'], + ); +}); + +test('runChecks skips GitHub-authoritative checks and passes when locals succeed', async () => { + const executed: string[][] = []; + const execute: CommandExecutor = async (command) => { + executed.push(command); + return 0; + }; + // A fail-open plan selects every check, including the non-local build lanes. + const plan = selectChecks({ + changedFiles: ['unknown/path.xyz'], + vitestProjects: [], + packageEntryFiles: [], + }); + assert.equal(plan.failOpen, true); + const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { execute, cwd: '.' }); + assert.equal(code, 0); + const ran = executed.map((command) => command[command.length - 1]); + for (const skipped of ['build:xcuitest', 'build:android-snapshot-helper', 'test:smoke:web']) { + assert.ok(!ran.includes(skipped), `${skipped} is GitHub-authoritative and must not run locally`); + } +}); diff --git a/scripts/check-affected/run.ts b/scripts/check-affected/run.ts index 044400e517..c9525edb66 100644 --- a/scripts/check-affected/run.ts +++ b/scripts/check-affected/run.ts @@ -49,11 +49,29 @@ function parseArgs(argv: readonly string[]): Args { }; } -function readChangedFiles(base: string, head: string): string[] { - const { stdout } = runCmdSync('git', ['diff', '--name-only', '--merge-base', base, head], { - cwd: repoRoot, - }); - return stdout.split('\n').filter(Boolean); +function gitLines(args: string[], cwd: string): string[] { + return runCmdSync('git', args, { cwd }).stdout.split('\n').filter(Boolean); +} + +// Collect every changed file a local plan must account for. The committed diff +// (base..head via merge-base) is the baseline; `--no-renames` keeps BOTH sides +// of a rename so a moved file cannot look docs-only by its destination alone. +// In local mode (head === HEAD) we also fold in working-tree changes (staged + +// unstaged) and untracked files, which the committed diff never sees — ignoring +// uncommitted edits would be an unsafe narrowing of the local feedback loop. +export function readChangedFiles(base: string, head: string, cwd: string = repoRoot): string[] { + const files = new Set( + gitLines(['diff', '--name-only', '--no-renames', '--merge-base', base, head], cwd), + ); + if (head === 'HEAD') { + for (const file of gitLines(['diff', '--name-only', '--no-renames', 'HEAD'], cwd)) { + files.add(file); + } + for (const file of gitLines(['ls-files', '--others', '--exclude-standard'], cwd)) { + files.add(file); + } + } + return [...files].sort(); } async function loadVitestProjects(): Promise { @@ -151,7 +169,29 @@ function printPlanHuman(plan: CheckPlan, args: Args): void { } } -async function runChecks(plan: CheckPlan, pkg: PackageJson, args: Args): Promise { +// How a resolved command is executed. Injectable so the entrypoint's `--run` +// propagation (order, skip of GitHub-authoritative checks, stop-on-failure) is +// testable without spawning real processes. +export type CommandExecutor = (command: string[], cwd: string) => Promise; + +const streamingExecutor: CommandExecutor = async (command, cwd) => { + const result = await runCmdStreaming(command[0]!, command.slice(1), { + cwd, + allowFailure: true, + onStdoutChunk: (chunk) => void process.stdout.write(chunk), + onStderrChunk: (chunk) => void process.stderr.write(chunk), + }); + return result.exitCode; +}; + +export async function runChecks( + plan: CheckPlan, + pkg: PackageJson, + args: Args, + options: { cwd?: string; execute?: CommandExecutor } = {}, +): Promise { + const cwd = options.cwd ?? repoRoot; + const execute = options.execute ?? streamingExecutor; const runnable = plan.checks.map(getCheckSpec).filter((spec: CheckSpec) => spec.localRunnable); const skipped = plan.checks.map(getCheckSpec).filter((spec: CheckSpec) => !spec.localRunnable); for (const spec of skipped) { @@ -162,13 +202,8 @@ async function runChecks(plan: CheckPlan, pkg: PackageJson, args: Args): Promise for (const spec of runnable) { const command = resolveCommand(spec, pkg.scripts, args.base); process.stdout.write(`\n[run] ${spec.id}: ${command.join(' ')}\n`); - const result = await runCmdStreaming(command[0]!, command.slice(1), { - cwd: repoRoot, - allowFailure: true, - onStdoutChunk: (chunk) => void process.stdout.write(chunk), - onStderrChunk: (chunk) => void process.stderr.write(chunk), - }); - if (result.exitCode !== 0) { + const exitCode = await execute(command, cwd); + if (exitCode !== 0) { process.stderr.write(`\ncheck:affected: ${spec.id} failed.\n`); return 1; } From ea414a5671344a842076f73c902e0a677cb94451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 13:12:30 +0000 Subject: [PATCH 6/8] fix(check:affected): union staged + unstaged diffs so they cannot cancel A single `git diff HEAD` nets index against working tree, so a staged add and an unstaged delete of the same file cancel and hide it. Collect `--cached` (staged) and unstaged diffs separately and union them; add a cancellation regression test. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/check-affected/run.test.ts | 31 ++++++++++++++++++++++++++++++ scripts/check-affected/run.ts | 20 +++++++++++-------- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/scripts/check-affected/run.test.ts b/scripts/check-affected/run.test.ts index a0b207d4fe..caa10633ab 100644 --- a/scripts/check-affected/run.test.ts +++ b/scripts/check-affected/run.test.ts @@ -56,6 +56,37 @@ test('readChangedFiles surfaces committed, staged, unstaged, untracked, and both } }); +test('readChangedFiles unions staged and unstaged so a net diff cannot hide a file', () => { + const dir = makeRepo(); + try { + fs.writeFileSync(path.join(dir, 'seed.ts'), 'export const s = 0;\n'); + git(dir, 'add', '-A'); + git(dir, 'commit', '-q', '-m', 'base'); + const base = runCmdSync('git', ['rev-parse', 'HEAD'], { cwd: dir }).stdout.trim(); + + // Stage a new file, then delete it in the working tree. `git diff HEAD` + // nets to nothing (absent in HEAD and in the working tree), so a net + // comparison would drop config.ts entirely. + fs.writeFileSync(path.join(dir, 'config.ts'), 'export const c = 1;\n'); + git(dir, 'add', 'config.ts'); + fs.rmSync(path.join(dir, 'config.ts')); + + assert.deepEqual( + runCmdSync('git', ['diff', '--name-only', 'HEAD'], { cwd: dir }).stdout.split('\n').filter( + Boolean, + ), + [], + 'sanity: the net `git diff HEAD` really does hide config.ts', + ); + assert.ok( + readChangedFiles(base, 'HEAD', dir).includes('config.ts'), + 'staged add + unstaged delete must still surface config.ts', + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + const ALL_SCRIPTS: Record = { 'format:check': 'x', lint: 'x', diff --git a/scripts/check-affected/run.ts b/scripts/check-affected/run.ts index c9525edb66..9a824d3fc8 100644 --- a/scripts/check-affected/run.ts +++ b/scripts/check-affected/run.ts @@ -56,19 +56,23 @@ function gitLines(args: string[], cwd: string): string[] { // Collect every changed file a local plan must account for. The committed diff // (base..head via merge-base) is the baseline; `--no-renames` keeps BOTH sides // of a rename so a moved file cannot look docs-only by its destination alone. -// In local mode (head === HEAD) we also fold in working-tree changes (staged + -// unstaged) and untracked files, which the committed diff never sees — ignoring -// uncommitted edits would be an unsafe narrowing of the local feedback loop. +// In local mode (head === HEAD) we also fold in working-tree changes and +// untracked files, which the committed diff never sees — ignoring uncommitted +// edits would be an unsafe narrowing of the local feedback loop. The staged +// (`--cached`) and unstaged diffs are collected separately and unioned: a +// single `git diff HEAD` nets index against working tree, so a staged add and +// an unstaged delete of the same file would cancel and hide it. export function readChangedFiles(base: string, head: string, cwd: string = repoRoot): string[] { const files = new Set( gitLines(['diff', '--name-only', '--no-renames', '--merge-base', base, head], cwd), ); if (head === 'HEAD') { - for (const file of gitLines(['diff', '--name-only', '--no-renames', 'HEAD'], cwd)) { - files.add(file); - } - for (const file of gitLines(['ls-files', '--others', '--exclude-standard'], cwd)) { - files.add(file); + for (const args of [ + ['diff', '--name-only', '--no-renames', '--cached'], // staged vs HEAD + ['diff', '--name-only', '--no-renames'], // unstaged (working tree vs index) + ['ls-files', '--others', '--exclude-standard'], // untracked + ]) { + for (const file of gitLines(args, cwd)) files.add(file); } } return [...files].sort(); From 05d9cff6fd68ac592dd99906736e19e7e764c4a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 17:06:11 +0200 Subject: [PATCH 7/8] fix(check:affected): cover required suite gates --- docs/agents/testing.md | 14 +++++--- scripts/check-affected/checks.ts | 11 ++++-- scripts/check-affected/model.test.ts | 32 ++++++++++++++++- scripts/check-affected/model.ts | 52 ++++++++++++++++++++++++---- scripts/check-affected/run.test.ts | 3 +- 5 files changed, 98 insertions(+), 14 deletions(-) diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 3aced23e88..6e13c94a6d 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -17,10 +17,15 @@ The selection is derived from repository sources of truth rather than a hand-maintained path map: - **Test ownership** comes from the Vitest project `include`/`exclude` globs in - `vitest.config.ts`. A changed test file selects the project that owns it; a - changed production `src/**` file selects the unit suite that mirrors it. + `vitest.config.ts`. A changed test file selects the project that owns it; + TypeScript support modules inherit the most-specific literal include root; + and a changed production `src/**` file selects the unit suite that mirrors + it. Root `test/integration/*.ts` support modules follow the Node integration + lane. - **Always-on gates** (`lint`, `typecheck`, `layering`, `fallow`, `format`) fire - for their input categories and are never silently skipped. + for their input categories and are never silently skipped. Platform source + also selects the provider-integration and coverage gates required by the + Testing Matrix. - **Commands** are resolved from real `package.json` scripts, so a renamed script fails loudly instead of dropping a gate. - A **small explicit build-ownership layer** covers the paths whose owning build @@ -37,7 +42,8 @@ sides of a rename are classified (a moved file cannot look docs-only by its destination alone). Anything the selector cannot classify — unknown, ambiguous, workflow/tooling, or -a change to the selector's own sources — **fails open to the full check set**. +a change to the selector's own sources (including the `AGENTS.md` Testing +Matrix) — **fails open to the full check set**. The plan documents the rule and changed path behind every selected check. Model and catalog live under `scripts/check-affected/`; the derivation is guarded diff --git a/scripts/check-affected/checks.ts b/scripts/check-affected/checks.ts index ebed78d469..480dc951dc 100644 --- a/scripts/check-affected/checks.ts +++ b/scripts/check-affected/checks.ts @@ -73,8 +73,15 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ }, { id: 'unit', - label: 'Unit suite', - kind: { type: 'script', script: 'test:unit' }, + label: 'Unit + smoke suite', + kind: { type: 'script', script: 'check:unit' }, + ciJobs: ['Coverage', 'Integration Tests'], + localRunnable: true, + }, + { + id: 'coverage', + label: 'Coverage + provider integration suite', + kind: { type: 'script', script: 'test:coverage' }, ciJobs: ['Coverage'], localRunnable: true, }, diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index 0522dfabee..bfccaf97c9 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -84,6 +84,7 @@ test('production source change selects gates + build + unit, with reasons', () = test('platform source additionally selects provider-integration', () => { const result = ids(['src/platforms/apple/core/apps.ts']); assert.ok(result.includes('provider-integration')); + assert.ok(result.includes('coverage')); assert.ok(result.includes('unit')); }); @@ -104,6 +105,21 @@ test('vitest project ownership routes each integration test to its project', () assert.ok(ids(['test/output-economy/baz.test.ts']).includes('output-economy')); }); +test('support modules inherit the most-specific Vitest project include root', () => { + assert.ok( + ids(['test/integration/provider-scenarios/fixtures.ts']).includes('provider-integration'), + ); + assert.ok( + ids(['test/integration/interaction-contract/fixtures.ts']).includes('interaction-contract'), + ); + assert.ok(ids(['test/output-economy/fixtures.ts']).includes('output-economy')); + assert.ok(ids(['src/__tests__/test-utils/session.ts']).includes('unit')); +}); + +test('root node-integration support modules select the node integration suite', () => { + assert.ok(ids(['test/integration/test-helpers.ts']).includes('integration-node')); +}); + test('android-adb stub test routes to the unit suite via its own project', () => { const result = ids(['src/platforms/android/__tests__/notifications.test.ts']); assert.ok(result.includes('unit')); @@ -170,6 +186,7 @@ test('workflow/tooling and selector-owning changes fail open', () => { plan(['scripts/check-affected/model.ts']).failOpenReasons[0]?.rule, 'selector-owning', ); + assert.equal(plan(['AGENTS.md']).failOpenReasons[0]?.rule, 'selector-owning'); }); test('a fail-open path in a mixed changeset forces the full set', () => { @@ -197,7 +214,8 @@ test('every catalog command resolves against package scripts', () => { 'check:fallow': 'x', 'check:mcp-metadata': 'x', build: 'x', - 'test:unit': 'x', + 'check:unit': 'x', + 'test:coverage': 'x', 'test:output-economy': 'x', 'test:integration:provider': 'x', 'test:integration:node': 'x', @@ -227,6 +245,18 @@ test('a missing package script makes command resolution throw', () => { assert.throws(() => resolveCommand(spec, {}, 'origin/main'), /does not exist/); }); +test('unit and coverage checks preserve the Testing Matrix aggregates', () => { + const scripts = { 'check:unit': 'x', 'test:coverage': 'x' }; + const unit = CHECK_CATALOG.find((entry) => entry.id === 'unit')!; + const coverage = CHECK_CATALOG.find((entry) => entry.id === 'coverage')!; + assert.deepEqual(resolveCommand(unit, scripts, 'origin/main'), ['pnpm', 'run', 'check:unit']); + assert.deepEqual(resolveCommand(coverage, scripts, 'origin/main'), [ + 'pnpm', + 'run', + 'test:coverage', + ]); +}); + // Guards the catalog against reality, not fixtures: the self-test above uses a // hand-built scripts/projects map, so this resolves every catalog entry against // the real package.json and checks vitest-project names against the real diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index 1f4a8a8296..ede8ef6f37 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -32,6 +32,7 @@ export type CheckId = | 'mcp-metadata' | 'build' | 'unit' + | 'coverage' | 'output-economy' | 'provider-integration' | 'interaction-contract' @@ -54,6 +55,7 @@ export const ALL_CHECKS: readonly CheckId[] = [ 'mcp-metadata', 'build', 'unit', + 'coverage', 'output-economy', 'provider-integration', 'interaction-contract', @@ -140,6 +142,13 @@ function matchesAny(file: string, globs: readonly string[]): boolean { return globs.some((glob) => globToRegExp(glob).test(file)); } +function literalGlobRoot(glob: string): string { + const firstPatternCharacter = glob.search(/[*?{]/); + const literalPrefix = firstPatternCharacter === -1 ? glob : glob.slice(0, firstPatternCharacter); + const lastSlash = literalPrefix.lastIndexOf('/'); + return lastSlash === -1 ? '' : literalPrefix.slice(0, lastSlash + 1); +} + // --- Path classification helpers ------------------------------------------- const ROOT_TOOLING = new Set([ 'package.json', @@ -155,7 +164,9 @@ const ROOT_TOOLING = new Set([ ]); function isSelectorOwning(file: string): boolean { - return file.startsWith('scripts/check-affected/') && !file.endsWith('.md'); + return ( + file === 'AGENTS.md' || (file.startsWith('scripts/check-affected/') && !file.endsWith('.md')) + ); } function isWorkflowTooling(file: string): boolean { @@ -244,16 +255,40 @@ const srcProdGate: OwnershipRule = ({ file, isSrcProd }) => { '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; }; -const vitestOwnership: OwnershipRule = ({ file }, input) => { +const vitestOwnership: OwnershipRule = ({ file, isTs, isSrcProd }, input) => { const selections: SelectionReason[] = []; - for (const project of input.vitestProjects) { + const directOwners = input.vitestProjects.filter((project) => { const excluded = project.exclude ? matchesAny(file, project.exclude) : false; - if (excluded || !matchesAny(file, project.include)) continue; + return !excluded && matchesAny(file, project.include); + }); + const supportOwners = + isTs && !isSrcProd + ? input.vitestProjects + .flatMap((project) => + project.include.map((glob) => ({ project, root: literalGlobRoot(glob) })), + ) + .filter(({ root }) => root.length > 0 && file.startsWith(root)) + : []; + const longestSupportRoot = Math.max(0, ...supportOwners.map(({ root }) => root.length)); + const owners = + directOwners.length > 0 + ? directOwners + : supportOwners + .filter(({ root }) => root.length === longestSupportRoot) + .map(({ project }) => project); + + for (const project of owners) { const check = vitestCheckId(project.name); if (check) { selections.push( @@ -261,7 +296,9 @@ const vitestOwnership: OwnershipRule = ({ file }, input) => { check, file, `vitest:${project.name}`, - `owned by the ${project.name} Vitest project`, + directOwners.length > 0 + ? `owned by the ${project.name} Vitest project` + : `support module under the ${project.name} Vitest project's include root`, ), ); } @@ -270,7 +307,10 @@ const vitestOwnership: OwnershipRule = ({ file }, input) => { }; const nodeIntegrationOwnership: OwnershipRule = ({ file }) => - matchesAny(file, ['test/integration/*.test.ts']) + matchesAny(file, ['test/integration/*.test.ts']) || + (file.startsWith('test/integration/') && + !file.slice('test/integration/'.length).includes('/') && + file.endsWith('.ts')) ? [reason('integration-node', file, 'node-integration', 'node --test integration smoke')] : []; diff --git a/scripts/check-affected/run.test.ts b/scripts/check-affected/run.test.ts index caa10633ab..5376b2cf41 100644 --- a/scripts/check-affected/run.test.ts +++ b/scripts/check-affected/run.test.ts @@ -95,7 +95,8 @@ const ALL_SCRIPTS: Record = { 'check:fallow': 'x', 'check:mcp-metadata': 'x', build: 'x', - 'test:unit': 'x', + 'check:unit': 'x', + 'test:coverage': 'x', 'test:output-economy': 'x', 'test:integration:provider': 'x', 'test:integration:node': 'x', From 2a9b15b954f3d08486a53f09756430a4625c4667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 17:33:28 +0200 Subject: [PATCH 8/8] refactor(check:affected): delegate tests to vitest --- AGENTS.md | 2 +- docs/agents/testing.md | 20 ++-- scripts/check-affected/checks.ts | 32 +++--- scripts/check-affected/model.test.ts | 121 +++++++--------------- scripts/check-affected/model.ts | 144 +++++---------------------- scripts/check-affected/run.test.ts | 34 +++++-- scripts/check-affected/run.ts | 20 +--- 7 files changed, 117 insertions(+), 256 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f978d0b436..af7dadbea5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -202,7 +202,7 @@ This repo encodes invariants as self-declaring gates. The correct response to a - Do not duplicate session/store/device helpers when a shared helper already exists; if a helper is missing, add it near the concept it serves and export it through the barrel. ## Testing Matrix -- Derive the local check set with `pnpm check:affected --base origin/main` (`--json` for a machine-readable plan). It is a fail-open advisory that mirrors this matrix from repository sources of truth; GitHub CI stays authoritative. See `docs/agents/testing.md`. +- For code changes, run `pnpm check:affected --base origin/main --run` by default (`--json` for a machine-readable plan without execution). It delegates affected Vitest selection to `vitest related` and derives the remaining gates from repository sources of truth; GitHub CI stays authoritative. See `docs/agents/testing.md`. - Docs/skills only: no tests required unless a more specific rule below applies. - CLI help/guidance changes in `src/cli/parser/cli-help.ts`, `src/utils/cli-command-overrides.ts`, or `src/utils/command-schema.ts`: run `pnpm exec vitest run src/cli/parser/__tests__ src/utils/__tests__/command-schema-guards.test.ts`. - SkillGym prompt/assertion changes: run `pnpm test:skillgym:case `; the script builds local CLI help first. For broad validation, use `pnpm test:skillgym`; append `-- --tag fixture-smoke` or `-- --tag skill-guidance` when validating one suite group. diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 6e13c94a6d..e27159726f 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -8,20 +8,22 @@ advisory**: existing GitHub CI stays authoritative and required, and this only narrows the *local* feedback loop. ```sh -pnpm check:affected --base origin/main # human-readable plan -pnpm check:affected --base origin/main --json # stable machine-readable plan -pnpm check:affected --base origin/main --run # also run the local checks +pnpm check:affected --base origin/main --run # default agent loop: plan + run +pnpm check:affected --base origin/main # human-readable plan only +pnpm check:affected --base origin/main --json # machine-readable plan only ``` The selection is derived from repository sources of truth rather than a hand-maintained path map: -- **Test ownership** comes from the Vitest project `include`/`exclude` globs in - `vitest.config.ts`. A changed test file selects the project that owns it; - TypeScript support modules inherit the most-specific literal include root; - and a changed production `src/**` file selects the unit suite that mirrors - it. Root `test/integration/*.ts` support modules follow the Node integration - lane. +- **Affected Vitest tests** are delegated to `vitest related --run`, using + Vitest's own project configuration and static module graph. The selector + passes its complete changed-file set instead of reproducing Vitest globs or + import ownership. Dynamic-import relationships remain outside Vitest's + analysis; GitHub's authoritative full suites still cover that boundary. +- **Non-Vitest suites** retain explicit ownership. Root + `test/integration/*.ts` files use the Node integration lane, SkillGym owns its + harness and skill guidance, and platform/build tools keep their native gates. - **Always-on gates** (`lint`, `typecheck`, `layering`, `fallow`, `format`) fire for their input categories and are never silently skipped. Platform source also selects the provider-integration and coverage gates required by the diff --git a/scripts/check-affected/checks.ts b/scripts/check-affected/checks.ts index 480dc951dc..9051daf1b2 100644 --- a/scripts/check-affected/checks.ts +++ b/scripts/check-affected/checks.ts @@ -1,15 +1,15 @@ // Catalog for the check-affected selector: how each derived CheckId maps to a // runnable command and the authoritative GitHub CI job(s) it mirrors. // -// Commands are resolved from real package.json scripts (or a plain Vitest -// project invocation) so this file stays a thin projection over existing +// Commands are resolved from real package.json scripts or Vitest's native +// affected-test command, so this stays a thin projection over existing // aggregate checks rather than a second source of truth for how to run them. import { ALL_CHECKS, type CheckId } from './model.ts'; export type CheckKind = | { readonly type: 'script'; readonly script: string } - | { readonly type: 'vitest-project'; readonly project: string }; + | { readonly type: 'vitest-related' }; export type CheckSpec = { readonly id: CheckId; @@ -71,6 +71,13 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ ciJobs: ['Packaged CLI Node 22.12'], localRunnable: true, }, + { + id: 'vitest-related', + label: 'Tests related by Vitest module graph', + kind: { type: 'vitest-related' }, + ciJobs: ['Coverage'], + localRunnable: true, + }, { id: 'unit', label: 'Unit + smoke suite', @@ -85,13 +92,6 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ ciJobs: ['Coverage'], localRunnable: true, }, - { - id: 'output-economy', - label: 'Output-economy suite', - kind: { type: 'script', script: 'test:output-economy' }, - ciJobs: ['Coverage'], - localRunnable: true, - }, { id: 'provider-integration', label: 'Provider-backed integration suite', @@ -99,13 +99,6 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [ ciJobs: ['Integration Tests', 'Coverage'], localRunnable: true, }, - { - id: 'interaction-contract', - label: 'Interaction-contract suite', - kind: { type: 'vitest-project', project: 'interaction-contract' }, - ciJobs: ['Coverage'], - localRunnable: true, - }, { id: 'integration-node', label: 'Node integration smoke', @@ -173,9 +166,10 @@ export function resolveCommand( spec: CheckSpec, scripts: Readonly>, base: string, + changedFiles: readonly string[] = [], ): string[] { - if (spec.kind.type === 'vitest-project') { - return ['pnpm', 'exec', 'vitest', 'run', '--project', spec.kind.project]; + if (spec.kind.type === 'vitest-related') { + return ['pnpm', 'exec', 'vitest', 'related', '--run', '--passWithNoTests', ...changedFiles]; } const { script } = spec.kind; if (!(script in scripts)) { diff --git a/scripts/check-affected/model.test.ts b/scripts/check-affected/model.test.ts index bfccaf97c9..8bd9efc78d 100644 --- a/scripts/check-affected/model.test.ts +++ b/scripts/check-affected/model.test.ts @@ -4,40 +4,11 @@ import path from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { assertCatalogComplete, CHECK_CATALOG, resolveCommand } from './checks.ts'; -import { - ALL_CHECKS, - globToRegExp, - selectChecks, - type CheckId, - type SelectInput, - type VitestProject, -} from './model.ts'; - -// Mirrors the real vitest.config.ts projects so tests exercise the same -// include/exclude ownership the runner loads at runtime. -const VITEST_PROJECTS: VitestProject[] = [ - { - name: 'unit-core', - include: ['src/**/*.test.ts', 'scripts/__tests__/help-conformance-bench.test.ts'], - exclude: [ - 'src/platforms/android/__tests__/{app-lifecycle-install,app-lifecycle-open,device-input-state,input-actions,notifications,settings}.test.ts', - ], - }, - { - name: 'android-adb', - include: [ - 'src/platforms/android/__tests__/{app-lifecycle-install,app-lifecycle-open,device-input-state,input-actions,notifications,settings}.test.ts', - ], - }, - { name: 'provider-integration', include: ['test/integration/provider-scenarios/**/*.test.ts'] }, - { name: 'interaction-contract', include: ['test/integration/interaction-contract/**/*.test.ts'] }, - { name: 'output-economy', include: ['test/output-economy/**/*.test.ts'] }, -]; +import { ALL_CHECKS, selectChecks, type CheckId, type SelectInput } from './model.ts'; function plan(changedFiles: string[], extra: Partial = {}) { return selectChecks({ changedFiles, - vitestProjects: VITEST_PROJECTS, packageEntryFiles: ['src/index.ts', 'src/selectors.ts'], ...extra, }); @@ -47,20 +18,7 @@ function ids(changedFiles: string[]): CheckId[] { return plan(changedFiles).checks; } -test('glob matcher handles **, *, ?, and brace groups like the vitest config', () => { - assert.ok(globToRegExp('src/**/*.test.ts').test('src/a/b/c.test.ts')); - assert.ok(globToRegExp('src/**/*.test.ts').test('src/a.test.ts')); - assert.ok(!globToRegExp('src/**/*.test.ts').test('src/a.ts')); - assert.ok(globToRegExp('test/output-economy/**/*.test.ts').test('test/output-economy/x.test.ts')); - assert.ok( - globToRegExp('src/platforms/android/__tests__/{notifications,settings}.test.ts').test( - 'src/platforms/android/__tests__/settings.test.ts', - ), - ); - assert.ok(!globToRegExp('a/*.ts').test('a/b/c.ts')); -}); - -test('production source change selects gates + build + unit, with reasons', () => { +test('production source selects static/build gates and delegates tests to Vitest', () => { const result = plan(['src/daemon/selectors.ts']); assert.equal(result.failOpen, false); for (const id of [ @@ -70,7 +28,7 @@ test('production source change selects gates + build + unit, with reasons', () = 'layering', 'fallow', 'build', - 'unit', + 'vitest-related', ] as const) { assert.ok(result.checks.includes(id), `expected ${id}`); } @@ -85,44 +43,35 @@ test('platform source additionally selects provider-integration', () => { const result = ids(['src/platforms/apple/core/apps.ts']); assert.ok(result.includes('provider-integration')); assert.ok(result.includes('coverage')); - assert.ok(result.includes('unit')); + assert.ok(result.includes('vitest-related')); }); -test('unit test file selects only the unit suite (+ gates), not integration projects', () => { +test('unit test files delegate affected-test discovery to Vitest', () => { const result = ids(['src/daemon/selectors.test.ts']); - assert.ok(result.includes('unit')); + assert.ok(result.includes('vitest-related')); + assert.ok(!result.includes('unit')); assert.ok(!result.includes('provider-integration')); - assert.ok(!result.includes('output-economy')); }); -test('vitest project ownership routes each integration test to its project', () => { - assert.ok( - ids(['test/integration/provider-scenarios/foo.test.ts']).includes('provider-integration'), - ); - assert.ok( - ids(['test/integration/interaction-contract/bar.test.ts']).includes('interaction-contract'), - ); - assert.ok(ids(['test/output-economy/baz.test.ts']).includes('output-economy')); -}); - -test('support modules inherit the most-specific Vitest project include root', () => { - assert.ok( - ids(['test/integration/provider-scenarios/fixtures.ts']).includes('provider-integration'), - ); - assert.ok( - ids(['test/integration/interaction-contract/fixtures.ts']).includes('interaction-contract'), - ); - assert.ok(ids(['test/output-economy/fixtures.ts']).includes('output-economy')); - assert.ok(ids(['src/__tests__/test-utils/session.ts']).includes('unit')); +test('Vitest owns project and support-module relationships through one check', () => { + for (const file of [ + 'test/integration/provider-scenarios/foo.test.ts', + 'test/integration/provider-scenarios/fixtures.ts', + 'test/integration/interaction-contract/fixtures.ts', + 'test/output-economy/fixtures.ts', + 'src/__tests__/test-utils/session.ts', + ]) { + assert.ok(ids([file]).includes('vitest-related'), `expected Vitest ownership for ${file}`); + } }); test('root node-integration support modules select the node integration suite', () => { assert.ok(ids(['test/integration/test-helpers.ts']).includes('integration-node')); }); -test('android-adb stub test routes to the unit suite via its own project', () => { +test('android-adb stub test delegates project ownership to Vitest', () => { const result = ids(['src/platforms/android/__tests__/notifications.test.ts']); - assert.ok(result.includes('unit')); + assert.ok(result.includes('vitest-related')); }); test('Swift runner change selects the swift-runner build', () => { @@ -216,7 +165,6 @@ test('every catalog command resolves against package scripts', () => { build: 'x', 'check:unit': 'x', 'test:coverage': 'x', - 'test:output-economy': 'x', 'test:integration:provider': 'x', 'test:integration:node': 'x', 'test:integration:progress:check': 'x', @@ -257,14 +205,27 @@ test('unit and coverage checks preserve the Testing Matrix aggregates', () => { ]); }); +test('vitest-related delegates changed paths to Vitest instead of modeling projects', () => { + const related = CHECK_CATALOG.find((entry) => entry.id === 'vitest-related')!; + assert.deepEqual(resolveCommand(related, {}, 'origin/main', ['src/a.ts', 'test/fixture.ts']), [ + 'pnpm', + 'exec', + 'vitest', + 'related', + '--run', + '--passWithNoTests', + 'src/a.ts', + 'test/fixture.ts', + ]); +}); + // Guards the catalog against reality, not fixtures: the self-test above uses a -// hand-built scripts/projects map, so this resolves every catalog entry against -// the real package.json and checks vitest-project names against the real -// vitest.config.ts. A renamed/removed script or project fails here instead of +// hand-built scripts map, so this resolves every catalog entry against the real +// package.json. A renamed/removed script fails here instead of // leaving `pnpm check:affected` broken on the exact command the docs advertise. const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); -test('catalog resolves against the real package.json + vitest.config.ts', () => { +test('catalog resolves against the real package.json', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts?: Record; }; @@ -275,16 +236,6 @@ test('catalog resolves against the real package.json + vitest.config.ts', () => `catalog entry "${spec.id}" must resolve against the real package.json`, ); } - - const vitestConfig = fs.readFileSync(path.join(repoRoot, 'vitest.config.ts'), 'utf8'); - for (const spec of CHECK_CATALOG) { - if (spec.kind.type === 'vitest-project') { - assert.ok( - vitestConfig.includes(`name: '${spec.kind.project}'`), - `vitest project "${spec.kind.project}" must exist in vitest.config.ts`, - ); - } - } }); test('every catalog CI job maps to a real workflow job (no fabricated checks)', () => { diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index ede8ef6f37..2bf7a2313d 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -4,10 +4,9 @@ // stable, machine-readable reasoning. It is intentionally source-of-truth // derived rather than a hand-maintained path-to-check registry (issue #1181): // -// - test ownership comes from the Vitest project include/exclude globs -// (passed in from vitest.config.ts) — a changed test file selects the -// project that owns it, and a changed production source file selects the -// unit suite that mirrors it (AGENTS.md: test topology mirrors src 1:1); +// - 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, @@ -31,11 +30,10 @@ export type CheckId = | 'fallow' | 'mcp-metadata' | 'build' + | 'vitest-related' | 'unit' | 'coverage' - | 'output-economy' | 'provider-integration' - | 'interaction-contract' | 'integration-node' | 'integration-progress' | 'swift-runner' @@ -54,11 +52,10 @@ export const ALL_CHECKS: readonly CheckId[] = [ 'fallow', 'mcp-metadata', 'build', + 'vitest-related', 'unit', 'coverage', - 'output-economy', 'provider-integration', - 'interaction-contract', 'integration-node', 'integration-progress', 'swift-runner', @@ -68,12 +65,6 @@ export const ALL_CHECKS: readonly CheckId[] = [ 'skillgym', ]; -export type VitestProject = { - name: string; - include: readonly string[]; - exclude?: readonly string[]; -}; - export type SelectionReason = { check: CheckId; path: string; @@ -97,58 +88,10 @@ export type CheckPlan = { export type SelectInput = { changedFiles: readonly string[]; - vitestProjects: readonly VitestProject[]; // Public package entry source files, derived from package.json `exports`. packageEntryFiles?: readonly string[]; }; -// --- Minimal glob matcher (supports the subset Vitest configs use) ---------- -// Handles `**`, `*`, `?`, and non-nested `{a,b,c}` brace groups. -export function globToRegExp(glob: string): RegExp { - let out = '^'; - for (let i = 0; i < glob.length; i++) { - const char = glob[i]!; - if (char === '*') { - if (glob[i + 1] === '*') { - i++; - if (glob[i + 1] === '/') { - i++; - out += '(?:[^/]+/)*'; - } else { - out += '.*'; - } - } else { - out += '[^/]*'; - } - } else if (char === '?') { - out += '[^/]'; - } else if (char === '{') { - const end = glob.indexOf('}', i); - const body = glob.slice(i + 1, end); - out += `(?:${body.split(',').map(escapeRegExp).join('|')})`; - i = end; - } else { - out += escapeRegExp(char); - } - } - return new RegExp(`${out}$`); -} - -function escapeRegExp(text: string): string { - return text.replace(/[.+^${}()|[\]\\]/g, '\\$&'); -} - -function matchesAny(file: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(file)); -} - -function literalGlobRoot(glob: string): string { - const firstPatternCharacter = glob.search(/[*?{]/); - const literalPrefix = firstPatternCharacter === -1 ? glob : glob.slice(0, firstPatternCharacter); - const lastSlash = literalPrefix.lastIndexOf('/'); - return lastSlash === -1 ? '' : literalPrefix.slice(0, lastSlash + 1); -} - // --- Path classification helpers ------------------------------------------- const ROOT_TOOLING = new Set([ 'package.json', @@ -191,22 +134,6 @@ function isTestPath(file: string): boolean { return /\.test\.ts$/.test(file) || /(?:^|\/)__tests__\//.test(file); } -function vitestCheckId(project: string): CheckId | null { - switch (project) { - case 'unit-core': - case 'android-adb': - return 'unit'; - case 'provider-integration': - return 'provider-integration'; - case 'interaction-contract': - return 'interaction-contract'; - case 'output-economy': - return 'output-economy'; - default: - return null; - } -} - // --- Ownership rules -------------------------------------------------------- // Each rule inspects one changed file and returns the reasons it contributes. // Splitting the selection into small, independent rules keeps every function @@ -245,7 +172,6 @@ const srcProdGate: OwnershipRule = ({ file, isSrcProd }) => { 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'), - reason('unit', file, 'src-prod', 'unit suite mirrors production source 1:1'), ]; if (file.startsWith('src/platforms/')) { selections.push( @@ -266,51 +192,31 @@ const srcProdGate: OwnershipRule = ({ file, isSrcProd }) => { return selections; }; -const vitestOwnership: OwnershipRule = ({ file, isTs, isSrcProd }, input) => { - const selections: SelectionReason[] = []; - const directOwners = input.vitestProjects.filter((project) => { - const excluded = project.exclude ? matchesAny(file, project.exclude) : false; - return !excluded && matchesAny(file, project.include); - }); - const supportOwners = - isTs && !isSrcProd - ? input.vitestProjects - .flatMap((project) => - project.include.map((glob) => ({ project, root: literalGlobRoot(glob) })), - ) - .filter(({ root }) => root.length > 0 && file.startsWith(root)) - : []; - const longestSupportRoot = Math.max(0, ...supportOwners.map(({ root }) => root.length)); - const owners = - directOwners.length > 0 - ? directOwners - : supportOwners - .filter(({ root }) => root.length === longestSupportRoot) - .map(({ project }) => project); +function isNodeIntegrationPath(file: string): boolean { + return ( + file.startsWith('test/integration/') && + !file.slice('test/integration/'.length).includes('/') && + file.endsWith('.ts') + ); +} - for (const project of owners) { - const check = vitestCheckId(project.name); - if (check) { - selections.push( +const vitestRelatedOwnership: OwnershipRule = ({ file, isTs, underSrc, underTest }) => + isTs && + (underSrc || underTest) && + !isNodeIntegrationPath(file) && + !file.startsWith('test/skillgym/') + ? [ reason( - check, + 'vitest-related', file, - `vitest:${project.name}`, - directOwners.length > 0 - ? `owned by the ${project.name} Vitest project` - : `support module under the ${project.name} Vitest project's include root`, + 'vitest:related', + 'Vitest resolves affected tests through its static module graph', ), - ); - } - } - return selections; -}; + ] + : []; const nodeIntegrationOwnership: OwnershipRule = ({ file }) => - matchesAny(file, ['test/integration/*.test.ts']) || - (file.startsWith('test/integration/') && - !file.slice('test/integration/'.length).includes('/') && - file.endsWith('.ts')) + isNodeIntegrationPath(file) ? [reason('integration-node', file, 'node-integration', 'node --test integration smoke')] : []; @@ -377,7 +283,7 @@ const OWNERSHIP_RULES: readonly OwnershipRule[] = [ formatGate, staticTsGates, srcProdGate, - vitestOwnership, + vitestRelatedOwnership, nodeIntegrationOwnership, skillgymOwnership, buildOwnership, diff --git a/scripts/check-affected/run.test.ts b/scripts/check-affected/run.test.ts index 5376b2cf41..988d485cd9 100644 --- a/scripts/check-affected/run.test.ts +++ b/scripts/check-affected/run.test.ts @@ -72,9 +72,9 @@ test('readChangedFiles unions staged and unstaged so a net diff cannot hide a fi fs.rmSync(path.join(dir, 'config.ts')); assert.deepEqual( - runCmdSync('git', ['diff', '--name-only', 'HEAD'], { cwd: dir }).stdout.split('\n').filter( - Boolean, - ), + runCmdSync('git', ['diff', '--name-only', 'HEAD'], { cwd: dir }) + .stdout.split('\n') + .filter(Boolean), [], 'sanity: the net `git diff HEAD` really does hide config.ts', ); @@ -97,7 +97,6 @@ const ALL_SCRIPTS: Record = { build: 'x', 'check:unit': 'x', 'test:coverage': 'x', - 'test:output-economy': 'x', 'test:integration:provider': 'x', 'test:integration:node': 'x', 'test:integration:progress:check': 'x', @@ -114,7 +113,6 @@ test('runChecks runs local checks in order and stops on the first failure', asyn }; const plan = selectChecks({ changedFiles: ['src/daemon/selectors.ts'], - vitestProjects: [], packageEntryFiles: [], }); const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { execute, cwd: '.' }); @@ -126,6 +124,26 @@ test('runChecks runs local checks in order and stops on the first failure', asyn ); }); +test('runChecks passes the selector change set to Vitest related', async () => { + const executed: string[][] = []; + const execute: CommandExecutor = async (command) => { + executed.push(command); + return 0; + }; + const changedFiles = ['src/daemon/selectors.ts', 'src/daemon/selectors.test.ts']; + const plan = selectChecks({ changedFiles, packageEntryFiles: [] }); + const code = await runChecks(plan, { scripts: ALL_SCRIPTS }, ARGS, { + execute, + cwd: '.', + changedFiles, + }); + assert.equal(code, 0); + assert.deepEqual( + executed.find((command) => command.includes('related')), + ['pnpm', 'exec', 'vitest', 'related', '--run', '--passWithNoTests', ...changedFiles], + ); +}); + test('runChecks skips GitHub-authoritative checks and passes when locals succeed', async () => { const executed: string[][] = []; const execute: CommandExecutor = async (command) => { @@ -135,7 +153,6 @@ test('runChecks skips GitHub-authoritative checks and passes when locals succeed // A fail-open plan selects every check, including the non-local build lanes. const plan = selectChecks({ changedFiles: ['unknown/path.xyz'], - vitestProjects: [], packageEntryFiles: [], }); assert.equal(plan.failOpen, true); @@ -143,6 +160,9 @@ test('runChecks skips GitHub-authoritative checks and passes when locals succeed assert.equal(code, 0); const ran = executed.map((command) => command[command.length - 1]); for (const skipped of ['build:xcuitest', 'build:android-snapshot-helper', 'test:smoke:web']) { - assert.ok(!ran.includes(skipped), `${skipped} is GitHub-authoritative and must not run locally`); + assert.ok( + !ran.includes(skipped), + `${skipped} is GitHub-authoritative and must not run locally`, + ); } }); diff --git a/scripts/check-affected/run.ts b/scripts/check-affected/run.ts index 9a824d3fc8..86d087497d 100644 --- a/scripts/check-affected/run.ts +++ b/scripts/check-affected/run.ts @@ -17,7 +17,7 @@ import { resolveCommand, type CheckSpec, } from './checks.ts'; -import { ALL_CHECKS, selectChecks, type CheckPlan, type VitestProject } from './model.ts'; +import { ALL_CHECKS, selectChecks, type CheckPlan } from './model.ts'; type Args = { base: string; head: string; json: boolean; run: boolean }; @@ -78,16 +78,6 @@ export function readChangedFiles(base: string, head: string, cwd: string = repoR return [...files].sort(); } -async function loadVitestProjects(): Promise { - const module = (await import(pathToFileURL(path.join(repoRoot, 'vitest.config.ts')).href)) as { - default: { test?: { projects?: Array<{ test?: VitestProject }> } }; - }; - const projects = module.default.test?.projects ?? []; - return projects - .map((project) => project.test) - .filter((project): project is VitestProject => Boolean(project?.name && project.include)); -} - type PackageJson = { scripts: Record; exports?: Record; @@ -192,7 +182,7 @@ export async function runChecks( plan: CheckPlan, pkg: PackageJson, args: Args, - options: { cwd?: string; execute?: CommandExecutor } = {}, + options: { cwd?: string; execute?: CommandExecutor; changedFiles?: readonly string[] } = {}, ): Promise { const cwd = options.cwd ?? repoRoot; const execute = options.execute ?? streamingExecutor; @@ -204,7 +194,7 @@ export async function runChecks( ); } for (const spec of runnable) { - const command = resolveCommand(spec, pkg.scripts, args.base); + const command = resolveCommand(spec, pkg.scripts, args.base, options.changedFiles); process.stdout.write(`\n[run] ${spec.id}: ${command.join(' ')}\n`); const exitCode = await execute(command, cwd); if (exitCode !== 0) { @@ -223,18 +213,16 @@ async function main(argv = process.argv.slice(2)): Promise { // Validate every catalog command resolves before selecting, so a broken // catalog fails loudly rather than silently dropping a gate. for (const spec of CHECK_CATALOG) resolveCommand(spec, pkg.scripts, args.base); - const vitestProjects = await loadVitestProjects(); const changedFiles = readChangedFiles(args.base, args.head); const plan = selectChecks({ changedFiles, - vitestProjects, packageEntryFiles: packageEntryFiles(pkg), }); if (args.json) printPlanJson(plan, args); else printPlanHuman(plan, args); - if (args.run) return await runChecks(plan, pkg, args); + if (args.run) return await runChecks(plan, pkg, args, { changedFiles }); return 0; }