Skip to content
Merged
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <case-id>`; 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.
Expand Down
51 changes: 51 additions & 0 deletions docs/agents/testing.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,56 @@
# Testing Notes

## Affected-check selector (`pnpm check:affected`)

`pnpm check:affected --base <ref>` 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:
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
197 changes: 197 additions & 0 deletions scripts/check-affected/checks.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>,
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(', ')}].`,
);
}
}
Loading
Loading