mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
* 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:
committed by
GitHub
parent
c7565cb1f8
commit
9c22467832
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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`',
|
||||
);
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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(' '),
|
||||
};
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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).
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user