diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52d9747b6b..9834a4f5f6 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 scripts/check-affected/run.test.ts + packaged-cli-node-22-12: name: Packaged CLI Node 22.12 runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 0fcbdc1a3f..af7dadbea5 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 +- 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 b369135322..e27159726f 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -1,5 +1,56 @@ # 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 --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: + +- **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 + 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 + 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 (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 +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/package.json b/package.json index cd6ca93e58..b2acc1c031 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 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 new file mode 100644 index 0000000000..9051daf1b2 --- /dev/null +++ b/scripts/check-affected/checks.ts @@ -0,0 +1,197 @@ +// 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 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-related' }; + +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: 'vitest-related', + label: 'Tests related by Vitest module graph', + kind: { type: 'vitest-related' }, + ciJobs: ['Coverage'], + localRunnable: true, + }, + { + id: '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, + }, + { + id: 'provider-integration', + label: 'Provider-backed integration suite', + kind: { type: 'script', script: 'test:integration:provider' }, + ciJobs: ['Integration Tests', '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' }, + // 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, + }, +]; + +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, + changedFiles: readonly string[] = [], +): string[] { + if (spec.kind.type === 'vitest-related') { + return ['pnpm', 'exec', 'vitest', 'related', '--run', '--passWithNoTests', ...changedFiles]; + } + 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..8bd9efc78d --- /dev/null +++ b/scripts/check-affected/model.test.ts @@ -0,0 +1,264 @@ +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, selectChecks, type CheckId, type SelectInput } from './model.ts'; + +function plan(changedFiles: string[], extra: Partial = {}) { + return selectChecks({ + changedFiles, + packageEntryFiles: ['src/index.ts', 'src/selectors.ts'], + ...extra, + }); +} + +function ids(changedFiles: string[]): CheckId[] { + return plan(changedFiles).checks; +} + +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 [ + 'format', + 'lint', + 'typecheck', + 'layering', + 'fallow', + 'build', + 'vitest-related', + ] 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('coverage')); + assert.ok(result.includes('vitest-related')); +}); + +test('unit test files delegate affected-test discovery to Vitest', () => { + const result = ids(['src/daemon/selectors.test.ts']); + assert.ok(result.includes('vitest-related')); + assert.ok(!result.includes('unit')); + assert.ok(!result.includes('provider-integration')); +}); + +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 delegates project ownership to Vitest', () => { + const result = ids(['src/platforms/android/__tests__/notifications.test.ts']); + assert.ok(result.includes('vitest-related')); +}); + +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('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('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'); + assert.equal(plan(['vitest.config.ts']).failOpenReasons[0]?.rule, 'workflow-tooling'); + assert.equal( + 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', () => { + 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', + 'check:unit': 'x', + 'test:coverage': '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/); +}); + +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', + ]); +}); + +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 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', () => { + 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`, + ); + } +}); + +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 new file mode 100644 index 0000000000..2bf7a2313d --- /dev/null +++ b/scripts/check-affected/model.ts @@ -0,0 +1,375 @@ +// 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): +// +// - Vitest owns affected-test discovery through its native `related` +// command and static module graph; this model only decides when that +// existing tool applies; +// - the lint/typecheck/layering/fallow gates are always-on for their input +// categories, so they are never silently skipped (issue constraint); +// - a small explicit build-ownership layer covers Swift, Android helpers, +// the macOS helper, MCP metadata, and the public package surface — the +// only paths whose owning build the sources of truth cannot derive; +// - 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 +// ambiguous files under an owned root that only resolve to `format` (e.g. a +// non-.ts fixture whose owning suite cannot be derived). Existing GitHub CI +// remains authoritative; this only optimizes local/agent feedback. + +export type CheckId = + | 'format' + | 'lint' + | 'typecheck' + | 'layering' + | 'fallow' + | 'mcp-metadata' + | 'build' + | 'vitest-related' + | 'unit' + | 'coverage' + | 'provider-integration' + | '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', + 'vitest-related', + 'unit', + 'coverage', + 'provider-integration', + 'integration-node', + 'integration-progress', + 'swift-runner', + 'android-helpers', + 'macos-helper', + 'web-smoke', + 'skillgym', +]; + +export type SelectionReason = { + check: CheckId; + path: string; + rule: string; + detail: string; +}; + +export type FailOpenReason = { + path: string; + rule: 'workflow-tooling' | 'selector-owning' | 'unknown-path' | 'ambiguous-path'; + detail: string; +}; + +export type CheckPlan = { + failOpen: boolean; + checks: CheckId[]; + reasons: SelectionReason[]; + failOpenReasons: FailOpenReason[]; + docsOnlyPaths: string[]; +}; + +export type SelectInput = { + changedFiles: readonly string[]; + // Public package entry source files, derived from package.json `exports`. + packageEntryFiles?: readonly string[]; +}; + +// --- Path classification helpers ------------------------------------------- +const ROOT_TOOLING = new Set([ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'tsconfig.json', + 'tsconfig.lib.json', + 'tsdown.config.ts', + 'vitest.config.ts', + '.oxlintrc.json', + '.oxfmtrc.json', + '.npmrc', +]); + +function isSelectorOwning(file: string): boolean { + return ( + file === 'AGENTS.md' || (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 { + // 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/') || + file === 'README.md' || + file === 'LICENSE' || + file.endsWith('.md') + ); +} + +function isTestPath(file: string): boolean { + return /\.test\.ts$/.test(file) || /(?:^|\/)__tests__\//.test(file); +} + +// --- Ownership rules -------------------------------------------------------- +// Each rule inspects one changed file and returns the reasons it contributes. +// Splitting the selection into small, independent rules keeps every function +// simple and makes the derivation self-documenting. +type FileFacts = { + file: string; + isTs: boolean; + underSrc: boolean; + underTest: boolean; + 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'), + ]; + if (file.startsWith('src/platforms/')) { + selections.push( + reason( + 'provider-integration', + file, + 'platform-src', + 'platform source shapes device/provider wire behavior', + ), + reason( + 'coverage', + file, + 'platform-src', + 'Testing Matrix requires coverage for platform/device-response changes', + ), + ); + } + return selections; +}; + +function isNodeIntegrationPath(file: string): boolean { + return ( + file.startsWith('test/integration/') && + !file.slice('test/integration/'.length).includes('/') && + file.endsWith('.ts') + ); +} + +const vitestRelatedOwnership: OwnershipRule = ({ file, isTs, underSrc, underTest }) => + isTs && + (underSrc || underTest) && + !isNodeIntegrationPath(file) && + !file.startsWith('test/skillgym/') + ? [ + reason( + 'vitest-related', + file, + 'vitest:related', + 'Vitest resolves affected tests through its static module graph', + ), + ] + : []; + +const nodeIntegrationOwnership: OwnershipRule = ({ file }) => + isNodeIntegrationPath(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; + 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, + vitestRelatedOwnership, + nodeIntegrationOwnership, + skillgymOwnership, + 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 reasons: SelectionReason[] = []; + const failOpenReasons: FailOpenReason[] = []; + const docsOnlyPaths: string[] = []; + + for (const file of input.changedFiles) { + const failOpen = failOpenFor(file); + if (failOpen) { + failOpenReasons.push(failOpen); + continue; + } + if (isDocs(file)) { + docsOnlyPaths.push(file); + continue; + } + const facts = fileFacts(file); + const selections = OWNERSHIP_RULES.flatMap((rule) => rule(facts, input)); + if (selections.length === 0) { + failOpenReasons.push({ + path: file, + rule: 'unknown-path', + detail: 'path has no derivable owner; run the full set to stay safe', + }); + continue; + } + // `format` is an always-on gate, not evidence of test/build ownership. A + // file we can only route to formatting (e.g. a non-.ts fixture under + // test/) has no derivable suite owner, so treat it as ambiguous and fail + // open rather than silently narrowing to just `format`. + if (!selections.some((selection) => selection.check !== 'format')) { + failOpenReasons.push({ + path: file, + rule: 'ambiguous-path', + detail: 'only formatting is derivable; no test/build owner, so run the full set', + }); + continue; + } + reasons.push(...selections); + } + + if (failOpenReasons.length > 0) { + return { failOpen: true, checks: [...ALL_CHECKS], reasons, failOpenReasons, docsOnlyPaths }; + } + const selected = new Set(reasons.map((entry) => entry.check)); + return { + failOpen: false, + checks: ALL_CHECKS.filter((check) => selected.has(check)), + reasons, + failOpenReasons, + docsOnlyPaths, + }; +} diff --git a/scripts/check-affected/run.test.ts b/scripts/check-affected/run.test.ts new file mode 100644 index 0000000000..988d485cd9 --- /dev/null +++ b/scripts/check-affected/run.test.ts @@ -0,0 +1,168 @@ +// 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 }); + } +}); + +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', + typecheck: 'x', + 'check:layering': 'x', + 'check:fallow': 'x', + 'check:mcp-metadata': 'x', + build: 'x', + 'check:unit': 'x', + 'test:coverage': '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'], + 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 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) => { + 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'], + 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 new file mode 100644 index 0000000000..86d087497d --- /dev/null +++ b/scripts/check-affected/run.ts @@ -0,0 +1,237 @@ +// 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 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, + getCheckSpec, + resolveCommand, + type CheckSpec, +} from './checks.ts'; +import { ALL_CHECKS, selectChecks, type CheckPlan } from './model.ts'; + +type Args = { base: string; head: string; json: boolean; run: boolean }; + +const repoRoot = runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim(); + +const USAGE = 'Usage: pnpm check:affected [--base ] [--head ] [--json] [--run]\n'; + +function parseArgs(argv: readonly string[]): Args { + 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 { + base: values.base ?? 'origin/main', + head: values.head ?? 'HEAD', + json: Boolean(values.json), + run: Boolean(values.run), + }; +} + +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 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 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(); +} + +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 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) { + writeLine('No local checks selected.'); + return; + } + 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) { + writeLine(`Docs-only changes ignored: ${plan.docsOnlyPaths.length} file(s).`); + } +} + +// 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; changedFiles?: readonly string[] } = {}, +): 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) { + 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, options.changedFiles); + process.stdout.write(`\n[run] ${spec.id}: ${command.join(' ')}\n`); + const exitCode = await execute(command, cwd); + if (exitCode !== 0) { + 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 changedFiles = readChangedFiles(args.base, args.head); + const plan = selectChecks({ + changedFiles, + packageEntryFiles: packageEntryFiles(pkg), + }); + + if (args.json) printPlanJson(plan, args); + else printPlanHuman(plan, args); + + if (args.run) return await runChecks(plan, pkg, args, { changedFiles }); + 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); + }, + ); +}