refactor(ci): make gate ownership structural (#1429) (#1753)

* test(ci): prove every registered gate is owned and reachable (#1429)

A check that silently stops running looks exactly like a green build. Two
suites had already stopped: `check:tmpdir-leaks` (with its model tests) and
`test:fixture-cache` are real package scripts that no workflow ran, reachable
only through the `check:unit` aggregate CI never invokes.

`CHECK_CATALOG` becomes the registry of every check and `pnpm gate <id>` the
only way CI runs one, so finding what a lane runs is a scan for `pnpm gate`
rather than an attempt to interpret shell. `pnpm check:gate-manifest` then
asserts against the real workflows that every registered check is run by some
qualifying lane (per unit, not per script name), that every check the real
selector activates for a path is run by a lane that path would start (#1420's
class), and that every Vitest project and suite script belongs to a check.

The wiring that keeps those honest is asserted too: a gate id must name a
registered check, an `if:` must be ruled on in GATE_CONDITIONS so `if: false`
unowns what it guards, an action declared to run a gate is proven to, and a
job whose steps the loader cannot open fails closed.

It deliberately does not try to prove CI runs project code only through
`pnpm gate`. Whether a shell block executes project code is not decidable from
its text, so shell this model does not recognise earns no ownership credit —
the failure direction is a check reported unowned, never one waved through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* test(ci): update the two suites that assert on rewired workflow text

`scripts/mutation/workflow.test.ts` and `test/ci/trusted-fixture-artifact.test.mjs`
read the workflow and action files and assert on their command text, so routing
those steps through `pnpm gate <id>` moved what they were matching.

They are the two suites the manifest cannot help with: it proves a gate is still
run, not that a test asserting on how CI spells a command was updated with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* fix(ci): credit gates by execution shape, and keep every guard

Three ways the manifest could report a gate as owned when it does not run.

1. Crediting was a substring scan over `run:`, which #1429 explicitly rules
   out — "do not infer reachability from a command name merely appearing in
   workflow text". `false && pnpm gate x`, a gate inside `if false; then … fi`,
   one named in a heredoc, and `echo pnpm gate x` all credited it. There is a
   live instance: conformance-regenerate.yml's "Fail if regeneration changed
   anything" step names `pnpm gate maestro-regenerate` inside an error message
   telling a human to run it, and that credited the gate.

   A gate now counts only as the first command segment of a line, and a body
   carrying shell structure earns nothing. Reachability inside a script is not
   decidable, so this does not try: unrecognised shape means no credit and the
   check reports unowned. `VAR=$(pnpm gate x …)` is read, since the assignment
   form is unambiguous and the gate runs.

2. Job-level `if:` was not modelled at all, though six live jobs carry one, so
   a job that cannot run still credited every gate inside it. Two conditions on
   the mutation lanes are now declared.

3. A caller's `if:` REPLACED the guard on a nested composite-action step
   (`guard[0] ?? step.condition`), so an outer `always()` erased an inner
   `if: false`. Steps carry every guard between the lane and the step.

Also corrects two source comments that still claimed project code run outside
the runner fails the manifest. It does not: such a step earns no credit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* ci: add the run-gate action that names a gate structurally

The seam the ownership proof will read instead of shell. A lane says which
gate it runs in `with.gate`, a typed input the manifest reads straight out of
the YAML and validates against CHECK_CATALOG.

Nothing here is wired yet — the ~60 call sites and the model change follow.
Added first so the target of that conversion is reviewable on its own.

`args` cannot select which gate runs; it is appended after the id, so the
worst a wrong value does is fail the gate it already named. There is no
`|| true` and no output capture: the gate's exit code is the step's exit code,
so a gate cannot run without being able to fail its lane.

Part of #1429.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* merge: main (#1770) and route its three new steps through the runner

#1770 landed the orphan-check fix on main, wiring `check:tmpdir-leaks`,
`check:tmpdir-leaks:test` and `test:fixture-cache` into Coverage, Layering
Guard and Integration Tests. This branch had wired the same three through
`pnpm gate`, so the merge produced two steps per check rather than a conflict
— each check ran twice.

Kept main's steps, with the placement and reasoning reviewed on #1770, and
changed only their `run:` line to the canonical runner. Dropped this branch's
duplicates. Net effect on CI is unchanged: the same three checks, in the same
three lanes, once each.

Gate manifest green after the merge: 47 checks wired across 33 lanes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* fix(ci): address review — suite detection, freerange, glob, vacuous skip-list

Six review findings plus the mutation blocker.

[bug] `registered` was shape-only, so a `test:*` script running
`node src/bin.ts test <dir>` resolved to a `script:` leaf and was invisible.
Four `test:replay:*` scripts were owned only because someone hand-registered
them; `test:replay:android` was neither registered nor reported while the
nightly ran the same six .ad files by inlining them. A `test:*` script is now
a suite by name. `replay-android` is registered, and the nightly runs the
script instead of re-listing its files so the two cannot drift.

  The nightly invokes it inside `reactivecircus/android-emulator-runner`'s
  `script:` input — shell handed to a third-party action this loader does not
  read — so the suite executes but cannot be credited. Recorded in
  UNPROVABLE_OWNERS with that exact reason rather than assumed.

  The fixed detector also found a second orphan the review did not name:
  `test:integration:progress`. That one is a reporter whose `--check` sibling
  is the registered gate, so it is declared in REPORTING_SCRIPTS — a
  declaration that itself fails when inert.

[bug] `freerange` defaulted to localRunnable, so fail-open ran `fr` (a Bun
binary) on the pre-push path. Now false.

[suggestion] The `--run` skip-list asserted `build:android-snapshot-helper`,
a name `android-helpers` no longer uses, so it could not fail. Derived from
the catalog instead.

[suggestion] `matchesGlob` joined `**` splits with `.*`, making the adjacent
slash mandatory — GitHub's `**` matches zero directories, so
`src/**/*.test.ts` did not match `src/a.test.ts`. Pinned against
`packages/*/src/**/*.test.ts`.

[suggestion] Deleted the unwired `run-gate` action. It had no callers, was
absent from GATE_ACTIONS, and its comment described a system that had not
shipped. It returns with the rewiring, not before.

[suggestion] Collapsed the module headers that narrated discarded designs.

Mutation: `daemon entrypoint publishes HTTP metadata and cleans up on
shutdown` is the only test here that spawns a real daemon process. It takes
~1.1s alone but exceeds Vitest's 5s default inside Stryker's dry run, which
aborts the sweep before a single mutant runs. Given 30s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* fix(mutation): order sandbox aliases longest-first so subpaths resolve

Every shard of the mutation sweep aborted in Stryker's dry run with:

  Cannot find package '@agent-device/selectors/engine' imported from
    .tmp/stryker/sandbox-*/src/core/selector-pipeline.ts

The alias was generated correctly; it just never won. Vite matches a STRING
alias by prefix and takes the first hit, and `workspaceSpecifierTargets`
emitted the bare `@agent-device/selectors` ahead of the subpath entries. The
bare entry therefore captured `@agent-device/selectors/engine` and rewrote it
to `…/src/index.ts/engine`, which does not exist; Node fell back to real
package resolution, could not find the subpath inside the sandbox, and the dry
run failed before a single mutant ran — so the shard uploaded an empty
envelope instead of a report and the ratchet failed for want of one.

Sorting longest specifier first makes the most specific alias win:

  @agent-device/selectors/engine -> packages/selectors/src/engine.ts
  @agent-device/selectors/ast    -> packages/selectors/src/ast.ts
  @agent-device/selectors        -> packages/selectors/src/index.ts

`/ast` never tripped this because nothing in a related test set imported it;
`selector-pipeline.ts` introduced the first subpath import that mattered
(#1744), so the mutation lane has been unable to run since that landed. Any
PR touching `scripts/mutation/**` — which fails open into the full sweep —
would have hit it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SkS4S8XXrfkJ8TD1VBKkvJ

* refactor: derive gate ownership from workflow structure

* fix: run gates without optional arguments

* fix: resolve mutation workspace subpaths exactly

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Michał Pierzchała
2026-08-12 16:02:18 +02:00
committed by GitHub
parent c7565cb1f8
commit 9c22467832
41 changed files with 1908 additions and 362 deletions
+38
View File
@@ -0,0 +1,38 @@
name: Run registered gate
description: Run one CHECK_CATALOG entry named structurally by the workflow
inputs:
gate:
description: Registered CHECK_CATALOG id
required: true
args:
description: Optional arguments, one per line
required: false
default: ''
outputs:
result:
description: Last stdout line, for gates that produce a matrix or scalar
value: ${{ steps.run.outputs.result }}
runs:
using: composite
steps:
- id: run
shell: bash
env:
INPUT_GATE: ${{ inputs.gate }}
INPUT_ARGS: ${{ inputs.args }}
run: |
set -euo pipefail
command=(pnpm gate "$INPUT_GATE")
while IFS= read -r arg; do
if [ -n "$arg" ]; then
command+=("$arg")
fi
done <<< "$INPUT_ARGS"
output_file="$(mktemp)"
trap 'rm -f "$output_file"' EXIT
"${command[@]}" | tee "$output_file"
{
echo 'result<<__GATE_OUTPUT__'
tail -n 1 "$output_file"
echo '__GATE_OUTPUT__'
} >> "$GITHUB_OUTPUT"
@@ -67,12 +67,9 @@ runs:
- name: Package npm-bundled Android helpers
if: inputs.package-helpers == 'true' && steps.android-helpers-cache.outputs.cache-hit != 'true'
run: |
VERSION="${{ steps.android-helper-source.outputs.version }}"
rm -rf android/snapshot-helper/dist android/ime-helper/dist
sh ./scripts/package-android-helper.sh snapshot "$VERSION" "v$VERSION" android/snapshot-helper/dist
sh ./scripts/package-android-helper.sh ime "$VERSION" android/ime-helper/dist
shell: bash
uses: ./.github/actions/run-gate
with:
gate: android-helpers
- name: Verify packaged Android helpers
if: inputs.package-helpers == 'true'
@@ -12,8 +12,8 @@ inputs:
description: 'Optional suffix for the cache key'
required: false
default: ''
build-command:
description: 'Command used to build Apple runner artifacts'
gate:
description: 'Registered check id built through `pnpm gate`'
required: true
xcuitest-platform:
description: 'Optional AGENT_DEVICE_XCUITEST_PLATFORM value'
@@ -49,13 +49,17 @@ runs:
- name: Resolve Apple runner build variant
id: build-variant
env:
INPUT_GATE: ${{ inputs.gate }}
INPUT_XCUITEST_PLATFORM: ${{ inputs.xcuitest-platform }}
INPUT_XCUITEST_DESTINATION: ${{ inputs.xcuitest-destination }}
run: |
set -euo pipefail
VARIANT="$(
printf '%s\n' \
'${{ inputs.build-command }}' \
'${{ inputs.xcuitest-platform }}' \
'${{ inputs.xcuitest-destination }}' \
"$INPUT_GATE" \
"$INPUT_XCUITEST_PLATFORM" \
"$INPUT_XCUITEST_DESTINATION" \
"${AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS:-0}" \
| shasum -a 256 \
| cut -c1-16
@@ -72,8 +76,9 @@ runs:
- name: Build Apple runner artifacts on cache miss
if: steps.restore-runner-build.outputs.cache-hit != 'true'
run: ${{ inputs.build-command }}
shell: bash
uses: ./.github/actions/run-gate
with:
gate: ${{ inputs.gate }}
env:
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ inputs.derived-path }}
AGENT_DEVICE_XCUITEST_PLATFORM: ${{ inputs.xcuitest-platform }}
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
set -eu
ANDROID_SERIAL="$(adb devices | awk 'NR > 1 && $2 == "device" { print $1; exit }')"
test -n "$ANDROID_SERIAL"
pnpm build
pnpm gate build
pnpm clean:daemon
AGENT_DEVICE_ANDROID_E2E=1 AGENT_DEVICE_ANDROID_E2E_TIER=smoke AGENT_DEVICE_ANDROID_SERIAL="$ANDROID_SERIAL" AGENT_DEVICE_FIXTURE_APP_PATH="${{ steps.fixture-app.outputs.apk-path }}" AGENT_DEVICE_FIXTURE_APP_ID="${{ steps.fixture-app.outputs.app-id }}" node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-android-emulator.test.ts
node --experimental-strip-types src/bin.ts test test/integration/replays/android/01-settings.ad --retries 2 --report-junit test/artifacts/replays-android-smoke.junit.xml
+98 -36
View File
@@ -53,6 +53,10 @@ jobs:
name: Swift Runner Unit Compile
runs-on: macos-26
timeout-minutes: 20
# Was an inline `VAR=1 pnpm gate …` prefix on the action's build-command input. The
# input is a gate id now, so the variable lives where ios.yml already puts it: the job.
env:
AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS: '1'
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -65,7 +69,7 @@ jobs:
with:
derived-path: ${{ github.workspace }}/.tmp/swift-runner-unit-derived
cache-key-prefix: swift-runner-unit
build-command: AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 pnpm build:xcuitest:macos
gate: swift-runner-macos
xcuitest-platform: macos
xcuitest-destination: platform=macOS,arch=arm64
@@ -81,10 +85,12 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Run oxlint
run: pnpm lint
uses: ./.github/actions/run-gate
with: { gate: lint }
- name: Check formatting
run: pnpm format:check
uses: ./.github/actions/run-gate
with: { gate: format }
layering-guard:
name: Layering Guard
@@ -107,14 +113,16 @@ jobs:
# Generalizes the former inline commands/-import grep into a structured
# import-direction lint over the resolved graph. See scripts/layering/check.ts
# and CONTEXT.md (Architecture: folder DAG + layering lint).
run: pnpm check:layering
uses: ./.github/actions/run-gate
with: { gate: layering }
- name: Check the depgraph report agrees with the gate
# scripts/depgraph reads the same model as the gate, so its inversion count must
# reproduce TYPE_INVERSION_BASELINE. Free two-sources check: if the tree changes
# and only one side is updated, this fails and names the difference. Runs here
# rather than in its own job so the two can never be green independently.
run: pnpm depgraph:test
uses: ./.github/actions/run-gate
with: { gate: depgraph }
# Tests for the TMPDIR redirection itself, hidden the same way as the check above.
#
@@ -125,7 +133,8 @@ jobs:
# Starting a nested Vitest seconds before the full instrumented suite is a contention
# risk with nothing to gain.
- name: Check the tmpdir redirection model
run: pnpm check:tmpdir-leaks:test
uses: ./.github/actions/run-gate
with: { gate: tmpdir-leaks-model }
affected-selector:
name: Affected-check Selector
@@ -146,7 +155,21 @@ jobs:
# The selector is fail-open and advisory (GitHub CI stays authoritative),
# so the gate only guards the derivation model.
- name: Check affected-selector model
run: pnpm check:affected:test
uses: ./.github/actions/run-gate
with: { gate: affected-selector }
# The gate-of-gates (#1429). It shares this job because it validates the
# same artifact the selector is built on — CHECK_CATALOG's `ciJobs` — and
# because a gate that proves the other gates are wired must not be the one
# gate sitting in its own job, green on its own. Deterministic and
# network-free: every input is a file in the checkout.
- name: Check the gate manifest model
uses: ./.github/actions/run-gate
with: { gate: gate-manifest-model }
- name: Check every gate is owned, wired, and reachable
uses: ./.github/actions/run-gate
with: { gate: gate-manifest }
maestro-conformance:
name: Maestro Conformance Oracle
@@ -168,7 +191,8 @@ jobs:
# device-backed layer 3 runs on the scheduled conformance-differential
# workflow. See scripts/maestro-conformance/README.md.
- name: Verify Maestro conformance fixtures
run: pnpm maestro:conformance
uses: ./.github/actions/run-gate
with: { gate: maestro-conformance }
packaged-cli-node-22-12:
name: Packaged CLI Node 22.12
@@ -182,9 +206,12 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Build CLI
run: |
pnpm build
pnpm check:bundle-owner-files
uses: ./.github/actions/run-gate
with: { gate: build }
- name: Verify emitted chunk ownership
uses: ./.github/actions/run-gate
with: { gate: bundle-owner-files }
# The build runs on the default toolchain Node and the package is verified on the minimum
# supported Node, so this job covers what a user on `engines.node` floor actually installs.
@@ -218,10 +245,16 @@ jobs:
- name: Run Fallow audit
env:
FALLOW_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
run: pnpm check:fallow --base "$FALLOW_BASE"
uses: ./.github/actions/run-gate
with:
gate: fallow
args: |
--base
${{ env.FALLOW_BASE }}
- name: Check for production-unused exports
run: pnpm check:production-exports
uses: ./.github/actions/run-gate
with: { gate: production-exports }
replay-compat-provenance:
# The frozen replay-compat corpus (#1417) claims each entry was published by
@@ -241,7 +274,8 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Verify corpus entries against their released blobs
run: pnpm check:replay-compat
uses: ./.github/actions/run-gate
with: { gate: replay-compat }
released-surface-compat:
# The daemon RPC wire ledger (#1432) is compared against the ledger as it
@@ -262,10 +296,12 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Verify the wire-compat rules
run: pnpm check:daemon-wire-compat:test
uses: ./.github/actions/run-gate
with: { gate: wire-compat-model }
- name: Compare the daemon RPC wire surface against the last released tag
run: pnpm check:daemon-wire-compat
uses: ./.github/actions/run-gate
with: { gate: daemon-wire-compat }
coverage:
# Runs the full unit + provider-integration suites under coverage with
@@ -283,12 +319,14 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Test changed-line coverage gate
run: pnpm check:coverage-changed:test
uses: ./.github/actions/run-gate
with: { gate: coverage-model }
# The retry list is an enumerated set of owned waivers, so an expired entry
# must fail before the suite runs rather than quietly keeping its retry.
- name: Check contention retry policy
run: pnpm check:contention-retry
uses: ./.github/actions/run-gate
with: { gate: contention-retry }
# Wrapped in the single-retry policy (#1419): a timeout-shaped failure in
# an enumerated contention-flaky file reruns that file once and reports it
@@ -296,14 +334,15 @@ jobs:
- name: Run coverage
env:
OUTPUT_ECONOMY_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
run: pnpm test:coverage:ci
uses: ./.github/actions/run-gate
with: { gate: unit-ci }
# The TMPDIR redirection both test lanes depend on (#1593/#1595). The check is a real
# package script that no workflow ran: it is reachable only through `check:unit`, an
# aggregate CI never invokes, so a leak regression could not fail a PR. Placed here
# because this is the lane whose instrumented suite would leak a run directory.
- name: Check for leaked temp directories
run: pnpm check:tmpdir-leaks
uses: ./.github/actions/run-gate
with: { gate: tmpdir-leaks }
- name: Upload contention-retry envelope
if: always()
@@ -321,7 +360,12 @@ jobs:
if: always() && github.event_name == 'pull_request'
env:
AGENT_DEVICE_COVERAGE_WAIVER: ${{ contains(github.event.pull_request.labels.*.name, 'coverage-waiver') }}
run: pnpm check:coverage-changed --base "${{ github.event.pull_request.base.sha }}"
uses: ./.github/actions/run-gate
with:
gate: coverage
args: |
--base
${{ github.event.pull_request.base.sha }}
typecheck:
name: Typecheck
@@ -335,7 +379,16 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Run typecheck
run: pnpm typecheck
uses: ./.github/actions/run-gate
with: { gate: typecheck }
# CHECK_CATALOG has always claimed this job mirrors the `mcp-metadata`
# check; until #1429 checked the claim, no PR job ran it at all, so
# server.json/smithery.yaml drift only surfaced at publish time (where
# publish-mcp-registry.yml duplicates the same command). Parse-only.
- name: Check MCP registry metadata is in sync
uses: ./.github/actions/run-gate
with: { gate: mcp-metadata }
freerange:
name: FreeRange
@@ -352,7 +405,8 @@ jobs:
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- name: Check numeric ranges
run: pnpm check:freerange
uses: ./.github/actions/run-gate
with: { gate: freerange }
integration:
name: Integration Tests
@@ -366,9 +420,11 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Run integration tests
run: |
pnpm clean:daemon
pnpm test:integration:node
run: pnpm clean:daemon
- name: Execute integration tests
uses: ./.github/actions/run-gate
with: { gate: integration-node }
- name: Run seeded concurrency torture lane (fast PR sweep)
# #1416's nightly torture lane lives under test/integration/nightly/, out
@@ -376,24 +432,28 @@ jobs:
# sweep (TORTURE_RUNS default 128 seeds, ~sub-second) — not an accidental
# glob inclusion. The Concurrency Torture Nightly workflow sweeps a much
# larger seed range on schedule.
run: pnpm test:concurrency-torture
uses: ./.github/actions/run-gate
with: { gate: concurrency-torture }
- name: Run provider-backed integration tests
run: pnpm test:integration:provider
uses: ./.github/actions/run-gate
with: { gate: provider-integration }
- name: Check Provider-backed integration architecture progress
run: pnpm test:integration:progress:check
uses: ./.github/actions/run-gate
with: { gate: integration-progress }
# A build-cache lookup outage must degrade setup-fixture-app to an inline
# build, not fail the caller. This drives that step's real shell against a
# failing `gh`.
- name: Setup-fixture-app cache-failure fallback
run: sh ./test/scripts/setup-fixture-app-fallback-smoke.sh
uses: ./.github/actions/run-gate
with: { gate: fixture-fallback }
# The trusted-artifact contract the device lanes rely on to decide a cached fixture
# app is the one this commit expects. A real test file that no workflow ran.
- name: Check the trusted fixture-artifact contract
run: pnpm test:fixture-cache
uses: ./.github/actions/run-gate
with: { gate: fixture-cache }
web-smoke:
name: Web Platform Smoke
@@ -411,9 +471,11 @@ jobs:
node-version: '24.13'
- name: Run live web smoke
run: |
pnpm clean:daemon
pnpm test:smoke:web
run: pnpm clean:daemon
- name: Execute live web smoke
uses: ./.github/actions/run-gate
with: { gate: web-smoke }
- name: Upload web smoke artifacts
if: always()
@@ -10,7 +10,7 @@ name: Concurrency Torture Nightly
# Scheduled + manual only: the PR gate already runs a fast default sweep via the
# Node integration lane; this nightly sweeps a much larger seed range to keep
# mining for ordering bugs. A failure prints the seed and the exact
# `TORTURE_SEED=<n> pnpm test:concurrency-torture` replay command.
# `TORTURE_SEED=<n> pnpm gate concurrency-torture` replay command.
on:
schedule:
@@ -54,7 +54,8 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Run concurrency torture sweep
run: pnpm test:concurrency-torture
uses: ./.github/actions/run-gate
with: { gate: concurrency-torture }
- name: Upload torture lane envelope
if: always()
+27 -7
View File
@@ -60,7 +60,7 @@ jobs:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: ios-runner-prebuilt
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
build-command: sh ./scripts/build-xcuitest-apple.sh
gate: swift-runner-ios
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
@@ -125,12 +125,32 @@ jobs:
# assert engine-side invariants (e.g. the settle loop latches instead of
# burning its budget) — outcome parity alone cannot see that.
- name: Run differential
run: |
pnpm maestro:conformance:differential \
--platform ios \
--out-dir "${{ github.workspace }}/.tmp/conformance-differential" \
--trace-root "${{ github.workspace }}/.agent-device" \
${DIFFERENTIAL_ONLY:+--only "$DIFFERENTIAL_ONLY"}
if: env.DIFFERENTIAL_ONLY == ''
uses: ./.github/actions/run-gate
with:
gate: maestro-differential
args: |
--platform
ios
--out-dir
${{ github.workspace }}/.tmp/conformance-differential
--trace-root
${{ github.workspace }}/.agent-device
- name: Run filtered differential
if: env.DIFFERENTIAL_ONLY != ''
uses: ./.github/actions/run-gate
with:
gate: maestro-differential
args: |
--platform
ios
--out-dir
${{ github.workspace }}/.tmp/conformance-differential
--trace-root
${{ github.workspace }}/.agent-device
--only
${{ env.DIFFERENTIAL_ONLY }}
# Keep the trace, not just the verdict: the engine-side invariants are
# computed FROM replay-timing.ndjson, so without it a report saying
+3 -2
View File
@@ -49,13 +49,14 @@ jobs:
# Verifies the pinned jar SHA-256s against pinned-upstream.json, runs the
# harness over the corpus, and rewrites fixtures/ + corpus/manifest.json.
- name: Regenerate fixtures from pinned upstream
run: pnpm maestro:conformance:regenerate
uses: ./.github/actions/run-gate
with: { gate: maestro-regenerate }
- name: Fail if regeneration changed anything
run: |
if ! git diff --exit-code -- scripts/maestro-conformance/fixtures scripts/maestro-conformance/corpus/manifest.json; then
echo "::error::Checked-in conformance fixtures do not match a fresh regeneration from the pinned upstream artifacts."
echo "The fixtures are generated: run 'pnpm maestro:conformance:regenerate' and commit the result."
echo "The fixtures are generated: run 'pnpm gate maestro-regenerate' and commit the result."
exit 1
fi
echo "Fixtures are byte-identical to a fresh regeneration from Maestro ${{ github.sha }}."
+2 -2
View File
@@ -73,7 +73,7 @@ jobs:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: ios-runner-prebuilt
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
build-command: sh ./scripts/build-xcuitest-apple.sh
gate: swift-runner-ios
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
@@ -158,7 +158,7 @@ jobs:
AGENT_DEVICE_IOS_E2E_TIER: smoke
AGENT_DEVICE_IOS_UDID: ${{ steps.ios-simulator.outputs.simulator-udid }}
run: |
pnpm build
pnpm gate build
node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-ios-simulator-coverage.test.ts test/integration/smoke-ios-simulator.test.ts
- name: Assert simulator automation preserved host focus
+11 -5
View File
@@ -102,11 +102,17 @@ jobs:
xdotool version
- name: Run Linux replay smoke test
run: |
pnpm clean:daemon
pnpm test:replay:linux \
--retries 2 \
--report-junit test/artifacts/replays-linux.junit.xml
run: pnpm clean:daemon
- name: Execute Linux replay smoke test
uses: ./.github/actions/run-gate
with:
gate: replay-linux
args: |
--retries
2
--report-junit
test/artifacts/replays-linux.junit.xml
- name: Upload Linux artifacts
if: always()
+11 -3
View File
@@ -41,15 +41,23 @@ jobs:
with:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: macos-runner-prebuilt
build-command: pnpm build:xcuitest:macos
gate: swift-runner-macos
xcuitest-platform: macos
xcuitest-destination: platform=macOS,arch=arm64
- name: Build macOS helper
run: pnpm build:macos-helper
uses: ./.github/actions/run-gate
with: { gate: macos-helper }
- name: Run macOS integration test
run: pnpm test:replay:macos --retries 2 --report-junit test/artifacts/replays-macos.junit.xml
uses: ./.github/actions/run-gate
with:
gate: replay-macos
args: |
--retries
2
--report-junit
test/artifacts/replays-macos.junit.xml
- name: Upload macOS artifacts
if: always()
+23 -15
View File
@@ -54,7 +54,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
modules: ${{ steps.select.outputs.modules }}
modules: ${{ steps.select.outputs.result }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -65,22 +65,25 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Ratchet self-test
run: pnpm mutation:test
uses: ./.github/actions/run-gate
with: { gate: mutation-model }
- id: select
name: Derive the affected shard matrix
run: |
modules=$(pnpm --silent mutation:affected --list-affected \
--base "origin/${{ github.event.pull_request.base.ref }}" | tail -n1)
echo "modules=$modules" >> "$GITHUB_OUTPUT"
echo "Affected mutation shards: $modules" >> "$GITHUB_STEP_SUMMARY"
uses: ./.github/actions/run-gate
with:
gate: mutation-affected
args: |
--list-affected
--base
origin/${{ github.event.pull_request.base.ref }}
# The self-test and the derivation run before any mutant, so a failure here
# would leave the lane with no envelope at all (#1430).
- name: Record a failed lane envelope
if: failure()
run: |
pnpm mutation:run --affected --fail-envelope \
pnpm gate mutation --affected --fail-envelope \
"affected selection failed before any mutant ran (run ${{ github.run_id }})" || true
- name: Upload selection envelope
@@ -109,14 +112,19 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Run mutants for ${{ matrix.name }}
run: |
pnpm mutation:run --modules ${{ matrix.module }} \
${{ matrix.shard && format('--shard {0}', matrix.shard) || '' }}
uses: ./.github/actions/run-gate
with:
gate: mutation
args: |
--modules
${{ matrix.module }}
${{ matrix.shard && '--shard' || '' }}
${{ matrix.shard || '' }}
- name: Record a failed shard envelope
if: failure()
run: |
pnpm mutation:run --affected --modules ${{ matrix.module }} --fail-envelope \
pnpm gate mutation --affected --modules ${{ matrix.module }} --fail-envelope \
"shard ${{ matrix.name }} failed before producing a report (run ${{ github.run_id }})" || true
- name: Upload shard report
@@ -158,17 +166,17 @@ jobs:
run: |
if [ -d .tmp/mutation/shards ]; then
expected=$(echo '${{ needs.select.outputs.modules }}' | jq length)
pnpm mutation:check --report-dir .tmp/mutation/shards --affected \
pnpm gate mutation-check --report-dir .tmp/mutation/shards --affected \
--expect-shards "$expected" \
--base "origin/${{ github.event.pull_request.base.ref }}"
else
pnpm mutation:affected --base "origin/${{ github.event.pull_request.base.ref }}"
pnpm gate mutation-affected --base "origin/${{ github.event.pull_request.base.ref }}"
fi
- name: Record a failed lane envelope
if: failure()
run: |
pnpm mutation:run --affected --fail-envelope \
pnpm gate mutation --affected --fail-envelope \
"affected ratchet failed before producing a verdict (run ${{ github.run_id }})" || true
- name: Upload mutation report
+23 -7
View File
@@ -62,9 +62,14 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Run mutants for ${{ matrix.name }}
run: |
pnpm mutation:run --modules ${{ matrix.module }} \
${{ matrix.shard && format('--shard {0}', matrix.shard) || '' }}
uses: ./.github/actions/run-gate
with:
gate: mutation
args: |
--modules
${{ matrix.module }}
${{ matrix.shard && '--shard' || '' }}
${{ matrix.shard || '' }}
# A shard can die before Stryker writes anything (install, config, crash);
# without this the artifact is absent and "shard failed" is indistinguishable
@@ -72,7 +77,7 @@ jobs:
- name: Record a failed shard envelope
if: failure()
run: |
pnpm mutation:run --modules ${{ matrix.module }} --fail-envelope \
pnpm gate mutation --modules ${{ matrix.module }} --fail-envelope \
"shard ${{ matrix.name }} failed before producing a report (run ${{ github.run_id }})" || true
- name: Upload shard report
@@ -99,7 +104,8 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Ratchet self-test
run: pnpm mutation:test
uses: ./.github/actions/run-gate
with: { gate: mutation-model }
- name: Download shard reports
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
@@ -110,8 +116,18 @@ jobs:
# run.ts writes the markdown verdict to $GITHUB_STEP_SUMMARY when the
# runner exports it, so the summary and the artifact carry the same numbers.
- name: Ratchet the merged sweep and propose the next baseline
uses: ./.github/actions/run-gate
with:
gate: mutation-check
args: |
--report-dir
.tmp/mutation/shards
--expect-shards
10
--update
- name: Stage the proposed baseline artifact
run: |
pnpm mutation:check --report-dir .tmp/mutation/shards --expect-shards 10 --update
cp mutation-baselines/decision-kernels.json .tmp/mutation/proposed-baseline.json
git checkout -- mutation-baselines/decision-kernels.json
@@ -120,7 +136,7 @@ jobs:
- name: Record a failed lane envelope
if: failure()
run: |
pnpm mutation:run --fail-envelope \
pnpm gate mutation --fail-envelope \
"weekly ratchet job failed before producing a verdict (run ${{ github.run_id }})" || true
# Freshness/drift telemetry (#1430): the envelope states commit, Stryker
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: ios-runner-prebuilt
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
build-command: sh ./scripts/build-xcuitest-apple.sh
gate: swift-runner-ios
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
+2 -1
View File
@@ -37,7 +37,8 @@ jobs:
uses: ./.github/actions/setup-node-pnpm
- name: Check command reference doc coverage
run: pnpm check:command-docs
uses: ./.github/actions/run-gate
with: { gate: command-docs }
deploy-preview:
if: github.event.pull_request.head.repo.full_name == github.repository
+56 -16
View File
@@ -42,7 +42,13 @@ jobs:
# A lane whose classifier or watchdog regressed would pass forever. This runs the
# broken-on-purpose targets first and fails unless each violation is caught.
- name: Self-check the harness
run: pnpm fuzz:parsers --self-check --artifact-dir .tmp/fuzz/self-check
uses: ./.github/actions/run-gate
with:
gate: fuzz-parsers
args: |
--self-check
--artifact-dir
.tmp/fuzz/self-check
# Runs even when the self-check fails, so the lane always produces a fuzz envelope too
# (a step that never runs writes no envelope, and monitoring cannot tell that apart from
@@ -52,11 +58,16 @@ jobs:
env:
FUZZ_ITERATIONS: ${{ github.event.inputs.fuzz-iterations || '50000' }}
FUZZ_SEED: ${{ github.event.inputs.fuzz-seed || github.run_number }}
run: |
pnpm fuzz:parsers \
--iterations "$FUZZ_ITERATIONS" \
--seed "$FUZZ_SEED" \
--artifact-dir .tmp/fuzz/run
uses: ./.github/actions/run-gate
with:
gate: fuzz-parsers
args: |
--iterations
${{ env.FUZZ_ITERATIONS }}
--seed
${{ env.FUZZ_SEED }}
--artifact-dir
.tmp/fuzz/run
# Uploaded on pass as well as failure: each subdirectory of .tmp/fuzz holds a
# run-envelope.json on #1430's shared lane contract (scripts/lib/lane-envelope.ts) —
@@ -136,7 +147,7 @@ jobs:
ANDROID_SERIAL="$(adb devices | awk 'NR > 1 && $2 == "device" { print $1; exit }')"
test -n "$ANDROID_SERIAL"
BOOT_FINISHED_AT="$(date +%s)"
pnpm build
pnpm gate build
pnpm clean:daemon
adb -s "$ANDROID_SERIAL" install -r "${{ steps.fixture-app.outputs.apk-path }}"
node --experimental-strip-types src/bin.ts replay examples/test-app/replays/drag-android.ad --platform android --serial "$ANDROID_SERIAL" --session nightly-android-target-drag --json
@@ -146,8 +157,7 @@ jobs:
FULL_FINISHED_AT="$(date +%s)"
echo "Android setup before emulator: $((BOOT_FINISHED_AT - ${{ steps.android-setup.outputs.seconds }}))s"
echo "Android full scenario wall time: $((FULL_FINISHED_AT - FULL_STARTED_AT))s"
node --experimental-strip-types src/bin.ts test test/integration/replays/android/01-settings.ad --retries 2 --artifacts-dir test/artifacts/replays-android-settings --report-junit test/artifacts/replays-android-settings.junit.xml
node --experimental-strip-types src/bin.ts test test/integration/replays/android/02-deep-navigation.ad test/integration/replays/android/03-scroll-discovery.ad test/integration/replays/android/04-text-input-keyboard.ad test/integration/replays/android/05-app-lifecycle.ad test/integration/replays/android/06-swipe-gestures.ad --artifacts-dir test/artifacts/replays-android --report-junit test/artifacts/replays-android.junit.xml
pnpm gate replay-android --retries 2 --artifacts-dir test/artifacts/replays-android --report-junit test/artifacts/replays-android.junit.xml
- name: Upload Android artifacts
if: always()
@@ -183,7 +193,7 @@ jobs:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: ios-runner-prebuilt
cache-key-suffix: -ios-${{ env.IOS_RUNTIME_VERSION }}
build-command: sh ./scripts/build-xcuitest-apple.sh
gate: swift-runner-ios
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
@@ -201,7 +211,18 @@ jobs:
pnpm clean:daemon
- name: Run iOS simulator replay suite
run: pnpm test:replay:ios --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --retries 2 --artifacts-dir test/artifacts/replays-ios-simulator --report-junit test/artifacts/replays-ios-simulator.junit.xml
uses: ./.github/actions/run-gate
with:
gate: replay-ios
args: |
--udid
${{ steps.ios-simulator.outputs.simulator-udid }}
--retries
2
--artifacts-dir
test/artifacts/replays-ios-simulator
--report-junit
test/artifacts/replays-ios-simulator.junit.xml
- name: Fetch current fixture app
id: fixture-app
@@ -228,14 +249,25 @@ jobs:
AGENT_DEVICE_IOS_E2E_TIER: full
AGENT_DEVICE_IOS_UDID: ${{ steps.ios-simulator.outputs.simulator-udid }}
run: |
pnpm build
pnpm gate build
node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-ios-simulator-coverage.test.ts test/integration/smoke-ios-simulator.test.ts
- name: Run iOS physical device replay suite
if: env.IOS_UDID != ''
env:
IOS_UDID: ${{ vars.IOS_UDID }}
run: pnpm test:replay:ios-device --udid "$IOS_UDID" --retries 2 --artifacts-dir test/artifacts/replays-ios-device --report-junit test/artifacts/replays-ios-device.junit.xml
uses: ./.github/actions/run-gate
with:
gate: replay-ios-device
args: |
--udid
${{ env.IOS_UDID }}
--retries
2
--artifacts-dir
test/artifacts/replays-ios-device
--report-junit
test/artifacts/replays-ios-device.junit.xml
- name: Upload iOS artifacts
if: always()
@@ -264,15 +296,23 @@ jobs:
with:
derived-path: ${{ env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH }}
cache-key-prefix: macos-runner-prebuilt
build-command: pnpm build:xcuitest:macos
gate: swift-runner-macos
xcuitest-platform: macos
xcuitest-destination: platform=macOS,arch=arm64
- name: Build macOS helper
run: pnpm build:macos-helper
uses: ./.github/actions/run-gate
with: { gate: macos-helper }
- name: Run macOS replay suite
run: pnpm test:replay:macos --retries 2 --report-junit test/artifacts/replays-macos.junit.xml
uses: ./.github/actions/run-gate
with:
gate: replay-macos
args: |
--retries
2
--report-junit
test/artifacts/replays-macos.junit.xml
- name: Upload macOS artifacts
if: always()
+2 -1
View File
@@ -46,7 +46,8 @@ jobs:
uses: ./.github/actions/setup-test-app-dependencies
- name: Typecheck test app
run: pnpm test-app:typecheck
uses: ./.github/actions/run-gate
with: { gate: test-app-typecheck }
- name: Resolve native fingerprint
id: fingerprint
+37
View File
@@ -205,6 +205,43 @@ Lists are bounded (`--limit`, default 10) and always disclose what they hid; `--
unbounded. The query is read-only, runs in well under a second, and adds no CI work — its model is
covered by `pnpm depgraph:test` (the existing `Layering Guard` job).
## Gate manifest: proving every check has a CI owner
Every gate above answers "is the code right?". None of them can answer "does CI still own this
check?" — and a check that silently loses its owner looks exactly like a green build. Two
suites had already stopped: `check:tmpdir-leaks` and `test:fixture-cache` were real package
scripts that no workflow ran, reachable only through the `check:unit` aggregate CI never
invokes.
`CHECK_CATALOG` (`scripts/check-affected/checks.ts`) is the registry of every check. CI ownership
is declared only by `uses: ./.github/actions/run-gate` with a literal `gate:` input; the action
then dispatches `pnpm gate <id>`. `pnpm check:gate-manifest` (`scripts/gate/`)
then asserts, against the real workflows:
- **owned** — every registered check is declared by some `pull_request`/`schedule` lane, compared
per *unit* (a Vitest project, a `node --test` file) rather than per script name, so a lane
running the whole suite covers one running part of it.
- **path coverage** — for each category the *real* selector emits over the tracked tree, every
check it activates is run by a lane a PR touching only that path would actually start. This
is #1420's class: a check can run somewhere and still be unreachable for the change that
needs it.
- **registered** — every Vitest project and every suite script belongs to some check, so a new
suite cannot arrive unowned.
Plus the wiring that keeps those honest: a structural gate id must name a registered check, the
canonical action is tested against its `pnpm gate` implementation, local composite actions are
followed transitively, and a job whose steps the loader cannot open fails closed.
What it deliberately does **not** do is infer execution from `run:` text or prove a conditional
step executes on every run. Raw shell can still run project code, but it cannot declare ownership;
`echo`, function bodies, command substitution, and `|| true` are therefore irrelevant to the
manifest. The check proves the smaller structural claim that every registered gate has an explicit
CI owner and every affected path can reach one.
The three facts the manifest cannot derive live together in `scripts/gate/declarations.ts`: one
coverage wrapper, one reporting-only `test:*` script, and the Android replay owner hidden inside a
third-party action's `script:` input.
## Mutation ratchet over decision kernels
Mutation score is the mechanical answer to "is this test load-bearing or decorative". A full-suite
+5 -1
View File
@@ -128,6 +128,9 @@
"check:fallow": "fallow audit",
"check:affected": "node --experimental-strip-types scripts/check-affected/run.ts",
"check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/platform-packages.test.ts scripts/check-affected/run.test.ts",
"gate": "node --experimental-strip-types scripts/gate/run.ts",
"check:gate-manifest": "node --experimental-strip-types scripts/gate/check.ts",
"check:gate-manifest:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/gate/*.test.ts",
"check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts",
"check:coverage-changed:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts",
"check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/*.test.ts && node --experimental-strip-types scripts/layering/check.ts",
@@ -147,7 +150,7 @@
"sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs",
"check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check",
"version": "pnpm sync:mcp-metadata && git add server.json",
"check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files && pnpm check:package",
"check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files && pnpm check:package",
"check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
"prepack": "pnpm check:mcp-metadata && pnpm package:npm",
@@ -172,6 +175,7 @@
"test:integration:progress": "node --experimental-strip-types scripts/integration-progress.ts",
"test:integration:progress:check": "node --experimental-strip-types scripts/integration-progress.ts --check",
"test:fixture-cache": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/ci/trusted-fixture-artifact.test.mjs",
"test:fixture-fallback": "sh ./test/scripts/setup-fixture-app-fallback-smoke.sh",
"test:output-economy": "vitest run --project output-economy",
"test:smoke:web": "pnpm build && node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-web-platform.test.ts",
"test:smoke": "node --experimental-strip-types scripts/node-test-tmpdir.ts --test test/integration/smoke-*.test.ts",
+116 -156
View File
@@ -1,9 +1,13 @@
// Catalog for the check-affected selector: how each derived CheckId maps to a
// runnable command and the authoritative GitHub CI job(s) it mirrors.
// The canonical gate registry: every check this repo can run, and how to run it.
//
// 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.
// One entry per gate, and one universe — the affected-selector's vocabulary and
// the set of gates CI runs are the same list, because CI may only invoke a gate
// through the structural run-gate action. That lets the manifest derive the
// workflow→check mapping from YAML fields without interpreting shell.
//
// The jobs that run a check are NOT recorded here. They are derived from the
// workflows by scripts/gate/model.ts, so the "GitHub-authoritative" claim an
// agent reads before skipping a check locally cannot go stale.
import { ALL_CHECKS, type CheckId } from './model.ts';
@@ -15,167 +19,123 @@ 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.
// Whether `--run` should attempt the check locally. Device/emulator lanes,
// network/toolchain-gated lanes, and long scheduled sweeps (mutation, fuzz,
// torture) stay authoritative on GitHub CI.
readonly localRunnable: boolean;
};
function gate(id: CheckId, label: string, script: string, localRunnable = true): CheckSpec {
return { id, label, kind: { type: 'script', script }, localRunnable };
}
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: 'test-app-typecheck',
label: 'Expo test app typecheck',
kind: { type: 'script', script: 'test-app:typecheck' },
ciJobs: ['Resolve native fingerprint'],
// The test app intentionally owns a separate Expo dependency graph. Do
// not make every root-checkout validation install it implicitly.
localRunnable: false,
},
{
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: 'package',
label: 'Published package (publint, attw, clean-install resolution)',
kind: { type: 'script', script: 'check:package' },
ciJobs: ['Packaged CLI Node 22.12'],
// Needs a `pnpm build` output and the npm registry, both of which local runs already have.
localRunnable: true,
},
{
id: 'integration-node',
label: 'Node integration smoke',
kind: { type: 'script', script: 'test:integration:node' },
ciJobs: ['Integration Tests'],
localRunnable: true,
},
gate('format', 'Formatting (oxfmt)', 'format:check'),
gate('lint', 'Lint (oxlint)', 'lint'),
gate('typecheck', 'Typecheck (tsc)', 'typecheck'),
// The test app intentionally owns a separate Expo dependency graph. Do not
// make every root-checkout validation install it implicitly.
gate('test-app-typecheck', 'Expo test app typecheck', 'test-app:typecheck', false),
gate('layering', 'Import-direction layering guard', 'check:layering'),
gate('fallow', 'Fallow code-quality audit', 'check:fallow'),
gate('mcp-metadata', 'MCP registry metadata sync', 'check:mcp-metadata'),
gate('build', 'Build (tsdown + declarations)', 'build'),
gate('package', 'Published package (publint, attw, clean-install resolution)', 'check:package'),
gate('integration-node', 'Node integration smoke', 'test:integration:node'),
{
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: 'Affected LCOV + changed-line coverage',
kind: { type: 'script', script: 'check:coverage-changed' },
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-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: 'replay-compat',
label: 'Replay-compat corpus provenance (released blobs)',
kind: { type: 'script', script: 'check:replay-compat' },
// Needs full history and tags, so it runs in its own fetch-depth: 0 job
// rather than inside the shallow-clone-safe unit lane.
ciJobs: ['Replay-Compat Provenance'],
localRunnable: true,
},
{
id: 'daemon-wire-compat',
label: 'Daemon RPC wire surface vs. last released tag',
kind: { type: 'script', script: 'check:daemon-wire-compat' },
// Same shape as replay-compat: the released ledger is only readable from a
// full-history checkout, so this cannot live in the shallow unit lane.
ciJobs: ['Released-Surface Compatibility'],
localRunnable: true,
},
gate('unit', 'Unit + smoke suite', 'check:unit'),
gate('coverage', 'Affected LCOV + changed-line coverage', 'check:coverage-changed'),
gate('provider-integration', 'Provider-backed integration suite', 'test:integration:provider'),
gate(
'integration-progress',
'Integration architecture-progress gate',
'test:integration:progress:check',
),
gate('swift-runner-ios', 'Swift runner build (iOS)', 'build:xcuitest:ios', false),
gate('swift-runner-macos', 'Swift runner build (macOS)', 'build:xcuitest:macos', false),
// `build:android`, not `build:android-snapshot-helper`: the Android lane needs both
// helpers packaged into `android/*/dist` (what the replay host verifies), where the
// build script writes only the snapshot helper into `.tmp/`.
gate('android-helpers', 'Android helper builds (snapshot + IME)', 'build:android', false),
gate('macos-helper', 'macOS helper build', 'build:macos-helper', false),
gate('web-smoke', 'Live web platform smoke', 'test:smoke:web', false),
// Needs full history and tags, so it runs in its own fetch-depth: 0 job rather
// than inside the shallow-clone-safe unit lane.
gate('replay-compat', 'Replay-compat corpus provenance (released blobs)', 'check:replay-compat'),
gate(
'daemon-wire-compat',
'Daemon RPC wire surface vs. last released tag',
'check:daemon-wire-compat',
),
// --- Tooling gates ---------------------------------------------------------
// Each proves one of the checkers above still behaves. They were always real CI
// steps; before the registry became canonical, nothing in the repo could name
// them, so nothing could ask whether they still ran.
//
// `unit-ci` is the CI form of the unit suite. Locally you run `unit` and
// `coverage`; the contention-retry coverage wrapper would just repeat them.
gate('unit-ci', 'CI unit suite under coverage + contention retry', 'test:coverage:ci', false),
gate('affected-selector', 'Affected-check selector model', 'check:affected:test'),
gate('gate-manifest', 'Gate manifest — every gate owned and wired', 'check:gate-manifest'),
gate('gate-manifest-model', 'Gate manifest model', 'check:gate-manifest:test'),
gate('depgraph', 'Dependency graph report agrees with the gate', 'depgraph:test'),
gate('tmpdir-leaks', 'Leaked test tmpdir detector', 'check:tmpdir-leaks'),
gate('tmpdir-leaks-model', 'TMPDIR redirection model', 'check:tmpdir-leaks:test'),
gate('contention-retry', 'Contention single-retry policy', 'check:contention-retry'),
gate('coverage-model', 'Changed-line coverage model', 'check:coverage-changed:test'),
gate('wire-compat-model', 'Wire-compat rules model', 'check:daemon-wire-compat:test'),
gate('production-exports', 'Production-unused exports', 'check:production-exports'),
gate('bundle-owner-files', 'Bundle owner-file manifest', 'check:bundle-owner-files'),
// Not locally runnable: `@chenglou/freerange`'s bin is `fr.ts`, so the CI job installs Bun
// for it and `pnpm check` never runs this gate. Left default, fail-open would have made
// every `scripts/**`/`.github/**`/`package.json` edit require Bun on the pre-push path.
gate('freerange', 'Numeric range audit', 'check:freerange', false),
gate('fixture-cache', 'Trusted fixture-artifact selection', 'test:fixture-cache'),
gate('fixture-fallback', 'Fixture-app cache-failure fallback', 'test:fixture-fallback'),
gate('command-docs', 'Command reference doc coverage', 'check:command-docs'),
// --- Gates that drive their own runner -------------------------------------
// The ones no naming convention could find: an executable terminal for
// `scripts/fuzz/run.ts` and one for `scripts/size-report.mjs` are the same
// shape, and only one of them can fail a build. Registering them is what tells
// the two apart, and `pnpm gate` is what keeps the registration load-bearing —
// delete an entry and its lane stops resolving, instead of the suite quietly
// leaving the universe.
gate('maestro-conformance', 'Maestro conformance fixtures', 'maestro:conformance'),
gate(
'maestro-differential',
'Maestro differential oracle',
'maestro:conformance:differential',
false,
),
gate(
'maestro-regenerate',
'Maestro fixture regeneration is a no-op',
'maestro:conformance:regenerate',
false,
),
gate('fuzz-parsers', 'Parser fuzz invariants', 'fuzz:parsers', false),
gate('mutation', 'Mutation sweep', 'mutation:run', false),
gate('mutation-affected', 'Affected mutation shard selection', 'mutation:affected', false),
gate('mutation-check', 'Mutation ratchet against the baseline', 'mutation:check', false),
gate('mutation-model', 'Mutation ratchet self-test', 'mutation:test'),
gate(
'concurrency-torture',
'Session/lease/lock torture sweep',
'test:concurrency-torture',
false,
),
gate('replay-ios', 'iOS simulator replay suite', 'test:replay:ios', false),
gate('replay-ios-device', 'iOS physical device replay suite', 'test:replay:ios-device', false),
gate('replay-macos', 'macOS replay suite', 'test:replay:macos', false),
gate('replay-linux', 'Linux replay suite', 'test:replay:linux', false),
gate('replay-android', 'Android replay suite', 'test:replay:android', false),
];
export function getCheckSpec(id: CheckId): CheckSpec {
+16 -48
View File
@@ -73,9 +73,13 @@ test('android-adb stub test delegates project ownership to Vitest', () => {
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('Swift runner change selects both XCUITest platform builds', () => {
// Each platform build is its own gate in its own lane, so a Swift change owns both.
assert.deepEqual(ids(['apple/runner/Sources/Runner/Main.swift']), [
'swift-runner-ios',
'swift-runner-macos',
]);
assert.ok(ids(['src/platforms/apple/core/runner/Support.swift']).includes('swift-runner-ios'));
});
test('Android helper change selects the android-helpers build', () => {
@@ -202,33 +206,16 @@ test('catalog covers exactly the CheckId universe', () => {
assert.doesNotThrow(assertCatalogComplete);
});
test('every catalog command resolves against package scripts', () => {
const scripts: Record<string, string> = {
'format:check': 'x',
lint: 'x',
typecheck: 'x',
'test-app:typecheck': 'x',
'check:layering': 'x',
'check:fallow': 'x',
'check:mcp-metadata': 'x',
build: 'x',
'check:package': 'x',
'check:unit': 'x',
'check:coverage-changed': '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',
'check:replay-compat': 'x',
'check:daemon-wire-compat': 'x',
};
test('every catalog command resolves against the real package scripts', () => {
// Against package.json rather than a fixture map: a fixture has to be updated by
// hand for every new gate, which is exactly the drift the registry exists to stop.
const scripts = (
JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
scripts: Record<string, string>;
}
).scripts;
for (const spec of CHECK_CATALOG) {
const command = resolveCommand(spec, scripts, 'origin/main');
assert.ok(command.length >= 2);
assert.ok(resolveCommand(spec, scripts, 'origin/main').length >= 2, `${spec.id} must resolve`);
}
const fallow = CHECK_CATALOG.find((spec) => spec.id === 'fallow')!;
assert.deepEqual(resolveCommand(fallow, scripts, 'origin/dev'), [
@@ -289,22 +276,3 @@ test('catalog resolves 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 "<workflow> / <job>"; 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}"`,
);
}
}
});
+108 -6
View File
@@ -21,6 +21,12 @@
import { WIRE_SURFACE_FILES } from '../../test/wire-compat/surface.ts';
// The canonical gate universe. Every gate CI runs is one of these — including the
// ones that drive their own runner (fuzz, mutation, the Maestro differential) and
// the command-reference docs gate, which used to be reachable only as a workflow
// job nothing in this repo could name. The run-gate action is the only way a CI lane
// declares ownership, so `check:gate-manifest` fails when a registered check has no lane.
// Raw shell earns no ownership credit.
export type CheckId =
| 'format'
| 'lint'
@@ -33,16 +39,49 @@ export type CheckId =
| 'package'
| 'vitest-related'
| 'unit'
| 'unit-ci'
| 'coverage'
| 'provider-integration'
| 'integration-node'
| 'integration-progress'
| 'swift-runner'
| 'swift-runner-ios'
| 'swift-runner-macos'
| 'android-helpers'
| 'macos-helper'
| 'web-smoke'
| 'replay-compat'
| 'daemon-wire-compat';
| 'daemon-wire-compat'
// Tooling gates: each proves one of the checkers above still behaves.
| 'affected-selector'
| 'gate-manifest'
| 'gate-manifest-model'
| 'depgraph'
| 'tmpdir-leaks'
| 'tmpdir-leaks-model'
| 'contention-retry'
| 'coverage-model'
| 'wire-compat-model'
| 'production-exports'
| 'bundle-owner-files'
| 'freerange'
| 'fixture-cache'
| 'fixture-fallback'
| 'command-docs'
// Gates that drive their own runner — declared nowhere, registered here.
| 'maestro-conformance'
| 'maestro-differential'
| 'maestro-regenerate'
| 'fuzz-parsers'
| 'mutation'
| 'mutation-affected'
| 'mutation-check'
| 'mutation-model'
| 'concurrency-torture'
| 'replay-ios'
| 'replay-ios-device'
| 'replay-macos'
| 'replay-linux'
| 'replay-android';
// 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).
@@ -61,15 +100,46 @@ export const ALL_CHECKS: readonly CheckId[] = [
'integration-node',
'vitest-related',
'unit',
'unit-ci',
'coverage',
'provider-integration',
'integration-progress',
'swift-runner',
'swift-runner-ios',
'swift-runner-macos',
'android-helpers',
'macos-helper',
'web-smoke',
'replay-compat',
'daemon-wire-compat',
'affected-selector',
'gate-manifest',
'gate-manifest-model',
'depgraph',
'tmpdir-leaks',
'tmpdir-leaks-model',
'contention-retry',
'coverage-model',
'wire-compat-model',
'production-exports',
'bundle-owner-files',
'freerange',
'fixture-cache',
'fixture-fallback',
'command-docs',
'maestro-conformance',
'maestro-differential',
'maestro-regenerate',
'fuzz-parsers',
'mutation',
'mutation-affected',
'mutation-check',
'mutation-model',
'concurrency-torture',
'replay-ios',
'replay-ios-device',
'replay-macos',
'replay-linux',
'replay-android',
];
export type SelectionReason = {
@@ -369,10 +439,18 @@ const BUILD_OWNERSHIP: ReadonlyArray<{
detail: string;
owns: (file: string) => boolean;
}> = [
// Both platform builds compile the same runner sources, and each is a separate
// gate in a separate lane, so a Swift change owns both.
{
check: 'swift-runner',
check: 'swift-runner-ios',
rule: 'own:swift',
detail: 'Swift runner sources require the XCUITest build',
detail: 'Swift runner sources require the iOS XCUITest build',
owns: (file) => file.startsWith('apple/runner/') || file.endsWith('.swift'),
},
{
check: 'swift-runner-macos',
rule: 'own:swift',
detail: 'Swift runner sources require the macOS XCUITest build',
owns: (file) => file.startsWith('apple/runner/') || file.endsWith('.swift'),
},
{
@@ -414,6 +492,25 @@ const buildOwnership: OwnershipRule = ({ file }, input) => {
return selections;
};
// Docs with an owning gate (#1420). Most Markdown has no suite, but the command
// reference is asserted against the CLI in both directions, and ci.yml ignores
// `website/**` — so a docs-only PR must still be routed to the lane that runs it.
// Registering `command-docs` as an ordinary check is what lets the gate manifest
// prove that with no docs-specific machinery.
const COMMAND_DOCS = 'website/docs/docs/commands.md';
const docsOwnership: OwnershipRule = ({ file }) =>
file === COMMAND_DOCS
? [
reason(
'command-docs',
file,
'own:command-docs',
'the command reference is asserted against the CLI in both directions',
),
]
: [];
const OWNERSHIP_RULES: readonly OwnershipRule[] = [
formatGate,
staticTsGates,
@@ -471,7 +568,12 @@ export function selectChecks(input: SelectInput): CheckPlan {
continue;
}
if (isDocs(file)) {
docsOnlyPaths.push(file);
const owned = docsOwnership(fileFacts(file), input);
if (owned.length === 0) {
docsOnlyPaths.push(file);
continue;
}
reasons.push(...owned);
continue;
}
const facts = fileFacts(file);
+19 -19
View File
@@ -9,6 +9,7 @@ import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { runCmdSync } from '../../src/utils/exec.ts';
import { CHECK_CATALOG } from './checks.ts';
import { DEFAULT_VITEST_MAX_WORKERS } from '../lib/vitest-concurrency.ts';
import { selectChecks } from './model.ts';
import { type CommandExecutor, readChangedFiles, runChecks } from './run.ts';
@@ -88,24 +89,15 @@ test('readChangedFiles unions staged and unstaged so a net diff cannot hide a fi
}
});
const ALL_SCRIPTS: Record<string, string> = {
'format:check': 'x',
lint: 'x',
typecheck: 'x',
'test-app:typecheck': 'x',
'check:layering': 'x',
'check:fallow': 'x',
'check:mcp-metadata': 'x',
build: 'x',
'check:package': 'x',
'check:unit': 'x',
'check:coverage-changed': 'x',
'test:integration:provider': 'x',
'test:integration:node': 'x',
'test:integration:progress:check': 'x',
'check:replay-compat': 'x',
'check:daemon-wire-compat': 'x',
};
const repoRoot = path.resolve(import.meta.dirname, '../..');
// The real package scripts: a fixture map has to be hand-extended for every new
// gate, which is the drift the registry exists to remove.
const ALL_SCRIPTS: Record<string, string> = (
JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
scripts: Record<string, string>;
}
).scripts;
const ARGS = { base: 'origin/main', head: 'HEAD', json: false, run: true };
@@ -163,7 +155,15 @@ test('runChecks skips GitHub-authoritative checks and passes when locals succeed
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']) {
// Derived from the catalog rather than hand-listed. A hand-written name goes vacuous the
// moment a check is repointed: this list still asserted `build:android-snapshot-helper`
// after `android-helpers` moved to `build:android`, so it could not have failed however
// the flag was set.
const authoritative = CHECK_CATALOG.filter((spec) => !spec.localRunnable).flatMap((spec) =>
spec.kind.type === 'script' ? [spec.kind.script] : [],
);
assert.ok(authoritative.length >= 10, 'the catalog must still mark CI-owned checks');
for (const skipped of authoritative) {
assert.ok(
!ran.includes(skipped),
`${skipped} is GitHub-authoritative and must not run locally`,
+16 -12
View File
@@ -10,6 +10,7 @@ import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { runCmdStreaming, runCmdSync } from '../../src/utils/exec.ts';
import { parseScriptArgs } from '../lib/cli-args.ts';
import { runEntrypoint } from '../lib/cli-entrypoint.ts';
import { DEFAULT_VITEST_MAX_WORKERS } from '../lib/vitest-concurrency.ts';
import {
assertCatalogComplete,
@@ -18,7 +19,15 @@ import {
resolveCommand,
type CheckSpec,
} from './checks.ts';
import { ALL_CHECKS, selectChecks, type CheckPlan } from './model.ts';
import { loadModel, owningLanes } from '../gate/model.ts';
import { ALL_CHECKS, selectChecks, type CheckId, type CheckPlan } from './model.ts';
// Which GitHub jobs run each check, read off the workflows rather than declared
// next to the check. A skipped check tells the reader where it is authoritative,
// and that pointer is only useful if it cannot drift from the workflows.
function ciJobsByCheck(): Map<CheckId, string[]> {
return owningLanes(loadModel(repoRoot, []));
}
type Args = { base: string; head: string; json: boolean; run: boolean };
@@ -88,12 +97,13 @@ function packageEntryFiles(pkg: PackageJson): string[] {
}
function printPlanJson(plan: CheckPlan, args: Args): void {
const ciJobs = ciJobsByCheck();
const checks = plan.checks.map((id) => {
const spec = getCheckSpec(id);
return {
id,
label: spec.label,
ciJobs: spec.ciJobs,
ciJobs: ciJobs.get(id) ?? [],
localRunnable: spec.localRunnable,
reasons: plan.reasons.filter((reason) => reason.check === id),
};
@@ -181,10 +191,10 @@ export async function runChecks(
const runnable = plan.checks.map(getCheckSpec).filter((spec: CheckSpec) => spec.localRunnable);
const skipped = plan.checks.map(getCheckSpec).filter((spec: CheckSpec) => !spec.localRunnable);
const coverageSelected = plan.checks.includes('coverage');
const ciJobs = skipped.length > 0 ? ciJobsByCheck() : new Map<CheckId, string[]>();
for (const spec of skipped) {
process.stdout.write(
`\n[skip] ${spec.id} — GitHub-authoritative (jobs: ${spec.ciJobs.join(', ')})\n`,
);
const jobs = ciJobs.get(spec.id) ?? [];
process.stdout.write(`\n[skip] ${spec.id} — GitHub-authoritative (jobs: ${jobs.join(', ')})\n`);
}
for (const spec of runnable) {
if (isCoveredByAffectedCoverage(spec, coverageSelected)) {
@@ -276,11 +286,5 @@ async function main(argv = process.argv.slice(2)): Promise<number> {
}
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);
},
);
runEntrypoint('check:affected', () => main());
}
+135
View File
@@ -0,0 +1,135 @@
// Load-bearing ownership, path-reachability, and suite-registration witnesses.
import { execFileSync } from 'node:child_process';
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { audit } from './audit.ts';
import { categories, loadModel, type Model } from './model.ts';
import type { Lane } from './workflows.ts';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const tracked = execFileSync('git', ['ls-files'], {
cwd: repoRoot,
encoding: 'utf8',
})
.split('\n')
.filter(Boolean);
const base = loadModel(repoRoot, tracked);
function mutate(change: (model: Model) => Partial<Model>): Model {
return { ...base, ...change(base) };
}
function messages(model: Model): string[] {
return audit(model).map((failure) => failure.message);
}
function mapLane(
model: Model,
match: (lane: Lane) => boolean,
change: (lane: Lane) => Lane,
): Lane[] {
return model.lanes.map((lane) => (match(lane) ? change(lane) : lane));
}
test('the live tree is green — every planted failure below is a real difference', () => {
assert.deepEqual(messages(base), []);
});
test('deleting the lane that runs a gate reports exactly that gate, naming the runner', () => {
const model = mutate((m) => ({
lanes: mapLane(
m,
(lane) => lane.gates.includes('fuzz-parsers'),
(lane) => ({
...lane,
gates: lane.gates.filter((id) => id !== 'fuzz-parsers'),
}),
),
}));
const found = messages(model);
assert.equal(found.length, 1);
assert.match(
found[0] ?? '',
/check "fuzz-parsers" is not declared by any pull_request\/schedule lane/,
);
assert.match(found[0] ?? '', /run-gate action step for `fuzz-parsers`/);
});
test('a docs-only change still reaches the command-reference gate (#1420)', () => {
const model = mutate((m) => ({
lanes: mapLane(
m,
(lane) => lane.workflow === 'pr-preview.yml',
(lane) => ({ ...lane, paths: ['website/assets/**'] }),
),
}));
const found = messages(model);
assert.equal(found.length, 1);
assert.match(found[0] ?? '', /website\/docs\/docs\/commands\.md/);
assert.match(found[0] ?? '', /selects "command-docs"/);
});
test('a path filter that excludes a category fails, though the check still runs somewhere', () => {
// Take the category's path from the derivation rather than naming a file, so the
// case keeps exercising the real classification as the tree changes.
const category = categories(base).find((entry) => entry.rule === 'own:daemon-wire-compat');
assert.ok(category, 'the wire ledger must still be a category');
const model = mutate((m) => ({
lanes: mapLane(
m,
(lane) => lane.workflow === 'ci.yml',
(lane) => ({
...lane,
pathsIgnore: [...lane.pathsIgnore, category.path],
}),
),
}));
const found = messages(model);
assert.ok(
found.every((message) => !/is not run by any/.test(message)),
'the checks still run somewhere — only this path stops reaching them',
);
assert.ok(found.some((message) => message.includes(category.path)));
assert.ok(found.some((message) => /selects "daemon-wire-compat"/.test(message)));
});
test('a Vitest project no check runs is reported, and so is a suite script', () => {
const project = mutate((m) => ({
vitestProjects: [...m.vitestProjects, 'new-lane'],
}));
assert.ok(
messages(project).some((message) =>
/Vitest project "new-lane" is run by no registered check/.test(message),
),
);
const script = mutate((m) => ({
scripts: {
...m.scripts,
'test:orphan': 'vitest run --project unit-core --project orphan-only',
},
vitestProjects: [...m.vitestProjects, 'orphan-only'],
}));
assert.ok(
messages(script).some((message) =>
/package script "test:orphan" runs vitest:orphan-only/.test(message),
),
);
});
test('a `test:*` script that is a suite by name, not by shape, needs an owner', () => {
// The five `test:replay:*` scripts run `node src/bin.ts test <dir>`, which resolves to a
// `script:` leaf. A shape-only rule could not see them: four were owned because someone
// hand-registered them, and `test:replay:android` was neither registered nor reported.
const model = mutate((m) => ({
scripts: { ...m.scripts, 'test:replay:freebsd': 'node src/bin.ts test test/replays/freebsd' },
}));
assert.ok(
messages(model).some((message) =>
/package script "test:replay:freebsd" runs script:test:replay:freebsd/.test(message),
),
'a new test:* script with no catalog entry must fail `registered`',
);
});
+79
View File
@@ -0,0 +1,79 @@
// Structural ownership regressions: only the canonical action can declare a gate.
import { execFileSync } from 'node:child_process';
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 { audit, formatFailures } from './audit.ts';
import { loadModel, type Model } from './model.ts';
import { loadLanes } from './workflows.ts';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const tracked = execFileSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8' })
.split('\n')
.filter(Boolean);
const base = loadModel(repoRoot, tracked);
function plant(yaml: string): Model {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gate-lane-'));
try {
fs.writeFileSync(path.join(dir, 'planted.yml'), yaml);
return { ...base, lanes: [...base.lanes, ...loadLanes(dir, repoRoot, base.scripts)] };
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
const workflow = (step: string) => `name: Planted
on:
pull_request:
jobs:
planted:
steps:
${step}`;
test('the live tree is green', () => {
assert.deepEqual(audit(base), []);
});
test('a structural gate id must exist in CHECK_CATALOG', () => {
const model = plant(
workflow(` - uses: ./.github/actions/run-gate
with:
gate: not-a-real-check`),
);
assert.ok(audit(model).some((failure) => /names no registered check/.test(failure.message)));
});
test('raw shell text cannot declare ownership', () => {
const model = plant(
workflow(` - run: |
pnpm gate not-a-real-check
echo "pnpm gate layering"`),
);
assert.ok(!audit(model).some((failure) => /not-a-real-check/.test(failure.message)));
});
test('reusable workflows fail closed because their action declarations are hidden', () => {
const model = plant(`name: Planted
on:
pull_request:
jobs:
planted:
uses: ./.github/workflows/reusable.yml`);
assert.ok(
audit(model).some((failure) => /runs steps this loader never opens/.test(failure.message)),
);
});
test('unknown assertion kinds remain visible in the report', () => {
const report = formatFailures([
{ assertion: 'owned', message: 'a' },
{ assertion: 'new-kind', message: 'b' },
]);
assert.match(report, /Registered checks no lane declares:/);
assert.match(report, /Other failures \(new-kind\):/);
assert.match(report, /2 failure\(s\)/);
});
+153
View File
@@ -0,0 +1,153 @@
// Structural owners, path reachability, and suite registration over the derived model.
import { CHECK_CATALOG } from '../check-affected/checks.ts';
import { REPORTING_SCRIPTS, UNPROVABLE_OWNERS } from './declarations.ts';
import { categories, checkUnits, covered, scriptUnits, type Model } from './model.ts';
export type Failure = { readonly assertion: string; readonly message: string };
const HEADINGS: Readonly<Record<string, string>> = {
owned: 'Registered checks no lane declares',
gate: 'Gate ids that name no registered check',
surface: 'Execution surfaces the manifest does not model',
'path-coverage': 'Paths whose selected checks no triggered lane runs',
registered: 'Suites and projects no registered check covers',
};
export function formatFailures(failures: readonly Failure[]): string {
const named = Object.keys(HEADINGS);
const unnamed = [...new Set(failures.map((failure) => failure.assertion))]
.filter((assertion) => !named.includes(assertion))
.sort();
const groups: [string, string][] = [
...Object.entries(HEADINGS),
...unnamed.map((assertion): [string, string] => [assertion, `Other failures (${assertion})`]),
];
const lines = groups.flatMap(([assertion, heading]) => {
const group = failures.filter((failure) => failure.assertion === assertion);
if (group.length === 0) return [];
return ['', `${heading}:`, ...group.map((failure) => ` - ${failure.message}`)];
});
return [...lines, '', `gate manifest: ${failures.length} failure(s).`, ''].join('\n');
}
const REGISTERED = new Set(CHECK_CATALOG.map((spec) => spec.id as string));
function fail(assertion: string, message: string): Failure {
return { assertion, message };
}
// Every registered check is declared by some qualifying lane, unit by unit.
function unowned(
model: Model,
unprovable: Readonly<Record<string, string>> = UNPROVABLE_OWNERS,
): Failure[] {
return CHECK_CATALOG.flatMap((spec) => {
const result = covered(spec, null, model);
if (result.covered || spec.id in unprovable) return [];
const missing = result.missing.length > 0 ? result.missing.join(', ') : '(no units resolved)';
return [
fail(
'owned',
`check "${spec.id}" is not declared by any pull_request/schedule lane: ${missing}. ` +
`Add a run-gate action step for \`${spec.id}\`, or drop the check.`,
),
];
});
}
function gateIds(model: Model): Failure[] {
return model.lanes
.filter((lane) => lane.qualifying)
.flatMap((lane) =>
lane.gates
.filter((id) => !REGISTERED.has(id))
.map((id) =>
fail('gate', `${lane.workflow} / ${lane.label}: "${id}" names no registered check.`),
),
);
}
function laneSurfaces(model: Model): Failure[] {
return model.lanes
.filter((lane) => lane.qualifying)
.flatMap((lane) =>
lane.unsupported.map((surface) =>
fail(
'surface',
`${lane.workflow} / ${lane.label}: ${surface} runs steps this loader never opens, ` +
`so any gate inside it is invisible. Model it before using it.`,
),
),
);
}
function pathCoverage(model: Model): Failure[] {
return categories(model).flatMap((category) =>
category.checks.flatMap((id) => {
const spec = CHECK_CATALOG.find((entry) => entry.id === id);
if (!spec) return [];
const result = covered(spec, category.path, model);
if (result.covered) return [];
return [
fail(
'path-coverage',
`a PR touching only ${category.path} (rule ${category.rule}) selects "${id}", but no ` +
`lane that the change starts runs ${result.missing.join(', ')}.`,
),
];
}),
);
}
function attestedUnits(model: Model): Set<string> {
return new Set(
CHECK_CATALOG.filter((spec) => spec.kind.type !== 'vitest-related').flatMap((spec) =>
checkUnits(spec, model),
),
);
}
function isSuite(unit: string, script: string): boolean {
if (unit.startsWith('vitest:') || unit.startsWith('node-test:')) return true;
if (script in REPORTING_SCRIPTS) return false;
return unit === `script:${script}` && script.startsWith('test:');
}
function unregisteredSuites(model: Model): Failure[] {
const owned = attestedUnits(model);
return Object.keys(model.scripts).flatMap((script) => {
const units = scriptUnits(script, model);
const suites = units.filter((unit) => isSuite(unit, script));
if (suites.length === 0) return [];
const orphans = suites.filter(
(unit) => ![...owned].some((have) => have === unit || unit.startsWith(`${have}@`)),
);
if (orphans.length === 0) return [];
return [
fail(
'registered',
`package script "${script}" runs ${orphans.join(', ')}, which no registered check covers. ` +
`Add it to the catalog so a lane can run it through \`pnpm gate\`.`,
),
];
});
}
function orphanProjects(model: Model): Failure[] {
const owned = attestedUnits(model);
return model.vitestProjects
.filter((name) => !owned.has(`vitest:${name}`))
.map((name) => fail('registered', `Vitest project "${name}" is run by no registered check.`));
}
export function audit(model: Model): Failure[] {
return [
...unowned(model),
...gateIds(model),
...laneSurfaces(model),
...pathCoverage(model),
...unregisteredSuites(model),
...orphanProjects(model),
];
}
+39
View File
@@ -0,0 +1,39 @@
// `pnpm check:gate-manifest` — assert every registered gate is owned and wired.
//
// Reports every failure at once, grouped by assertion, so a rewiring round sees
// the whole picture instead of one error per run.
//
// There is no `--update`: everything this command reads is either derived from the tree or
// hand-written in declarations.ts. A generated baseline is what let an earlier design
// launder a new step past review by regenerating it.
import { pathToFileURL } from 'node:url';
import { runCmdSync } from '../../src/utils/exec.ts';
import { audit, formatFailures } from './audit.ts';
import { UNPROVABLE_OWNERS } from './declarations.ts';
import { loadModel } from './model.ts';
function main(): number {
const repoRoot = runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim();
const tracked = runCmdSync('git', ['ls-files'], { cwd: repoRoot })
.stdout.split('\n')
.filter(Boolean);
const model = loadModel(repoRoot, tracked);
const failures = audit(model);
if (failures.length === 0) {
const gates = model.lanes.filter((lane) => lane.qualifying).flatMap((lane) => lane.gates);
// Unprovable owners are reported rather than folded into the count: a check whose lane
// this tree cannot show running should not read the same as one it can.
const unprovable = Object.keys(UNPROVABLE_OWNERS).length;
process.stdout.write(
`gate manifest: ok — ${new Set(gates).size} checks wired across ` +
`${model.lanes.filter((lane) => lane.gates.length > 0).length} lanes` +
`${unprovable > 0 ? `, ${unprovable} declared unprovable` : ''}.\n`,
);
return 0;
}
process.stderr.write(formatFailures(failures));
return 1;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exit(main());
+30
View File
@@ -0,0 +1,30 @@
// The three small facts the manifest cannot derive from package scripts and workflow YAML.
export const OPAQUE_RUNNERS: Readonly<Record<string, readonly string[]>> = {
// This wrapper runs Vitest over every project, then retries owned contention failures.
'test:coverage:ci': [
'vitest:unit-core',
'vitest:subprocess-stub',
'vitest:provider-integration',
'vitest:interaction-contract',
'vitest:output-economy',
],
};
export const REPORTING_SCRIPTS: Readonly<Record<string, string>> = {
'test:integration:progress': [
'Prints the provider-backed integration status table and exits 0. The assertion lives in',
'its `--check` sibling, `test:integration:progress:check`, which IS the registered',
'`integration-progress` gate. Running the reporter in CI would gate nothing.',
].join(' '),
};
export const UNPROVABLE_OWNERS: Readonly<Record<string, string>> = {
'replay-android': [
'Replay Nightly / Android Replay Suite runs `pnpm gate replay-android`, but inside the',
'`script:` input of `reactivecircus/android-emulator-runner` — shell handed to a',
'third-party action, which this loader does not read. The suite executes; the manifest',
'cannot see it. Routing the emulator lane through steps it can read is the open item',
'named in #1429.',
].join(' '),
};
+87
View File
@@ -0,0 +1,87 @@
// Unit-resolution tests, kept to the cases where a mistake would OVER-credit a lane.
//
// Under-credit corrects itself: the real tree is audited on every PR, so a command
// the model fails to read shows up as an unowned check within one run. Over-credit
// is the dangerous direction — it reads as coverage that is not there — so those are
// the shapes pinned here, plus the two real-tree facts the unit vocabulary exists for.
import { execFileSync } from 'node:child_process';
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { loadModel, scriptUnits, unitCovers } from './model.ts';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const tracked = execFileSync('git', ['ls-files'], {
cwd: repoRoot,
encoding: 'utf8',
})
.split('\n')
.filter(Boolean);
const model = loadModel(repoRoot, tracked);
const scriptModel = (scripts: Record<string, string>) => ({
scripts,
vitestProjects: ['unit-core', 'subprocess-stub'],
opaque: {},
});
test('an env prefix does not hide the command behind it', () => {
assert.deepEqual(
scriptUnits(
'build:x',
scriptModel({
'build:x': 'AGENT_DEVICE_XCUITEST_PLATFORM=ios sh ./scripts/build.sh',
}),
),
['script:build:x'],
);
});
test('a filtered Vitest run does not credit the whole project', () => {
const units = scriptUnits(
'docs',
scriptModel({
docs: 'vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts',
}),
);
assert.deepEqual(units, ['vitest:unit-core@src/__tests__/command-doc-coverage.test.ts']);
assert.equal(
unitCovers('vitest:unit-core', units[0] as string),
true,
'the whole project covers the file',
);
assert.equal(
unitCovers(units[0] as string, 'vitest:unit-core'),
false,
'the file does not cover the project',
);
});
test('a bare Vitest run spans every configured project', () => {
assert.deepEqual(scriptUnits('all', scriptModel({ all: 'vitest run --coverage' })), [
'vitest:unit-core',
'vitest:subprocess-stub',
]);
});
test('aggregates expand transitively, so a lane running the aggregate owns its parts', () => {
const units = scriptUnits(
'check:all',
scriptModel({
'check:all': 'pnpm lint && pnpm test:unit',
lint: 'oxlint .',
'test:unit': 'vitest run --project unit-core',
}),
);
assert.deepEqual(units, ['script:lint', 'vitest:unit-core']);
});
test('`node --test` globs expand against the tree, which is how test:smoke is owned', () => {
const smoke = scriptUnits('test:smoke', model);
const integration = scriptUnits('test:integration:node', model);
assert.ok(smoke.length > 1, 'the smoke glob must resolve to real files');
for (const unit of smoke) {
assert.ok(integration.includes(unit), `${unit} must be covered by the integration glob`);
}
});
+217
View File
@@ -0,0 +1,217 @@
// Derive script units, structural workflow owners, and real selector path categories.
import fs from 'node:fs';
import path from 'node:path';
import { CHECK_CATALOG, type CheckSpec } from '../check-affected/checks.ts';
import { selectChecks, type CheckId } from '../check-affected/model.ts';
import { OPAQUE_RUNNERS } from './declarations.ts';
import { ENV_PREFIX, commandSegments } from './shell.ts';
import { loadLanes, triggersOnPath, type Lane } from './workflows.ts';
// Units distinguish whole Vitest projects, filtered files, node:test files, and scripts.
export type Unit = string;
export type Model = {
readonly scripts: Readonly<Record<string, string>>;
readonly vitestProjects: readonly string[];
readonly lanes: readonly Lane[];
readonly trackedFiles: ReadonlySet<string>;
readonly packageEntryFiles: readonly string[];
readonly opaque: Readonly<Record<string, readonly string[]>>;
};
// --- Units -------------------------------------------------------------------
function tokens(segment: string): string[] {
return segment.split(/\s+/).filter(Boolean);
}
/** `pnpm x`, `pnpm run x`, `pnpm --silent x` — the invoked script, or null. */
function invokedScript(segment: string, scripts: Readonly<Record<string, string>>): string | null {
const parts = tokens(segment);
if (parts[0] !== 'pnpm') return null;
for (const part of parts.slice(1)) {
if (part === 'run' || part.startsWith('-')) continue;
return part in scripts ? part : null;
}
return null;
}
function expandGlob(pattern: string): string[] {
if (!pattern.includes('*')) return [pattern];
const dir = path.dirname(pattern);
const rest = path.basename(pattern);
const matcher = new RegExp(
`^${rest.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')}$`,
);
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((entry) => matcher.test(entry))
.map((entry) => path.posix.join(dir, entry))
.sort();
}
function vitestArgs(parts: readonly string[]): {
named: string[];
files: string[];
} {
const named: string[] = [];
const files: string[] = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i] ?? '';
if (part === '--project') named.push(parts[++i] ?? '');
else if (part.startsWith('--project=')) named.push(part.slice('--project='.length));
else if (!part.startsWith('-') && /[./]/.test(part) && !RUNNER_TOKENS.test(part))
files.push(part);
}
return { named, files };
}
const RUNNER_TOKENS = /^(?:pnpm|exec|vitest|run)$/;
function vitestUnits(parts: readonly string[], projects: readonly string[]): Unit[] {
const { named, files } = vitestArgs(parts);
const selected = named.length > 0 ? named : projects;
const suffix = files.length > 0 ? `@${files.join(',')}` : '';
return selected.map((project) => `vitest:${project}${suffix}`);
}
function nodeTestUnits(parts: readonly string[]): Unit[] {
const targets = parts
.slice(parts.indexOf('--test') + 1)
.filter((part) => !part.startsWith('-'))
.flatMap(expandGlob);
return targets.map((file) => `node-test:${file}`);
}
export function scriptUnits(
script: string,
model: Pick<Model, 'scripts' | 'vitestProjects' | 'opaque'>,
seen: ReadonlySet<string> = new Set(),
): Unit[] {
const declared = model.opaque[script];
if (declared) return [...declared];
const body = model.scripts[script];
if (body === undefined || seen.has(script)) return [];
const next = new Set([...seen, script]);
return [
...new Set(
commandSegments(body).flatMap((segment) => segmentUnits(segment, script, model, next)),
),
];
}
function segmentUnits(
raw: string,
script: string,
model: Pick<Model, 'scripts' | 'vitestProjects' | 'opaque'>,
seen: ReadonlySet<string>,
): Unit[] {
const segment = raw.replace(ENV_PREFIX, '');
const nested = invokedScript(segment, model.scripts);
if (nested) return scriptUnits(nested, model, seen);
const parts = tokens(segment);
if (parts.includes('vitest')) return vitestUnits(parts, model.vitestProjects);
if (parts.includes('--test')) return nodeTestUnits(parts);
return [`script:${script}`];
}
/** A lane running `have` satisfies a need for `want`. Whole projects cover their files. */
export function unitCovers(have: Unit, want: Unit): boolean {
if (have === want) return true;
return want.startsWith(`${have}@`);
}
export function checkUnits(spec: CheckSpec, model: Model): Unit[] {
// `vitest-related` has no script: it is Vitest's own `related` command over the
// diff, so the lane that runs the whole suite is what owns it.
if (spec.kind.type === 'vitest-related') {
return model.vitestProjects.map((name) => `vitest:${name}`);
}
return scriptUnits(spec.kind.script, model);
}
// --- Coverage ----------------------------------------------------------------
function laneUnits(lane: Lane, model: Model): Unit[] {
const fromGates = lane.gates.flatMap((id) => {
const spec = CHECK_CATALOG.find((entry) => entry.id === id);
return spec ? checkUnits(spec, model) : [];
});
return [...fromGates, ...lane.verbatim.flatMap((name) => scriptUnits(name, model))];
}
export function covered(
spec: CheckSpec,
file: string | null,
model: Model,
): { covered: boolean; missing: Unit[]; lanes: string[] } {
const wanted = checkUnits(spec, model);
const usable = model.lanes.filter(
(lane) => lane.qualifying && (file === null || triggersOnPath(lane, file)),
);
const owners = new Map<Unit, string[]>();
for (const lane of usable) {
const have = laneUnits(lane, model);
for (const want of wanted) {
if (have.some((unit) => unitCovers(unit, want)))
owners.set(want, [...(owners.get(want) ?? []), lane.label]);
}
}
const missing = wanted.filter((unit) => !owners.has(unit));
return {
covered: wanted.length > 0 && missing.length === 0,
missing,
lanes: [...new Set([...owners.values()].flat())].sort(),
};
}
export type Category = {
readonly rule: string;
readonly path: string;
readonly checks: readonly CheckId[];
};
// One real tracked path per selector rule; fictional hand-written samples are impossible.
export function categories(model: Model): Category[] {
const found = new Map<string, Category>();
for (const path of [...model.trackedFiles].sort()) {
const plan = selectChecks({
changedFiles: [path],
packageEntryFiles: model.packageEntryFiles,
});
if (plan.failOpen) continue;
for (const { rule } of plan.reasons) {
if (!found.has(rule)) found.set(rule, { rule, path, checks: plan.checks });
}
}
return [...found.values()];
}
export function owningLanes(model: Model): Map<CheckId, string[]> {
return new Map(CHECK_CATALOG.map((spec) => [spec.id, covered(spec, null, model).lanes]));
}
export function loadModel(
repoRoot: string,
trackedFiles: readonly string[],
opaque: Readonly<Record<string, readonly string[]>> = OPAQUE_RUNNERS,
): Model {
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
scripts: Record<string, string>;
exports?: Record<string, { import?: string }>;
};
const config = fs.readFileSync(path.join(repoRoot, 'vitest.config.ts'), 'utf8');
return {
scripts: pkg.scripts,
packageEntryFiles: Object.values(pkg.exports ?? {})
.map((entry) => entry.import)
.filter((target): target is string => typeof target === 'string')
.map((target) => target.replace(/^\.\/dist\//, '').replace(/\.js$/, '.ts')),
vitestProjects: [...config.matchAll(/name:\s*'([^']+)'/g)].map((match) => match[1] as string),
lanes: loadLanes(path.join(repoRoot, '.github/workflows'), repoRoot, pkg.scripts),
trackedFiles: new Set(trackedFiles),
opaque,
};
}
+56
View File
@@ -0,0 +1,56 @@
// The runner resolves a CheckId to the registry's command and forwards the rest.
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveGate } from './run.ts';
const scripts = {
lint: 'oxlint .',
'check:fallow': 'fallow audit',
'test:replay:macos': 'node src/bin.ts test test/integration/replays/macos',
};
test('a registered gate resolves to its package script', () => {
assert.deepEqual(resolveGate('lint', scripts, []), ['pnpm', 'run', 'lint']);
});
test('lane-specific flags are forwarded verbatim, with or without a `--` separator', () => {
const expected = ['pnpm', 'run', 'test:replay:macos', '--retries', '2'];
assert.deepEqual(resolveGate('replay-macos', scripts, ['--retries', '2']), expected);
assert.deepEqual(resolveGate('replay-macos', scripts, ['--', '--retries', '2']), expected);
});
test('a lane supplying --base replaces the default rather than passing it twice', () => {
assert.deepEqual(resolveGate('fallow', scripts, ['--base', 'origin/release']), [
'pnpm',
'run',
'check:fallow',
'--base',
'origin/release',
]);
assert.deepEqual(resolveGate('fallow', scripts, []), [
'pnpm',
'run',
'check:fallow',
'--base',
'origin/main',
]);
});
test('an unknown gate fails loudly and lists what is registered', () => {
assert.throws(
() => resolveGate('laering', scripts, []),
(error: Error) => {
assert.match(error.message, /Unknown gate "laering"/);
assert.match(error.message, /Registered gates: .*\blayering\b/);
return true;
},
);
});
test('a registered check whose script was renamed away fails instead of silently skipping', () => {
assert.throws(
() => resolveGate('lint', { 'lint:new': 'oxlint .' }, []),
/references package.json script "lint", which does not exist/,
);
});
+63
View File
@@ -0,0 +1,63 @@
// `pnpm gate <check-id> [args…]` — the only way CI may run a gate.
//
// The run-gate action calls this with a structural id. The manifest reads that YAML field,
// never this command's surrounding shell. Raw workflow shell may still invoke this CLI, but
// doing so declares no ownership.
//
// Extra arguments are forwarded verbatim, so lane-specific flags (`--base`,
// `--udid`, `--report-junit`) stay in the workflow where they belong.
import { pathToFileURL } from 'node:url';
import fs from 'node:fs';
import path from 'node:path';
import { runCmdStreaming, runCmdSync } from '../../src/utils/exec.ts';
import { runEntrypoint } from '../lib/cli-entrypoint.ts';
import { CHECK_CATALOG, resolveCommand } from '../check-affected/checks.ts';
const USAGE = 'Usage: pnpm gate <check-id> [args…]\n';
export function resolveGate(
id: string,
scripts: Readonly<Record<string, string>>,
args: readonly string[],
): string[] {
// `pnpm gate x -- --flag` and `pnpm gate x --flag` both forward `--flag`.
const extra = args[0] === '--' ? args.slice(1) : args;
const spec = CHECK_CATALOG.find((entry) => entry.id === id);
if (!spec) {
const known = CHECK_CATALOG.map((entry) => entry.id).join(', ');
throw new Error(`Unknown gate "${id}".\nRegistered gates: ${known}`);
}
const command = resolveCommand(spec, scripts, 'origin/main');
// `fallow` carries a default --base; a lane that supplies its own replaces it
// rather than passing the flag twice.
const injected = extra.includes('--base') ? command.indexOf('--base') : -1;
const resolved =
injected === -1 ? command : [...command.slice(0, injected), ...command.slice(injected + 2)];
return [...resolved, ...extra];
}
async function main(argv: readonly string[]): Promise<number> {
const [id, ...rest] = argv;
if (!id || id === '--help') {
process.stdout.write(USAGE);
return id ? 0 : 1;
}
const repoRoot = runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim();
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as {
scripts: Record<string, string>;
};
const command = resolveGate(id, pkg.scripts, rest);
process.stdout.write(`[gate] ${id}: ${command.join(' ')}\n`);
const result = await runCmdStreaming(command[0] as string, command.slice(1), {
cwd: repoRoot,
allowFailure: true,
onStdoutChunk: (chunk) => void process.stdout.write(chunk),
onStderrChunk: (chunk) => void process.stderr.write(chunk),
});
return result.exitCode;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
runEntrypoint('gate', () => main(process.argv.slice(2)));
}
+28
View File
@@ -0,0 +1,28 @@
// Minimal package-script splitting for suite registration. Workflow ownership never uses it.
/** `VAR=x VAR2="y z" ` at the head of a segment. */
export const ENV_PREFIX = /^(?:[A-Z_][A-Z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/;
/**
* `a && b`, `a; b`, and `a | b` all run both sides; newlines and `\` continuations join
* first. Env prefixes are LEFT ON: `NODE_OPTIONS=--import ./x.ts pnpm gate lint` runs
* code, so a caller deciding whether a segment is a bare gate invocation has to see it.
*/
export function commandSegments(body: string): string[] {
return body
.replace(/\\\n/g, ' ')
.split('\n')
.map(stripComment)
.join('\n')
.split(/&&|\|\||[;\n|]/)
.map((segment) => segment.trim())
.filter(Boolean);
}
/** `echo done # && pnpm test:unit` must not credit test:unit. Quotes protect a literal `#`. */
function stripComment(line: string): string {
for (const match of line.matchAll(/"[^"]*"|'[^']*'|(?:^|\s)#/g)) {
if (match[0].endsWith('#')) return line.slice(0, match.index);
}
return line;
}
+160
View File
@@ -0,0 +1,160 @@
// What the loader derives from the real workflow tree, plus the two structural cases the
// tree cannot show: a composite-action cycle, and an action whose source is not here.
import { execFileSync } from 'node:child_process';
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 { parse } from 'yaml';
import { loadModel } from './model.ts';
import { loadLanes, matchesGlob, verbatimScripts } from './workflows.ts';
const repoRoot = path.resolve(import.meta.dirname, '../..');
const tracked = execFileSync('git', ['ls-files'], {
cwd: repoRoot,
encoding: 'utf8',
})
.split('\n')
.filter(Boolean);
const model = loadModel(repoRoot, tracked);
test('the canonical action binds and runs a structural gate without optional arguments', () => {
const action = parse(
fs.readFileSync(path.join(repoRoot, '.github/actions/run-gate/action.yml'), 'utf8'),
) as {
inputs: { gate: { required: boolean } };
runs: { steps: { env?: Record<string, string>; run?: string }[] };
};
assert.equal(action.inputs.gate.required, true);
const runner = action.runs.steps.at(-1);
assert.equal(runner?.env?.INPUT_GATE, '${{ inputs.gate }}');
assert.match(runner?.run ?? '', /pnpm gate "\$INPUT_GATE"/);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'run-gate-'));
try {
const mockPnpm = path.join(root, 'pnpm');
fs.writeFileSync(mockPnpm, '#!/bin/bash\nprintf "%s\\n" "$@"\n');
fs.chmodSync(mockPnpm, 0o755);
const stdout = execFileSync('/bin/bash', ['-c', runner?.run ?? ''], {
encoding: 'utf8',
env: {
...process.env,
PATH: `${root}:${process.env.PATH ?? ''}`,
INPUT_GATE: 'layering',
INPUT_ARGS: '',
GITHUB_OUTPUT: path.join(root, 'output'),
},
});
assert.equal(stdout, 'gate\nlayering\n');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
/** Write a workflow (and optionally a tree of actions) and load it with the real loader. */
function planted(files: Record<string, string>): ReturnType<typeof loadLanes> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gate-wf-'));
try {
for (const [name, body] of Object.entries(files)) {
fs.mkdirSync(path.join(root, path.dirname(name)), { recursive: true });
fs.writeFileSync(path.join(root, name), body);
}
return loadLanes(path.join(root, '.github/workflows'), root, model.scripts);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
test('a command repeating a script body verbatim credits that script', () => {
const body = model.scripts['check:package'] as string;
assert.deepEqual(verbatimScripts(body, model.scripts), ['check:package']);
assert.deepEqual(verbatimScripts('node scripts/something-else.ts', model.scripts), []);
});
test('path filters use GitHub glob semantics', () => {
assert.equal(matchesGlob('website/**', 'website/docs/docs/commands.md'), true);
assert.equal(matchesGlob('docs/**', 'website/docs/x.md'), false);
assert.equal(matchesGlob('*.md', 'README.md'), true);
assert.equal(matchesGlob('*.md', 'docs/README.md'), false, '* must not span a separator');
});
test('lanes carry the workflow spelling the catalog used, and only real triggers qualify', () => {
const labels = model.lanes.map((lane) => lane.label);
assert.ok(labels.includes('Coverage'), 'CI jobs are named bare');
assert.ok(labels.includes('iOS / Smoke Tests'), 'other workflows are prefixed');
const deploy = model.lanes.find((lane) => lane.workflow === 'deploy.yml');
assert.equal(deploy?.qualifying, false, 'a push-only lane gates nothing on the way in');
});
test('a gate invoked from inside a composite action belongs to the calling lane', () => {
const android = model.lanes.find((lane) => lane.label === 'Android / Smoke Tests');
assert.ok(
android?.gates.includes('android-helpers'),
'the helper build is reached through the setup action',
);
});
test('a composite action cycle is a loud error, not an empty step list', () => {
// The depth cutoff this replaces returned no steps, which reads to every assertion
// downstream as "this action executes nothing" — silence in the one place that must shout.
assert.throws(
() =>
planted({
'.github/workflows/planted.yml': `name: Planted
on:
pull_request:
jobs:
planted:
steps:
- uses: ./.github/actions/a
`,
'.github/actions/a/action.yml': `runs:
using: composite
steps:
- uses: ./.github/actions/b
`,
'.github/actions/b/action.yml': `runs:
using: composite
steps:
- uses: ./.github/actions/a
`,
}),
/composite action cycle/,
);
});
test('nesting deeper than the old cutoff is followed rather than silently dropped', () => {
const nested = (next: string) => `runs:
using: composite
steps:
- uses: ./.github/actions/${next}
`;
const lanes = planted({
'.github/workflows/planted.yml': `name: Planted
on:
pull_request:
jobs:
planted:
steps:
- uses: ./.github/actions/a
`,
'.github/actions/a/action.yml': nested('b'),
'.github/actions/b/action.yml': nested('c'),
'.github/actions/c/action.yml': nested('d'),
'.github/actions/d/action.yml': nested('e'),
'.github/actions/e/action.yml': `runs:
using: composite
steps:
- uses: ./.github/actions/run-gate
with:
gate: layering
`,
});
assert.deepEqual(
lanes[0]?.gates,
['layering'],
'a gate five levels down is still credited to the lane',
);
});
+166
View File
@@ -0,0 +1,166 @@
// Which registered gates each GitHub job declares through the canonical run-gate action.
// Raw `run:` text is deliberately invisible: shell text is not an execution graph.
import fs from 'node:fs';
import path from 'node:path';
import { parse } from 'yaml';
import type { CheckId } from '../check-affected/model.ts';
import { commandSegments } from './shell.ts';
const RUN_GATE_ACTION = './.github/actions/run-gate';
export type Lane = {
readonly workflow: string;
readonly label: string;
readonly qualifying: boolean;
readonly gates: readonly CheckId[];
readonly verbatim: readonly string[];
readonly paths: readonly string[];
readonly pathsIgnore: readonly string[];
readonly unsupported: readonly string[];
};
type RawStep = {
uses?: string;
run?: string;
with?: Record<string, unknown>;
};
type Job = { name?: string; steps?: RawStep[]; uses?: string };
type WorkflowDoc = {
name?: string;
on?: Record<string, never>;
true?: Record<string, never>;
jobs?: Record<string, Job>;
};
type ActionDoc = { runs?: { steps?: RawStep[] } };
function laneLabel(workflow: string, job: string): string {
return workflow === 'CI' ? job : `${workflow} / ${job}`;
}
function readLocalAction(uses: string | undefined, root: string): ActionDoc | null {
if (!uses?.startsWith('./')) return null;
const file = path.join(root, uses.slice(2), 'action.yml');
return fs.existsSync(file) ? (parse(fs.readFileSync(file, 'utf8')) as ActionDoc) : null;
}
function declaredGate(
step: RawStep,
inputs: Readonly<Record<string, unknown>>,
): CheckId | undefined {
if (step.uses !== RUN_GATE_ACTION) return undefined;
const raw = step.with?.gate;
const input =
typeof raw === 'string' ? /^\$\{\{\s*inputs\.([\w-]+)\s*\}\}$/.exec(raw)?.[1] : undefined;
const gate = input ? inputs[input] : raw;
return typeof gate === 'string' && gate.trim() ? (gate.trim() as CheckId) : undefined;
}
function declaredGates(
steps: readonly RawStep[],
root: string,
chain: readonly string[] = [],
inputs: Readonly<Record<string, unknown>> = {},
): CheckId[] {
const gates: CheckId[] = [];
for (const step of steps) {
const gate = declaredGate(step, inputs);
if (gate) {
gates.push(gate);
continue;
}
const action = readLocalAction(step.uses, root);
if (!action || !step.uses) continue;
if (chain.includes(step.uses)) {
throw new Error(`composite action cycle: ${[...chain, step.uses].join(' → ')}`);
}
gates.push(
...declaredGates(action.runs?.steps ?? [], root, [...chain, step.uses], step.with ?? {}),
);
}
return gates;
}
function triggerPaths(on: Record<string, { paths?: string[]; 'paths-ignore'?: string[] }>) {
const pr = on.pull_request ?? {};
return { paths: pr.paths ?? [], pathsIgnore: pr['paths-ignore'] ?? [] };
}
function workflowLanes(
file: string,
doc: WorkflowDoc,
root: string,
scripts: Readonly<Record<string, string>>,
): Lane[] {
const on = (doc.on ?? doc.true ?? {}) as Record<
string,
{ paths?: string[]; 'paths-ignore'?: string[] }
>;
const qualifying = 'pull_request' in on || 'schedule' in on;
const { paths, pathsIgnore } = triggerPaths(on);
return Object.entries(doc.jobs ?? {}).map(([jobId, job]) => ({
workflow: file,
label: laneLabel(doc.name ?? file, job.name ?? jobId),
qualifying,
gates: [...new Set(declaredGates(job.steps ?? [], root))],
verbatim: (job.steps ?? []).flatMap((step) =>
typeof step.run === 'string' ? verbatimScripts(step.run, scripts) : [],
),
paths,
pathsIgnore,
unsupported: job.uses ? [`\`uses: ${job.uses}\` (reusable workflow)`] : [],
}));
}
export function loadLanes(
dir: string,
root: string,
scripts: Readonly<Record<string, string>> = {},
): Lane[] {
return fs
.readdirSync(dir)
.filter((entry) => entry.endsWith('.yml'))
.sort()
.flatMap((file) =>
workflowLanes(
file,
parse(fs.readFileSync(path.join(dir, file), 'utf8')) as WorkflowDoc,
root,
scripts,
),
);
}
export function matchesGlob(pattern: string, file: string): boolean {
const escape = (part: string) =>
part.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
return new RegExp(
`^${pattern
.split(/(\/\*\*\/|\/\*\*|\*\*\/|\*\*)/)
.map((part) => {
if (part === '/**/') return '/(?:.*/)?';
if (part === '**/') return '(?:.*/)?';
if (part === '/**') return '(?:/.*)?';
if (part === '**') return '.*';
return escape(part);
})
.join('')}$`,
).test(file);
}
export function triggersOnPath(lane: Lane, file: string): boolean {
if (lane.pathsIgnore.some((pattern) => matchesGlob(pattern, file))) return false;
if (lane.paths.length > 0) return lane.paths.some((pattern) => matchesGlob(pattern, file));
return true;
}
export function verbatimScripts(
command: string,
scripts: Readonly<Record<string, string>>,
): string[] {
const wanted = commandSegments(command).map((segment) => segment.replace(/\s+/g, ' '));
return Object.entries(scripts)
.filter(([, body]) => wanted.includes(body.replace(/\s+/g, ' ')))
.map(([name]) => name);
}
+12
View File
@@ -0,0 +1,12 @@
// Shared bootstrap for script entrypoints: run `main`, exit on its code, and turn a
// thrown error into one prefixed line rather than a stack trace.
export function runEntrypoint(prefix: string, main: () => Promise<number>): void {
main().then(
(code) => process.exit(code),
(error: unknown) => {
process.stderr.write(`${prefix}: ${error instanceof Error ? error.message : error}\n`);
process.exit(1);
},
);
}
+3 -3
View File
@@ -35,12 +35,12 @@ test('the weekly sweep shards exactly the registry matrix', () => {
test('the weekly sweep merges the shards into one ratcheted verdict', () => {
const yaml = workflow('mutation-weekly.yml');
assert.match(yaml, /pnpm mutation:check --report-dir/);
assert.match(yaml, /gate: mutation-check[\s\S]*--report-dir/);
assert.match(yaml, /GITHUB_STEP_SUMMARY|\$GITHUB_STEP_SUMMARY/);
// A dead shard must not be merged into a verdict that looks like a sweep.
assert.match(
yaml,
new RegExp(`--expect-shards ${shardMatrix().length}\\b`),
new RegExp(`--expect-shards\\s+${shardMatrix().length}\\b`),
'the weekly ratchet does not require the full shard set',
);
});
@@ -84,7 +84,7 @@ test('every kernel path a PR can touch selects the affected mutation job', () =>
paths.includes('packages/*/src/**/*.test.ts'),
'the PR lane must trigger on every packages/*/src test too — target-annotation-serde is owned by one',
);
assert.match(workflow('mutation-affected.yml'), /mutation:affected --list-affected/);
assert.match(workflow('mutation-affected.yml'), /gate: mutation-affected[\s\S]*--list-affected/);
// The lane's own sources fail open into it too: a ratchet or baseline edit must
// prove itself against real mutants, not against a stale report.
for (const own of ['scripts/mutation/**', 'stryker.config.json', 'mutation-baselines/**']) {
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import { test } from 'node:test';
import { workspaceSpecifierTargets } from '../layering/package-boundaries.ts';
import { workspaceSourceAliases } from './workspace-aliases.ts';
const repoRoot = path.resolve(import.meta.dirname, '../..');
test('Stryker aliases every exported workspace subpath exactly', () => {
const targets = workspaceSpecifierTargets(repoRoot);
const aliases = workspaceSourceAliases(repoRoot);
for (const [specifier, target] of targets) {
const matches = aliases.filter(({ find }) => find.test(specifier));
assert.deepEqual(
matches.map(({ replacement }) => replacement),
[path.join(repoRoot, target)],
`${specifier} must resolve to its own export instead of a package-root prefix`,
);
}
assert.equal(
aliases.some(({ find }) => find.test('@agent-device/selectors/not-exported')),
false,
);
});
+19
View File
@@ -0,0 +1,19 @@
import path from 'node:path';
import { workspaceSpecifierTargets } from '../layering/package-boundaries.ts';
export type WorkspaceSourceAlias = {
find: RegExp;
replacement: string;
};
function exactSpecifier(specifier: string): RegExp {
return new RegExp(`^${specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
}
/** Exact exported workspace-package aliases for Vitest inside Stryker's sandbox. */
export function workspaceSourceAliases(repoRoot: string): WorkspaceSourceAlias[] {
return [...workspaceSpecifierTargets(repoRoot)].map(([specifier, target]) => ({
find: exactSpecifier(specifier),
replacement: path.join(repoRoot, target),
}));
}
+2 -5
View File
@@ -1,8 +1,8 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
import { workspaceSpecifierTargets } from './scripts/layering/package-boundaries.ts';
import { readTestScope, threadHostileTestFiles } from './scripts/mutation/test-scope.ts';
import { workspaceSourceAliases } from './scripts/mutation/workspace-aliases.ts';
import { SUBPROCESS_STUB_TESTS } from './vitest.config.ts';
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
@@ -16,10 +16,7 @@ const repoRoot = path.dirname(fileURLToPath(import.meta.url));
// which Stryker copies into the sandbox — keeps resolution inside the mutated
// tree. Entries come from the shared exports-map reader, never a wildcard, so
// this cannot resolve package internals the boundary forbids (R11).
const workspaceAliases = [...workspaceSpecifierTargets(repoRoot)].map(([find, target]) => ({
find,
replacement: path.join(repoRoot, target),
}));
const workspaceAliases = workspaceSourceAliases(repoRoot);
// Test scope for the decision-kernel mutation lane (issue #1415).
//