fix: address Phase 2 review findings from #1819 #4517
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| # Build native modules (better-sqlite3, bufferutil, utf-8-validate) from source | |
| # instead of downloading prebuilt binaries via `prebuild-install`. Those | |
| # prebuilds come from GitHub Releases (objects.githubusercontent.com), whose | |
| # fetch wedged on the hosted runners — `npm ci` hung silently after the | |
| # `prebuild-install` warning until the job timeout, on every retry. Source | |
| # builds use only the tarball already in the npm cache + the runner's toolchain | |
| # (~48s for better-sqlite3), so the install no longer depends on that CDN. | |
| # No-op for pure-JS deps, for the root install (no gyp natives), and for the | |
| # `--omit=dev` binary-matrix jobs. See RCA docs/rca/2026-05-30-state-source-integrity.md. | |
| env: | |
| npm_config_build_from_source: 'true' | |
| jobs: | |
| # ───────────────────────────────────────────────────────────────────── | |
| # Gate hosting taxonomy (DR-1): every job/gate below lives in exactly one | |
| # of four host classes (zero-dep prefix unfiltered, deps-tail unfiltered, | |
| # deps-unfiltered suite-shaped, deps-filtered). The decision table, the | |
| # two-surface subset rule, and the non-blocking allowlist contract | |
| # (tests/scripts/ci-topology.test.ts) are documented ONCE, canonically, at | |
| # docs/guides/ci-gate-hosting.md — cite it at each gate's wiring site, | |
| # do not restate it here. `pr-body-check.yml` is a separate workflow, | |
| # outside this aggregator's contract (see the guide's out-of-scope note). | |
| # ───────────────────────────────────────────────────────────────────── | |
| changes: | |
| name: Detect Changes | |
| if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| root: ${{ steps.filter.outputs.root }} | |
| mcp: ${{ steps.filter.outputs.mcp }} | |
| prompts: ${{ steps.filter.outputs.prompts }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dorny/paths-filter@v3 | |
| id: filter | |
| with: | |
| filters: | | |
| root: | |
| - 'src/**' | |
| # Task 030 moved every co-located suite out of `src/`, so a PR | |
| # that changes only a test now touches `tests/**` and nothing | |
| # else. Without this the jobs policing that test are skipped — | |
| # and a skipped required job reads as passed (#1711). Deliberately | |
| # the whole tree rather than a tier list, because a new tier would | |
| # otherwise be un-triggered until someone noticed. | |
| - 'tests/**' | |
| - 'content/**' | |
| - 'content/harness/runtimes/**' | |
| # Projection roots (DR-22): these directories/files are part of | |
| # the shipped surface (see `files` in package.json) and are | |
| # regenerated + drift-checked by `skills:guard` / `hooks:guard` | |
| # in the `test-root` job below. Without an entry here, a PR | |
| # that ONLY touches one of these paths never flips `root` to | |
| # 'true' — `test-root` (and both guards) never runs, and the | |
| # deletion/mutation of a shipped agent, command alias, or hook | |
| # passes CI green, unobserved (the gap this filter closes). | |
| # `rendered/` IS those roots. The DR-4 block folded `agents/`, | |
| # `command-aliases/`, `commands/`, `rules/` and `skills/` into one | |
| # generated tree and this list kept naming the old five, so all of | |
| # them matched nothing — reopening the exact gap the comment above | |
| # says this filter closes, since a PR touching only generated | |
| # output stopped flipping `root` and `render:guard` stopped | |
| # running on the PR that caused the drift. | |
| - 'rendered/**' | |
| - 'hooks/**' | |
| - 'content/harness/hooks/**' | |
| - 'binding/**' | |
| - 'content/harness/binding/**' | |
| - '.claude-plugin/**' | |
| # The agent-generator sources under the MCP workspace feed the | |
| # rendered `agents/**` projection above; a change there must | |
| # trigger the root suite's `skills:guard` drift check on the | |
| # CAUSING PR, not only on a later PR that happens to touch the | |
| # rendered output. | |
| - 'src/runtime/agents/**' | |
| - 'AGENTS.md' | |
| - 'package.json' | |
| - 'tsconfig.json' | |
| - '.github/workflows/ci.yml' | |
| # `tests/scripts/release-workflow.test.ts` (root suite) parses | |
| # release.yml — a release.yml-only PR must run the root suite | |
| # or the workflow's own contract test never fires on the PR | |
| # that changes it. | |
| - '.github/workflows/release.yml' | |
| # Task 036 gathered every gate, build and publish script under | |
| # `tools/`. They were `scripts/**`, deliberately outside this | |
| # filter, with unfiltered jobs re-asserting them. Naming the tree | |
| # is the simpler guarantee: a PR that weakens a gate now triggers | |
| # the lane that runs it, instead of relying on a second job to | |
| # notice. The whole tree, so a new `tools/` root is not | |
| # un-triggered until someone spots it. | |
| - 'tools/**' | |
| mcp: | |
| - 'src/**' | |
| # `tests/unit/**` and `tests/integration/**` are the core suite's | |
| # own tests after task 030 — same reason as the `root` filter. | |
| - 'tests/**' | |
| # Was `scripts/core/**`. Widened to the whole tree by task 036 for | |
| # the same reason as the `root` filter above: this lane hosts | |
| # gates whose sources moved into sibling `tools/` roots, and a | |
| # gate outside every filter of its own hosting job is exactly what | |
| # the guard inventory's reachability proof rejects. | |
| - 'tools/**' | |
| - 'tools/test-helpers/**' | |
| - 'vitest.config.ts' | |
| - 'package.json' | |
| - '.github/workflows/ci.yml' | |
| prompts: | |
| # Authored sources and the tree they render to. These were | |
| # `skills/`, `commands/` and `rules/` until the DR-4 block moved | |
| # them, after which this lane fired on prompt changes only by | |
| # accident — through `tests/evals/**`. | |
| - 'content/**' | |
| - 'rendered/**' | |
| - 'tests/evals/**' | |
| - 'tools/evals/evals/**' | |
| # Opt-in eval package (DR-3): owns the promptfoo dep the graders | |
| # dynamic-import. A change here must still fire RUN_EVALS / the | |
| # eval gate, so it is named alongside the eval sources above. | |
| - 'tools/evals-pkg/**' | |
| - 'src/workflow/playbooks.ts' | |
| test-root: | |
| name: Root Package | |
| needs: changes | |
| if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') && needs.changes.outputs.root == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| # fetch-depth: 0 — the DR-8 legacy-hash manifest test | |
| # (tests/scripts/generate-legacy-skill-hashes.test.ts, part of the root unit suite) | |
| # derives render hashes from release tags via `git show <tag>:<path>`. The | |
| # default shallow, tagless checkout makes `git tag --list 'v2.*'` empty → | |
| # enumerateReleaseRefs() returns [] → the suite reds. Full history brings the | |
| # tags AND their tree objects. | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.3.13 | |
| - run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh" | |
| - run: npm run typecheck | |
| # Windows-portability lint (#1623): editor-first ESLint rules, also run | |
| # here as a config-rot guard + second enforcement of the spawn-shim / | |
| # URL.pathname anti-patterns (the grep-gate is the primary CI enforcer). | |
| - run: npm run lint:windows | |
| - run: bash tests/scripts/check-eslint-windows.test.sh | |
| - run: npm run test:run | |
| # The extracted conformance suite (task 018a). `npm run test:run` is the | |
| # `unit` project ONLY, so the `conformance` project needs its own step or | |
| # it is collected by nothing — the silent-green shape this suite exists to | |
| # catch, applied to the suite itself. This is the WHOLESALE host, the role | |
| # `test-mcp` filled before the extraction; the five Wave-1 guards keep | |
| # their individually-named UNFILTERED hosts on `grep-gates`. | |
| - run: npm run test:conformance | |
| # End-to-end install acceptance (task 028). Its own step for the same | |
| # reason as conformance above — `npm run test:run` is the `unit` project | |
| # only, so this project is collected by nothing without it. | |
| # | |
| # It materializes HEAD's TRACKED content into a scratch dir and runs the | |
| # real installer over that, which is the only vantage point from which a | |
| # gitignored payload, an unflattened skill tree, or a hook registered | |
| # twice is visible. Every other check reads the working tree, where an | |
| # untracked file resolves exactly like a shipped one. | |
| - run: npm run test:acceptance | |
| # render:guard — the single drift gate over everything the build | |
| # generates. Re-renders content/ and fails if any generated tree differs | |
| # from what is committed: the rendered/ artifact trees (skills, commands, | |
| # rules, agents, command-aliases), the plugin-root hooks/ and binding/ | |
| # output, the per-harness agent dot-directories, and the embedded runtime | |
| # module codegen'd from content/harness/runtimes/*.yaml. | |
| # | |
| # It replaces skills:guard, hooks:guard and runtimes:guard, which each | |
| # covered one slice. Consolidating them also lets the guard assert its own | |
| # liveness: every declared scope must resolve to a non-empty tree, so a | |
| # guard that has stopped watching fails instead of reporting one confident | |
| # success. Depends on dist/install/render-guard.js from the earlier | |
| # `npm ci` (`prepare` -> `tsc`). | |
| - run: npm run render:guard | |
| # desc:budget-guard — MCP description token-budget gate (#1321, R-E). | |
| # Tool/action descriptions cost every agent on every `tools/list` call; | |
| # this asserts each composite-tool / slim / per-action description stays | |
| # under a documented token budget (chars/4 estimate) and fails CI on | |
| # overrun with a per-offender breakdown. Runs here (not grep-gates) | |
| # because it imports TOOL_REGISTRY via tsx, which needs both the root | |
| # and MCP-server deps this job already installs. Sibling to skills:guard | |
| # / lint:invariants. Blocking via the ci-gate aggregator (test-root). | |
| - run: npm run desc:budget-guard | |
| test-mcp: | |
| name: Exarchos MCP Server | |
| needs: changes | |
| if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') && needs.changes.outputs.mcp == 'true' | |
| runs-on: ubuntu-latest | |
| env: | |
| RUN_EVALS: ${{ needs.changes.outputs.prompts == 'true' && '1' || '' }} | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| # fetch-depth: 0 — the DR-7 mutation gate (check-mutation-gate.mjs, | |
| # observe→blocking) diffs `<base>...HEAD` (merge-base three-dot) to | |
| # scope changed `src`. A shallow checkout lacks | |
| # the merge-base commit, so `git diff FETCH_HEAD...HEAD` fails with | |
| # "no merge base" (fail-closed). Full history makes the base-ref diff | |
| # resolvable in CI. Mirrors the fetch-depth:0 jobs elsewhere here. | |
| fetch-depth: 0 | |
| # The mutation gate only reads history, and this job then runs | |
| # repo/dependency code (test runners, bun build, npm postinstalls), so | |
| # don't persist the checkout token into .git/config for those steps. | |
| persist-credentials: false | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| # `compiled-binary-mcp.test.ts` shells out to | |
| # `bun run tools/release/build-binary.ts` from the repo root — bun must | |
| # be on PATH or the spawn fails with `exit null`. | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.3.13 | |
| # One install: task 019 dissolved the nested workspace, so the core's | |
| # dependency closure and the installer's are the same closure now. | |
| - name: Install dependencies | |
| run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh" | |
| - run: npx tsc --noEmit | |
| # DR-5 (coverage baseline): the plain `npm run test:run` suite step BECOMES | |
| # the coverage-instrumented run — ONE execution, not two (a second full | |
| # run would double wall-time and double exposure to the known flaky | |
| # E2E/perf tests). RUN_EVALS is pinned OFF here (step env overrides the | |
| # job-level derivation) so the measured lane is stable and | |
| # RUN_EVALS-independent; the eval lane it used to carry is preserved as | |
| # its own conditional step below. DR-1 host class: deps, filtered on `mcp` | |
| # — scan surface src/** ⊆ the mcp filter; the scripts/ | |
| # ratchet implementation is re-asserted unfiltered by a grep-gates | |
| # .test.sh step (DR-10). See docs/guides/ci-gate-hosting.md. | |
| - name: Coverage-instrumented suite (DR-5, RUN_EVALS pinned off) | |
| env: | |
| RUN_EVALS: '' | |
| run: npm run test:coverage | |
| # DR-5 coverage non-regression ratchet, BLOCKING (task 009: the CI soak | |
| # is complete — 3 green observe-mode runs on this feature branch fed the | |
| # measured, floored-epsilon baseline at | |
| # tools/audit/coverage-baseline.json — so `--observe` is | |
| # dropped and a regression beyond the floored epsilon now fails this | |
| # job). The path is $GITHUB_WORKSPACE-absolute because the script | |
| # resolves its own inputs from its location, not the cwd. DR-1 host | |
| # class: deps, | |
| # filtered on `mcp`. See docs/guides/ci-gate-hosting.md. | |
| # `if: !cancelled()` (not the implicit `success()`): the ratchet is | |
| # EVIDENCE that must be captured even when the coverage suite above went | |
| # red — vitest writes coverage-summary.json on failure too | |
| # (reportOnFailure), so the ratchet still has a summary to inspect. A red | |
| # suite already fails the job on its own step; running the ratchet anyway | |
| # does not mask that (it can only ADD a reason), it just ensures the | |
| # non-regression signal is never silently skipped after an unrelated flake. | |
| - name: Coverage ratchet — blocking (DR-5) | |
| if: ${{ !cancelled() }} | |
| run: node "$GITHUB_WORKSPACE/tools/audit/gates/check-coverage-ratchet.mjs" | |
| # DR-5: publish the coverage summary as a run artifact so task 009 can | |
| # capture the live baseline (totals + run-ids) from ≥3 CI runs. `path` is | |
| # workspace-relative — upload-artifact ignores the job's run | |
| # default-directory. | |
| - name: Upload coverage summary (DR-5) | |
| if: ${{ !cancelled() }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-summary | |
| path: coverage/coverage-summary.json | |
| if-no-files-found: error | |
| # DR-5 eval-lane preservation (non-negotiable): the replaced plain-suite | |
| # step was the ONLY execution surface of the RUN_EVALS-gated eval tests | |
| # (evals/harness.test.ts, evals/graders/llm-rubric.test.ts). This | |
| # conditional step reproduces the OLD job-level RUN_EVALS derivation | |
| # EXACTLY — it fires iff `needs.changes.outputs.prompts == 'true'`, the | |
| # same predicate that set RUN_EVALS=1 — running the eval files with | |
| # RUN_EVALS=1 OUTSIDE the coverage instrument, so the eval lane fires on | |
| # prompts-touching PRs exactly as before. DR-1 host class: deps, filtered | |
| # on `mcp`. See docs/guides/ci-gate-hosting.md. | |
| - name: Eval lane (RUN_EVALS-gated, prompts-touching PRs only) (DR-5) | |
| # `!cancelled() &&` the SAME prompts predicate: the eval lane is the | |
| # preserved evidence surface for the RUN_EVALS-gated tests — a red | |
| # coverage suite above must not silently skip it on a prompts-touching | |
| # PR. The prompts predicate is unchanged (only prompts-touching PRs run | |
| # it); adding `!cancelled()` only lets it survive an earlier step failure. | |
| if: ${{ !cancelled() && needs.changes.outputs.prompts == 'true' }} | |
| env: | |
| RUN_EVALS: '1' | |
| run: npx vitest run tools/evals/evals/harness.test.ts tools/evals/evals/graders/llm-rubric.test.ts | |
| # DR-7 diff-scoped mutation gate, OBSERVE MODE, pull_request events only | |
| # (a push-to-main has no PR base to diff). The script self-fetches the | |
| # shallow PR base ref (origin/$GITHUB_BASE_REF) and invokes the in-tree | |
| # mutation-adequacy handler via bun (set up above); an empty server diff | |
| # is a logged skip, a git failure is fail-closed. Observe mode logs the | |
| # verdict and always exits 0 — this step STAYS observe (task 009 | |
| # deliberately deferred its flip: the diff-scoped mutation gate degrades | |
| # in CI because StrykerJS dry-run fails on the full server suite; | |
| # tracked in #1720). It flips to blocking once #1720 resolves. DR-1 host | |
| # class: deps, filtered on `mcp` — scan surface servers/** ⊆ the mcp | |
| # filter; the scripts/ impl is re-asserted unfiltered in grep-gates | |
| # (DR-10). See docs/guides/ci-gate-hosting.md. | |
| - name: Mutation gate — observe mode (DR-7), PR events only | |
| if: github.event_name == 'pull_request' | |
| run: node "$GITHUB_WORKSPACE/tools/audit/gates/check-mutation-gate.mjs" --observe | |
| # DR-7 composed-path Stryker smoke test (task 012): resolver → handler → | |
| # adapter → real pinned Stryker → parser, on a tiny fixture scope. Runs in | |
| # its OWN step OUTSIDE the coverage instrument and OUTSIDE the default | |
| # suite glob (excluded in vitest.config.ts) so it neither inflates the | |
| # coverage-measured lane nor rides the Windows leg's known spawn-flake | |
| # class. EXARCHOS_SMOKE_ONLY=1 lifts that exclusion for this one | |
| # invocation (vitest's CLI --exclude is additive and cannot un-exclude). | |
| # test-mcp runs on ubuntu-latest, so this is Linux-only by host. DR-1 host | |
| # class: deps, filtered on `mcp`. See docs/guides/ci-gate-hosting.md. | |
| - name: Composed-path Stryker smoke test (DR-7, task 012) — Linux-only | |
| env: | |
| EXARCHOS_SMOKE_ONLY: '1' | |
| run: npx vitest run tests/unit/verbs/stryker-adapter.smoke.test.ts | |
| # cli:vocab-guard (#1317) — mechanical gate against CLI vocabulary drift | |
| # (R-A / Principle 6). Walks the rendered Commander surface via `buildCli` | |
| # and fails on a banned verb (`info`, `ls`, `rm`) or banned flag alias | |
| # (`--format` as a JSON carrier, `--skip-confirmation*`). Runs here rather | |
| # than in grep-gates because resolving `buildCli` pulls in `bun:sqlite`, | |
| # which needs Bun (already set up above) + the MCP deps installed in this | |
| # job. Flows into the required `ci-gate` check via test-mcp. | |
| - run: npm run cli:vocab-guard | |
| # ───────────────────────────────────────────────────────────────────── | |
| # test-windows (#1170) | |
| # ───────────────────────────────────────────────────────────────────── | |
| # Runs the MCP server unit project on a real windows-latest host — the | |
| # first Windows CI for Exarchos. Two jobs in one: | |
| # 1. Closes the long-standing Windows-CI gap (#1085 class): Linux-only | |
| # tests have shipped Windows bugs (path-separator, ancestry preflight). | |
| # 2. Captures the `merge.preflight` debug payload that #1402 phase-2 RCA | |
| # needs — `EXARCHOS_PREFLIGHT_DEBUG=1` makes the ancestry preflight | |
| # emit its 9-field structured payload on a genuine Windows host (DR-2). | |
| # Harmless on green runs; the RCA reads it from the event store. | |
| # | |
| # `shell: bash` is set per-step for the *.sh helpers (Git Bash ships on | |
| # windows-latest); npm/npx steps use the default shell. The bun setup | |
| # mirrors test-mcp so the compiled-binary tests can resolve their bundler. | |
| # PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD avoids the npm-ci chromium wedge (#1502). | |
| # ───────────────────────────────────────────────────────────────────── | |
| test-windows: | |
| name: Windows Unit (MCP) | |
| needs: changes | |
| if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') && needs.changes.outputs.mcp == 'true' | |
| runs-on: windows-latest | |
| env: | |
| EXARCHOS_PREFLIGHT_DEBUG: '1' | |
| PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.3.13 | |
| - name: Install dependencies | |
| shell: bash | |
| run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh" | |
| - run: npx tsc --noEmit | |
| - run: npm run test:core | |
| # DR-11s (WLM #1576): zero-count guard for the worktree suite. | |
| # `npm run test:run` above already executes the worktree tests on this | |
| # windows-latest host; this step FAILS the job if a future path-filter / | |
| # matrix-exclusion regression silently drops them (a green-on-zero-tests | |
| # lane would otherwise falsely satisfy the Windows-CI requirement). | |
| - name: Guard — worktree suite executed on Windows (DR-11s) | |
| shell: bash | |
| run: | | |
| npx vitest run src/verbs/worktree/ --reporter=json --outputFile=.wlm-win-guard.json | |
| count=$(node -e "const r=require('./.wlm-win-guard.json');process.stdout.write(String((r.numPassedTests||0)+(r.numFailedTests||0)))") | |
| echo "worktree tests executed on windows-latest: $count" | |
| if [ "$count" -eq 0 ]; then | |
| echo '::error::worktree suite ran 0 tests on windows-latest — DR-11s guard (a path-filter/matrix regression dropped the suite)' | |
| exit 1 | |
| fi | |
| # DR-8 (harness-launcher #1603): the NAMED win32-fragile launcher tests. | |
| # `npm run test:run` above already executes these on this windows-latest | |
| # host; running them again BY NAME pins the DR-8 contract that the Windows | |
| # lane gates the async spawn shim resolution (task 003, | |
| # src/utils/process.spawn.test.ts) and the worktree path | |
| # derivation/containment (task 009, src/launcher/topology.test.ts) — | |
| # authored OS-native, not POSIX-literal mocks. Naming the files (vs. only | |
| # the whole suite) makes a future path-filter/matrix regression that drops | |
| # either one fail loudly here rather than pass green-on-zero. The zero-count | |
| # guard is the same fail-closed backstop as DR-11s above. | |
| # | |
| # NOTE: marking test-windows as a REQUIRED branch-protection check is a | |
| # manual repo-settings step (Settings → Branches → main → required status | |
| # checks) — out-of-repo and not vitest-assertable. Capture it as a manual | |
| # followup in the merge PR so "lane present but non-blocking" cannot | |
| # silently pass as done. (test-windows already flows into the blocking | |
| # ci-gate aggregator below.) | |
| - name: DR-8 — named win32-fragile launcher tests (spawn 003 + path 009) | |
| shell: bash | |
| run: | | |
| npx vitest run src/utils/process.spawn.test.ts src/launcher/topology.test.ts --reporter=json --outputFile=.launcher-win-guard.json | |
| count=$(node -e "const r=require('./.launcher-win-guard.json');process.stdout.write(String((r.numPassedTests||0)+(r.numFailedTests||0)))") | |
| echo "win32-fragile launcher tests executed on windows-latest: $count" | |
| if [ "$count" -eq 0 ]; then | |
| echo '::error::named win32-fragile launcher tests ran 0 tests on windows-latest — DR-8 guard (a path-filter/matrix regression dropped src/utils/process.spawn.test.ts or src/launcher/topology.test.ts)' | |
| exit 1 | |
| fi | |
| # ───────────────────────────────────────────────────────────────────── | |
| # test-windows-root (task 021) | |
| # ───────────────────────────────────────────────────────────────────── | |
| # Runs the root `unit` suite (`npm run test:run`) on a real | |
| # windows-latest host. The sibling `test-windows` lane above covers only | |
| # the `core` project; the root tiers (installer surfaces under | |
| # `src/install/`, `scripts/`) had NO Windows coverage until now. This closes that gap | |
| # so root-side path/OS regressions fail here rather than shipping. | |
| # | |
| # Gated on `changes.outputs.root` — the same path filter as the Linux | |
| # `test-root` lane — and wired into the blocking `ci-gate` aggregator | |
| # below (with a fail-closed skip guard mirroring the MCP DR-5 guard). | |
| # | |
| # Install prerequisites mirror `test-root`: the root `unit` vitest project | |
| # includes `scripts/**/*.test.ts` (e.g. `tests/scripts/build-binary.test.ts`, | |
| # which shells out to `bun build --compile`), so bun + the MCP server's | |
| # runtime deps must be present for the bundler to resolve | |
| # pino/commander/@modelcontextprotocol/sdk/zod/yaml. `shell: bash` is set | |
| # on the *.sh install steps (Git Bash ships on windows-latest); npm steps | |
| # use the default shell. PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD avoids the | |
| # npm-ci chromium wedge (#1502). | |
| # | |
| # NOTE: marking this job as a REQUIRED branch-protection check is a manual | |
| # repo-settings step (Settings → Branches → main → required status checks) | |
| # — out-of-repo and not YAML-assertable. Capture it as a manual followup in | |
| # the merge PR so "lane present but non-blocking" cannot silently pass as | |
| # done. (test-windows-root already flows into the blocking ci-gate | |
| # aggregator below.) | |
| # ───────────────────────────────────────────────────────────────────── | |
| test-windows-root: | |
| name: Windows Unit (Root) | |
| needs: changes | |
| if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') && needs.changes.outputs.root == 'true' | |
| runs-on: windows-latest | |
| env: | |
| PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' | |
| steps: | |
| # fetch-depth: 0 — this lane also runs the root unit suite, which includes the | |
| # DR-8 legacy-hash manifest test that reads release-tag trees via `git show`. | |
| # A shallow, tagless checkout would red it (see test-root above). | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.3.13 | |
| - name: Install root dependencies | |
| shell: bash | |
| run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh" | |
| # The MCP server's deps must be present so `bun build --compile` can | |
| # resolve `pino`, `commander`, `@modelcontextprotocol/sdk`, `zod`, and | |
| # `yaml` when bundling `src/index.ts` (exercised by | |
| # `tests/scripts/build-binary.test.ts`, part of the root `unit` project run by | |
| # `npm run test:run`). | |
| - run: npm run test:run | |
| # ───────────────────────────────────────────────────────────────────── | |
| # binary-matrix | |
| # ───────────────────────────────────────────────────────────────────── | |
| # Cross-compiles the Exarchos CLI + MCP server into a single native | |
| # binary per OS/arch pair, using `bun build --compile --target=<bun-target>` | |
| # from `tools/release/build-binary.ts`. | |
| # | |
| # DRIFT CONTRACT: | |
| # - The `matrix.target` list below MUST match the `os-arch` names | |
| # derived from `TARGETS` in `tools/release/build-binary.ts`. | |
| # - `tests/scripts/ci-binary-matrix.test.ts` is the drift enforcement gate: | |
| # if this matrix or `TARGETS` is edited without the other, that | |
| # test fails `npm run test:run`. | |
| # - The script itself rejects unknown `--target <name>` values with | |
| # a listed error message — failing fast inside the runner if a | |
| # typo slips past CI review. | |
| # | |
| # Releases: this job uploads per-target workflow artifacts only. Actual | |
| # GitHub Release publishing + SHA-512 checksums are owned by | |
| # `.github/workflows/release.yml` (task 2.7). | |
| # | |
| # DISPOSITION (DR-4, wave-S enforcement-substrate spec): NON-BLOCKING — | |
| # not in `ci-gate.needs`, and recorded as such (with this rationale) in | |
| # the `tests/scripts/ci-topology.test.ts` non-blocking allowlist. Rationale: | |
| # release-lane compile evidence, not a per-PR gate — the standing reason | |
| # the `--minify` A/B comparison was dropped (#1703). Measured over the | |
| # last 60 completed `ci.yml` runs (2026-07-17): 300/300 per-target job | |
| # instances executed (5-target matrix × 60 runs), 0 failed — the low | |
| # failure rate does not change the disposition, since the rationale for | |
| # non-blocking is architectural (release-lane evidence), not reliability. | |
| # Host class: deps, unfiltered, suite-shaped — see | |
| # docs/guides/ci-gate-hosting.md. | |
| # ───────────────────────────────────────────────────────────────────── | |
| binary-matrix: | |
| name: Binary Matrix (${{ matrix.target }}) | |
| if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # Keep in sync with TARGETS in tools/release/build-binary.ts. | |
| target: | |
| - linux-x64 | |
| - linux-arm64 | |
| - darwin-x64 | |
| - darwin-arm64 | |
| - windows-x64 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.3.13 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| # CodeRabbit/CI #1213: install root deps so `bun build --compile` | |
| # can resolve modules imported via the cross-tree bridge — e.g. | |
| # `js-yaml` (from `src/install/runtimes/load.ts`) and `@inquirer/prompts` | |
| # (from `src/install/install-skills.ts`) which the MCP entry point reaches | |
| # transitively. Both are declared in root `package.json` but were | |
| # absent on the runner because only the nested workspace had a | |
| # `npm ci`. The bundler then errored with "Could not resolve | |
| # 'js-yaml'" / "Could not resolve '@inquirer/prompts'". | |
| - name: Install root dependencies | |
| run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh" | |
| # Install MCP-server *runtime* deps so `bun build --compile` can resolve | |
| # `pino`, `commander`, `@modelcontextprotocol/sdk`, `zod`, and `yaml` | |
| # when bundling `src/index.ts`. `--omit=dev` skips | |
| # the heavy dev-only tree (notably `promptfoo`, an eval-only library, and | |
| # `better-sqlite3`, used only by the Node test shim) — the compiled binary | |
| # embeds `bun:sqlite`, so none of it is needed here. Big install-time win | |
| # + far less exposure to the npm-ci stall (RCA 2026-05-30-state-source-integrity). | |
| - name: Build binary for ${{ matrix.target }} | |
| run: bun run tools/release/build-binary.ts --target ${{ matrix.target }} | |
| - name: Upload binary artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: exarchos-${{ matrix.target }} | |
| path: dist/bin/exarchos-${{ matrix.target }}* | |
| if-no-files-found: error | |
| retention-days: 7 | |
| # ───────────────────────────────────────────────────────────────────── | |
| # validate-no-legacy | |
| # ───────────────────────────────────────────────────────────────────── | |
| # Runs tools/audit/gates/validate-no-legacy.sh, which rolls up two checks: | |
| # 1. The NoLegacy_* shell assertion suite (tests/scripts/validate-no-legacy.test.sh) | |
| # — pins obsolete v2.8 install artifacts as deleted/archived. | |
| # 2. `knip` (files + dependencies + exports + types) via the allowlist-diff | |
| # wrapper (tools/audit/knip-diff.ts) — detects unused modules, deps, | |
| # exported symbols, and types, diffed against | |
| # tools/audit/knip-allowlist.json (fails closed on unallowlisted or | |
| # expired findings, a missing binary, or unparseable output). | |
| # | |
| # Runs in parallel with test-root / test-mcp on a GitHub-hosted | |
| # ubuntu-latest runner. All CI jobs run on ubuntu-latest — the | |
| # self-hosted runners were retired after their npm ci step intermittently | |
| # stalled (network RX dies mid-install), whereas the same install completes | |
| # in 30-45s on hosted runners. | |
| # ───────────────────────────────────────────────────────────────────── | |
| validate-no-legacy: | |
| name: Validate No Legacy | |
| if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - name: Install dependencies (root) | |
| run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh" | |
| # The exports/types sweep resolves TypeScript across the workspaces | |
| # knip.json declares. Since task 019 the product is ONE package, so the | |
| # single install above is the whole closure. | |
| - name: Run validate-no-legacy rollup | |
| run: bash tools/audit/gates/validate-no-legacy.sh | |
| # ───────────────────────────────────────────────────────────────────── | |
| # manifest-gate (DR-2 / Task 004) | |
| # ───────────────────────────────────────────────────────────────────── | |
| # The primary de-divergence guarantee: on each consolidation PR, prove NO | |
| # pre-image test case was lost. Reconstructs BOTH pre-images (the legacy | |
| # __tests__ copy AND the co-located canonical copy) from the PR's merge-base | |
| # via `git show <merge-base>:<path>` (hence `fetch-depth: 0`) and asserts | |
| # every case survives into the PR-HEAD result (merged file or relocated | |
| # <base>.legacy.test.ts sibling) verbatim modulo import-path rewrites, or as | |
| # a textual duplicate — BIDIRECTIONAL (both sides). Equivalence is TEXTUAL | |
| # only; a divergent vi.mock/vi.hoisted/env preamble forces relocate, never a | |
| # silent drop. The gate is inert on PRs that touch no consolidation pair. | |
| # WIRED into `ci-gate.needs` below so it is REQUIRED — a job merely present | |
| # in ci.yml but absent from the aggregator fails OPEN (the Wave-S lesson). | |
| # Host class: deps-tail, unfiltered — see docs/guides/ci-gate-hosting.md. | |
| # ───────────────────────────────────────────────────────────────────── | |
| manifest-gate: | |
| name: Manifest Gate (textual case-preservation) | |
| if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| # DR-2: reconstruct pre-images from the merge-base via `git show`, so | |
| # the full history (not a shallow clone) must be present. | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| # The gate imports consolidate-suite.mjs, which pulls in `typescript` (a | |
| # ROOT devDependency) for TS-compiler-API case extraction. Only the root | |
| # install is needed — no MCP-server deps, no native build. | |
| - name: Install dependencies (root) | |
| run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh" | |
| - name: Manifest gate — no pre-image case dropped (DR-2, Task 004) | |
| # Reconstruct against the PR's actual base branch (not a hardcoded | |
| # origin/main) so a stacked PR onto a non-main base verifies against the | |
| # right merge-base; on push events base_ref is empty and the tool | |
| # defaults to origin/main. | |
| run: node tools/audit/manifest-gate-ci.mjs ${{ github.base_ref && format('--base origin/{0}', github.base_ref) || '' }} | |
| # ───────────────────────────────────────────────────────────────────── | |
| # grep-gates | |
| # ───────────────────────────────────────────────────────────────────── | |
| # Fast pre-test structural checks that enforce architectural invariants | |
| # by scanning src — no full install, no build: | |
| # | |
| # 1. `withSession({...})` calls in production code must include | |
| # `operationId` or `allowNonIdempotent: true` (#1342 P1.D). | |
| # 2. The literal `BEGIN IMMEDIATE` may only appear inside the SQLite | |
| # substrate (`storage/`, `event-store/`); leakage into application | |
| # code is a layering violation (#1342 P1.E). | |
| # 3. Event-sourcing read-path gates (v2.11.0 Wave 3): the upcast | |
| # choke-point (#1556), no raw `.state.json` IO (#1504), a single | |
| # canonical workflow-state fold (#1554), and event-store access only | |
| # via the composition root. Zero-dependency node scripts. | |
| # | |
| # The #1342 gates are pure bash + grep; the Wave-3 gates are pure node | |
| # (built-ins only). Blocking gate via the `ci-gate` aggregator below. | |
| # ───────────────────────────────────────────────────────────────────── | |
| grep-gates: | |
| name: Grep Gates (idempotency + substrate) | |
| if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: withSession idempotency contract (#1342 P1.D) | |
| run: bash tools/audit/gates/check-withsession-idempotency.sh src | |
| # Self-test for the gate above. It exercises the gate's own matching logic | |
| # against seeded fixtures in a tmpdir — it says nothing about production | |
| # code. Paths resolve from the script's own location, not the cwd. | |
| - name: withSession idempotency gate self-test (#1342 P1.D) | |
| run: bash tests/scripts/check-withsession-idempotency.test.sh | |
| - name: BEGIN IMMEDIATE substrate-only (#1342 P1.E) | |
| run: bash tools/audit/gates/check-begin-immediate-substrate.sh src | |
| # Self-test for the gate above. Seeded-fixture cases plus RealTree_* cases | |
| # that scan this checkout to catch false positives on the legitimate | |
| # in-substrate `.immediate()` call site. | |
| - name: BEGIN IMMEDIATE substrate gate self-test (#1342 P1.E) | |
| run: bash tests/scripts/check-begin-immediate-substrate.test.sh | |
| # Event-sourcing read-path structural gates (v2.11.0 Wave 3). Each is a | |
| # zero-dependency node script that scans src for an | |
| # architectural violation and exits non-zero on a regression. These | |
| # previously ran only via `npm run validate`, which no workflow invoked — | |
| # so a real regression in the event-store read path would have passed CI. | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| - name: Upcast choke-point — migrateEvents at the read seam (#1556) | |
| run: node tools/audit/gates/check-query-upcast-choke-point.mjs | |
| - name: No raw .state.json read/write in production (#1504) | |
| run: node tools/audit/gates/check-no-state-json.mjs | |
| - name: Single workflow-state fold — one canonical reducer (#1554) | |
| run: node tools/audit/gates/check-single-workflow-fold.mjs | |
| - name: Event-store access via composition root (#1342) | |
| run: node tools/audit/gates/check-event-store-composition-root.mjs | |
| - name: Windows-portability anti-patterns (#1623) | |
| run: node tools/audit/gates/check-windows-portability.mjs | |
| - name: Windows-portability gate self-test (#1623) | |
| run: bash tests/scripts/check-windows-portability.test.sh | |
| - name: WLM wiring gate — DR-1 retry adapter + DR-2 skills reroute (task-004) | |
| run: node tools/audit/gates/check-wlm-wiring.mjs | |
| - name: WLM wiring gate self-test (task-004) | |
| run: bash tests/scripts/check-wlm-wiring.test.sh | |
| # v2.12 proof-substrate ownership census (DR-5). Exact typed dispositions | |
| # reject new direct gate emitters, provider registrations, and custom-shell | |
| # transition guards while preserving the audited v3.0 migration reserve. | |
| - name: Gate-runner ownership census (v2.12 DR-5) | |
| run: node tools/audit/gates/check-gate-runner-ownership.mjs | |
| - name: Gate-runner ownership census self-test (v2.12 DR-5) | |
| run: bash tests/scripts/check-gate-runner-ownership.test.sh | |
| # ── Wave-S zero-dep register + node-only re-asserts (DR-9, DR-10) ────── | |
| # Zero-dependency node (uses the node set up above; no install) on the | |
| # UNFILTERED host so they fire on every PR. See | |
| # docs/guides/ci-gate-hosting.md. | |
| # DR-9 type-debt register: per-file `as unknown as` count budget compared | |
| # against tools/audit/gates/type-debt-baseline.json. BLOCKING from merge day one | |
| # (an unwired register is the "exists, not wired" class this spec closes). | |
| # Scans the whole src tree, not a PR diff. DR-1 host class: zero-dep | |
| # prefix, unfiltered. | |
| - name: Type-debt count-budget register (DR-9) | |
| run: node tools/audit/gates/check-type-debt.mjs | |
| # DR-10 .test.sh re-asserts (task-015 pattern — every check script's | |
| # self-test runs on every PR, unfiltered). These two are zero-dep node; | |
| # the bun-dependent check-mutation-gate.test.sh re-assert lives in the tsx | |
| # tail below (after the MCP install + a bun setup). | |
| - name: check-type-debt self-test re-assert (DR-10) | |
| run: bash tests/scripts/check-type-debt.test.sh | |
| - name: check-coverage-ratchet self-test re-assert (DR-10) | |
| run: bash tests/scripts/check-coverage-ratchet.test.sh | |
| # Enforcer-wiring gate (task 011, DR-5/DR-8). Zero-dependency node | |
| # script: reconciles every scripts/check-*|lint-* primary against | |
| # tools/audit/gates/enforcer-wiring-manifest.json by TRANSITIVELY walking npm-script | |
| # chains + workflow run-steps and inspecting per-term exit-code handling. | |
| # Catches the four trap classes a name-grep cannot see — orphan, | |
| # unreachable-via-uninvoked-npm-script, exit-code-swallowed (`|| true` / | |
| # continue-on-error), and diff-dependent-gate-without-`synchronize`. Lives | |
| # on this unfiltered host so it fires on every PR (DR-8: a gate in a | |
| # path-filtered job is skipped-as-passed). Self-tests: the four | |
| # `EnforcerWiring_*` cases in tests/scripts/check-enforcer-wiring.test.ts, run by | |
| # the root unit suite (test-root). | |
| - name: Enforcer wiring gate (DR-5/DR-8, task 011) | |
| run: node tools/audit/gates/check-enforcer-wiring.mjs | |
| # Cross-gate fail-closed self-test (task 015, DR-8). The gate's own | |
| # `.test.ts` runs only in the path-filtered `test-root` job (excludes | |
| # `scripts/**`), so a scripts-only PR skips it. This `.test.sh` re-asserts | |
| # the tool-missing + unparseable-output fail-closed paths on THIS | |
| # unfiltered host, where they fire on every PR. Pure node, no install. | |
| - name: Enforcer-wiring gate self-test (DR-8, task 015) | |
| run: bash tests/scripts/check-enforcer-wiring.test.sh | |
| # Plugin-packaging gate (task 064, DR-24). Previously reachable ONLY as | |
| # step 1 of the `npm run validate` && chain, which no workflow invokes | |
| # (class-2 unreachable) — and it was itself RED, so the 8 gates after it | |
| # never executed locally either. Measured 2026-08-07: 1 of 9 declared | |
| # steps ran. Its expectations now live in | |
| # `.claude-plugin/packaging-policy.json`, read at runtime, and the gate is | |
| # zero-dependency node (the old bash+jq implementation is a thin wrapper), | |
| # so it rides this unfiltered zero-dep prefix and fires on every PR. | |
| - name: Plugin-packaging gate (DR-24, task 064) | |
| run: bash tools/audit/gates/validate-plugin.sh | |
| # DR-10 re-assert for the gate above. Its `.test.ts` runs only in the | |
| # path-filtered `test-root` job (which excludes `scripts/**`), so a | |
| # scripts-only PR would skip the case that proves the gate can still fail. | |
| # This `.test.sh` asserts the fail-closed + non-empty-denominator paths on | |
| # THIS unfiltered host. See docs/guides/ci-gate-hosting.md. | |
| - name: Plugin-packaging gate self-test (DR-10, task 064) | |
| run: bash tests/scripts/validate-plugin.test.sh | |
| # The aggregating `npm run validate` runner (task 064, DR-24). Runs every | |
| # step declared in tools/audit/gates/validate-manifest.json instead of | |
| # short-circuiting, so a red early gate can no longer render the later | |
| # ones as skipped-as-passed. `--list` is the cheap reachability proof for | |
| # THIS host: it fails when the manifest declares zero steps, which is the | |
| # non-empty-denominator tooth. The steps themselves are each wired | |
| # individually elsewhere in this job (and in the tsx tail), so running the | |
| # whole chain here would only duplicate them. | |
| - name: Validate manifest declares a non-empty step list (DR-24, task 064) | |
| run: node tools/audit/gates/run-validate.mjs --list | |
| - name: npm-ci-retry helper tests | |
| run: bash tests/scripts/npm-ci-retry.test.sh | |
| # TEMP diagnostic (RCA 2026-05-30-state-source-integrity): the MCP `npm ci` | |
| # wedges fetching native artifacts. Probe each host with hard timeouts to | |
| # pinpoint which egress hangs from the runner. Non-blocking. | |
| - name: CDN connectivity probe | |
| run: | | |
| set +e | |
| for url in \ | |
| https://registry.npmjs.org/ \ | |
| https://objects.githubusercontent.com/ \ | |
| https://github.com/WiseLibs/better-sqlite3/releases/ \ | |
| https://nodejs.org/download/release/v24.12.0/SHASUMS256.txt ; do | |
| echo "--- $url ---" | |
| curl -sS -o /dev/null -w " http=%{http_code} connect=%{time_connect}s total=%{time_total}s\n" \ | |
| --connect-timeout 15 --max-time 60 "$url" || echo " FAILED/timeout: $url" | |
| done | |
| echo "probe complete" | |
| # ───────────────────────────────────────────────────────────────── | |
| # NOTE: the design's "grep-gates are root-only / zero-dependency" framing | |
| # is inaccurate for this job as a whole — do not rely on it. The tsx-backed | |
| # gates below shell `tsx` over the product TypeScript source, so this | |
| # job installs the package's dependencies (step below). | |
| # | |
| # Rehydration prefix/prose gates (task 011, DR-8). These two gates | |
| # previously ran ONLY via `npm run validate`, which no workflow invokes — | |
| # so a real DR-12 fingerprint drift or DR-13 prose regression passed CI | |
| # (the enforcer-wiring gate above flags this exact class-2 "unreachable" | |
| # trap). They belong on this UNFILTERED host so they fire on every PR | |
| # (DR-8). Unlike the grep-only gates above, each shells out to `tsx` on | |
| # the MCP TypeScript source (fingerprint-cli.ts / prose-lint-cli.ts), so | |
| # this job installs the root devDep (tsx) + the MCP runtime deps (pino, | |
| # reached transitively by fingerprint-cli.ts) before running them. Kept at | |
| # the end of the job so the fast install-free structural gates run first. | |
| - name: Install root dependencies (for tsx-backed gates) | |
| run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh" | |
| # Duplicate-location ratchet (DR-1, Task 022). An (area, basename) | |
| # intersection guard that FAILS on any legacy __tests__/<area>/<base>.test.ts | |
| # twin of a co-located subject not in the (empty) allowlist. Shares Task | |
| # 001's `enumeratePairs` — so it imports consolidate-suite.mjs, which pulls | |
| # in `typescript` (a root devDependency); it therefore rides the root | |
| # install above rather than the zero-dep prefix. No extra install step. | |
| # The allowlist shrinks to 0 once all 17 pairs relocate, so this gate is | |
| # green only in the consolidated end-state (it lands with/after the pairs). | |
| - name: Duplicate-location ratchet (DR-1, Task 022) | |
| run: node tools/audit/check-no-duplicate-suites.mjs | |
| # ── Wave-S tsx-tail + bun gates (DR-8, DR-2, DR-10) ──────────────────── | |
| # These ride the UNFILTERED grep-gates host so they fire on every PR, | |
| # using the BOTH dep trees installed by the two steps above. See | |
| # docs/guides/ci-gate-hosting.md. | |
| # DR-8 vocabulary-lint: scans content/, commands/, docs/architecture/, | |
| # docs/guides/ (broader than any path filter) via a tsx entrypoint that | |
| # needs both dep trees — exactly what this tail already installs, so the | |
| # marginal cost is one tsx invocation. DR-1 host class: deps tail, | |
| # unfiltered. | |
| - name: Vocabulary-lint (DR-8) | |
| run: npm run lint:invariants | |
| # Error-envelope lint (#1706 DR-2, task 002): scans the registration-set | |
| # surface (src/orchestrate/**) via a DEDICATED | |
| # type-aware flat config (eslint.envelopes.config.js — never the shared | |
| # eslint.config.js the filtered test-root lint:windows step loads), | |
| # hosted as a scripts/lint-*.mjs PRIMARY (not a bare eslint invocation) | |
| # so check-enforcer-wiring.mjs's manifest walker can see and reconcile | |
| # it. Rides this UNFILTERED deps-tail — not the filtered test-root — so | |
| # a registered MCP handler that lets a throw abnormally complete it | |
| # fails CI on every PR; needs the root eslint/typescript-eslint | |
| # devDependencies already installed above. DR-1 host class: deps tail, | |
| # unfiltered. | |
| - name: Error-envelope lint (#1706 DR-2) | |
| run: npm run lint:envelopes | |
| # DR-10 .test.sh re-assert (task-015 pattern — every check/lint script's | |
| # self-test runs on every PR, unfiltered): the wrapper's own | |
| # fixture-driven both-direction + fail-closed assertions, so a | |
| # scripts-only PR that skips the filtered test-root suite still proves | |
| # the gate is provably failable. | |
| - name: Error-envelope lint self-test (#1706 DR-2) | |
| run: bash tests/scripts/lint-envelopes.test.sh | |
| # The RULE's own self-test, as distinct from the wrapper's. `lint-envelopes.test.sh` | |
| # above covers the wrapper's exit codes against one violating fixture; these 18 | |
| # cases cover the rule's handler resolution (adaptXxx unwrap, zero-arg factory, | |
| # cast, inline literal, the fail-loud `unresolvedHandler` path) and its three | |
| # exemption classes. They were executed by NOTHING: it is a `node:test` file with | |
| # a `.js` extension, and the root vitest `include` list is all `*.test.ts` under | |
| # src/benchmarks/scripts/hooks/test/docs — no `tools/eslint-rules/**`, no `.js` pattern. | |
| # A blocking lint rule whose resolution logic is unproven is the DR-24 shape the | |
| # rest of this lane exists to prevent. | |
| - name: Error-envelope RULE self-test (18 cases) | |
| run: node tools/eslint-rules/no-handler-throw.test.js | |
| # `tools/eslint-rules/tsconfig.json` exists solely to typecheck `__fixtures__/**/*.ts` | |
| # and was compiled by nothing either — root `tsconfig.json` includes `src/**/*` | |
| # and `tsconfig.scripts.json` includes `scripts/**/*.ts`; neither covers this tree. | |
| - name: Typecheck tools/eslint-rules/ | |
| run: npx --no-install tsc -p tools/eslint-rules/tsconfig.json | |
| # DR-15/DR-24 ratchets re-asserted UNFILTERED. Both live in root `src/` and were | |
| # hosted only by the `root`-path-filtered test-root lane, while the surfaces they | |
| # SCAN sit outside that filter: `advisory-registry` walks `scripts/**` plus | |
| # `.github/workflows/**`, and `shim-registry` walks | |
| # `src/runtime/**` (the filter names only | |
| # `src/runtime/agents/**` from that tree). So a PR editing exactly | |
| # what they police did not arm the job that runs them — skipped-as-passed, which | |
| # is the failure mode these ratchets were written to close. Hosting them here | |
| # makes each fire on every PR regardless of the paths touched. | |
| - name: Advisory + shim ratchets (DR-15/DR-24, unfiltered) | |
| run: npx --no-install vitest run src/install/advisory-registry.test.ts src/install/shim-registry.test.ts | |
| # DR-2 CI-topology conformance test on the UNFILTERED host: a root-suite | |
| # vitest that parses ci.yml and asserts the ci-gate aggregator's | |
| # completeness / evaluate-coverage / skip-guard coverage. Running it here | |
| # (not only via test-root's filtered suite) means neither the topology nor | |
| # the detector can change without the detector running on that very PR. | |
| # Uses the root vitest installed above. DR-1 host class: deps tail, | |
| # unfiltered. | |
| - name: CI-topology conformance test (DR-2) | |
| run: npx --no-install vitest run tests/scripts/ci-topology.test.ts | |
| # ── `scripts/` typechecks (task 066, DR-24) ──────────────────────────── | |
| # Both `scripts/` trees were compiled by NOTHING: `tsconfig.json` and | |
| # `tsconfig.json` both `include: ["src/**/*"]`, so | |
| # the guards living there — cli-derivation-guard.ts, authority-live-proof.ts, | |
| # cli-vocab-guard.ts, guard-inventory.ts, tools/audit/* — were never | |
| # typechecked. Three separate tasks (020, 021, 026) each discovered this | |
| # independently and each hand-ran a standalone `tsc --noEmit` over their own | |
| # files; widening the tree at once surfaced 12 real errors (9 of them in | |
| # tools/audit/), all fixed by narrowing/widening rather than by a cast. | |
| # | |
| # Hosted HERE and not in test-root/test-mcp, deliberately. The scan surface | |
| # of the root scripts project is `scripts/**` PLUS the MCP `src/` modules its | |
| # guards import, and `changes.root` covers neither (this file already notes | |
| # at the enforcer-wiring self-test above that the root filter excludes | |
| # `scripts/**`, so a scripts-only PR skips test-root entirely). An unfiltered | |
| # host makes the two-surface subset rule hold trivially. DR-1 host class: | |
| # deps tail, unfiltered — both dep trees are installed above. See | |
| # docs/guides/ci-gate-hosting.md. | |
| - name: Typecheck the root `scripts/` tree (task 066, DR-24) | |
| run: npx tsc -p tsconfig.scripts.json | |
| # The guard's own self-test on the same host (DR-24: "each guard's | |
| # self-test runs in the same CI job as the guard"). `tsc -p` already fails | |
| # on a config resolving ZERO files (TS18003), but it exits 0 over a config | |
| # that quietly stopped covering the guards it exists to cover — so this | |
| # asserts the resolved file set against a stated coverage floor, and | |
| # carries the zero-file kill fixture. | |
| - name: scripts/ typecheck coverage self-test (task 066, DR-24) | |
| run: npx --no-install vitest run tests/scripts/tsconfig-scripts-coverage.test.ts | |
| # DR-14 cast-census scope (task 022's finding, folded into task 066): the | |
| # census roots must cover every tree the tsconfig projects compile, so a | |
| # directory cannot be typechecked by one gate and un-censused by the other. | |
| # Runs unfiltered for the same reason as the typechecks above — the roots | |
| # span both packages, and `changes.root` covers neither `scripts/` tree. | |
| - name: Cast-census scope + budget ratchet (DR-14, task 066) | |
| run: npx --no-install vitest run src/install/tsconfig-strictness.test.ts | |
| # DR-10 .test.sh re-assert for check-mutation-gate (task-015 pattern, | |
| # unfiltered — every check script's self-test runs on every PR). Its | |
| # invocation seam drives the real mutation-adequacy handler through a | |
| # bun-run bridge (EventStore needs bun:sqlite), so this re-assert needs | |
| # BOTH bun AND the MCP node_modules installed above. grep-gates does not | |
| # otherwise set up bun, so this step adds it (mirrors test-mcp's | |
| # oven-sh/setup-bun@v2 idiom). The other two re-asserts (type-debt, | |
| # coverage-ratchet) are zero-dep node and live in the prefix above. | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.3.13 | |
| - name: check-mutation-gate self-test re-assert (DR-10) | |
| run: bash tests/scripts/check-mutation-gate.test.sh | |
| - name: Prefix-fingerprint gate — rehydration stable-prefix drift (DR-12) | |
| run: node tools/audit/gates/check-prefix-fingerprint.mjs | |
| - name: Prose-lint gate — rehydration prose slop (DR-13) | |
| run: node tools/audit/gates/check-prose-lint.mjs | |
| # Measured-premise drift gate (task 054, DR-27). Binds every annotated | |
| # numeric claim in the internal-mechanics-overhaul spec (+ the invariants | |
| # catalog) to the artifact that produces it, so the document cannot assert | |
| # a number nothing derives. The `outputSchema` census and `EventTypes` | |
| # derivations shell out to `tsx` over the MCP source, so this rides the | |
| # deps tail rather than the zero-dep prefix. Unfiltered on purpose: the | |
| # premises drift when the CODE changes, which is precisely the PR a | |
| # docs-path filter would skip. | |
| - name: Measured-premise drift gate (DR-27) | |
| run: node tools/audit/gates/check-measured-premises.mjs | |
| # Its own self-test on the same host (DR-24: "each guard's self-test runs | |
| # in the same CI job as the guard", so guard-execution failure cannot pass | |
| # as success). Includes the rev-3 kill fixture — a document already proven | |
| # wrong, which the checker must still report as drifted. | |
| - name: Measured-premise gate self-test (DR-27) | |
| run: npx --no-install vitest run tests/scripts/check-measured-premises.test.ts | |
| # No-circular runtime import-cycle ratchet (task 010, DR-4/DR-8). Runs the | |
| # ROOT-hoisted dependency-cruiser over src, computes | |
| # the runtime cycles (Tarjan, via architecture/import-cycles.ts), and diffs | |
| # them against tools/audit/cycle-baseline.json — failing CLOSED on an | |
| # unbaselined cycle, an expired waiver, a PHANTOM entry (baselined edge with | |
| # no live cycle — the no-mask tooth), or missing/unparseable depcruise output | |
| # (exit 2). Blocking, NOT advisory: the shared config ships `no-circular` at | |
| # `warn` so the dogfooded runBoundaryLint stays green; the ratchet lives here. | |
| # Uses the root tsx + depcruise installed by the two steps above. Self-tests: | |
| # the four named DR-4 failure-mode cases in tests/scripts/audit/cycle-gate.test.ts, | |
| # run by the root unit suite (test-root). | |
| - name: No-circular cycle ratchet (DR-4/DR-8, task 010) | |
| run: npx --no-install tsx tools/audit/cycle-gate.ts | |
| # Cross-gate fail-closed self-tests for the two tsx-backed audit gates | |
| # (task 015, DR-8). Their own `.test.ts` cases run only in the | |
| # path-filtered `test-root` job (excludes `scripts/**`), so a scripts-only | |
| # PR skips them. These `.test.sh` re-assert the tool-missing + | |
| # unparseable-output fail-closed paths here, driving the REAL CLI via the | |
| # EXARCHOS_{DEPCRUISE,KNIP}_BIN seam (a missing path / a garbage-emitting | |
| # stub) — so depcruise/knip need not even be resolvable. Uses the root tsx | |
| # installed by the two steps above. | |
| - name: Cycle-gate ratchet self-test (DR-8, task 015) | |
| run: bash tests/scripts/audit/cycle-gate.test.sh | |
| - name: Knip-diff gate self-test (DR-8, task 015) | |
| run: bash tests/scripts/audit/knip-diff.test.sh | |
| # Module-intent gate (task 013, DR-7/DR-8). Delegates reachability to the | |
| # vendored tools/audit/refgraph.mjs detector, then requires every | |
| # dead-in-prod module (0 production importers) to declare intent: a | |
| # RESERVED(issue, owner, expires) header with a future expiry, OR | |
| # membership in a declared allowlist class (test-infra / build-shim / | |
| # type-test entrypoint). Expired-and-unadopted RESERVED → FAIL (the DR-7 | |
| # "deletion happens at expiry" enforcement point). Fails CLOSED (exit 2) on | |
| # a scan crash / unparseable detector output / unreadable module (DR-8) — | |
| # never passes on partial evidence. Zero-dependency node; refgraph.mjs is | |
| # itself pure node:fs. Self-tests: the DR-7/DR-8 cases in | |
| # tests/scripts/check-module-intent.test.ts, run by the root unit suite (test-root). | |
| - name: Module-intent gate (DR-7/DR-8, task 013) | |
| run: node tools/audit/gates/check-module-intent.mjs | |
| # Cross-gate fail-closed self-test (task 015, DR-8). The gate's own | |
| # `.test.ts` runs only in the path-filtered `test-root` job (excludes | |
| # `scripts/**`), so a scripts-only PR skips it. This `.test.sh` re-asserts | |
| # the tool-missing + unparseable-output fail-closed paths here (via the | |
| # `--refgraph` seam: a missing detector / a garbage-emitting stub). Pure | |
| # node, no install. | |
| - name: Module-intent gate self-test (DR-8, task 015) | |
| run: bash tests/scripts/check-module-intent.test.sh | |
| # Wave-1 guard inventory + CI-reachability proof (DR-24, task 063). Parses | |
| # this workflow, both package.json script tables, both vitest configs and | |
| # the enforcer-wiring manifest, then proves every guard Wave 1 shipped is | |
| # executed by some CI job — or carries a recorded, EXPIRING reason why not. | |
| # Also reports path-filtered hosting rather than accepting it (#1711's | |
| # skipped-as-passed failure), and fails closed on a zero-guard resolution. | |
| # This step IS the guard: the module is a library, its assertions live in | |
| # the test, so hosting the test here satisfies DR-24's "each guard's | |
| # self-test runs in the same CI job as the guard" for free. Must be | |
| # UNFILTERED — `scripts/**` is outside the `root` path filter, so the | |
| # filtered `test-root` job skips-as-passed on exactly the PR that edits this | |
| # inventory. Rides the deps tail (needs vitest + typescript + js-yaml from | |
| # the root install above), not the zero-dep prefix. | |
| # See docs/guides/ci-gate-hosting.md. | |
| - name: Wave-1 guard-inventory reachability proof (DR-24, task 063) | |
| run: npx --no-install vitest run tests/scripts/guard-inventory.test.ts | |
| # Cast-census self-test re-assert (DR-24, task 063 finding). The DR-14 cast | |
| # budget is the constraint every task in this wave is judged against, and | |
| # its census (`tools/audit/tsconfig-strictness/count-casts.ts`) was hosted ONLY | |
| # by the `root`-filtered `test-root` job — while its own source sits under | |
| # `scripts/**`, which the `root` filter excludes. A PR that weakened the | |
| # census therefore never armed the only job that runs it: the two-surface | |
| # subset rule violated, found by the inventory above. This re-assert closes | |
| # the implementation-surface half on the unfiltered host. It is the | |
| # fixture-driven SELF-TEST only (tmpdir + string inputs); the repo-wide | |
| # ratchet stays in `src/install/tsconfig-strictness.test.ts` on `test-root`, so this | |
| # step adds no new repo-wide assertion. Same DR-10 pattern as the | |
| # `.test.sh` re-asserts above. | |
| - name: Cast-census self-test re-assert (DR-24, unfiltered) | |
| run: npx --no-install vitest run tests/scripts/tsconfig-strictness/count-casts.test.ts | |
| # G2 `outputSchema` vacuity ratchet (DR-4, task 017). Runs all four teeth | |
| # against the live triple: membership (allowlist vs census, both | |
| # directions), seed key-set integrity (vs the frozen digest), and — new in | |
| # task 017 — the per-entry EXPIRY, enforced rather than advisory. Tasks 055 | |
| # and 060 shipped the first three but wired NONE of them to an executable | |
| # gate: the whole `auditVacuity*` family was driven only by its co-located | |
| # vitest in the `mcp`-PATH-FILTERED `test-mcp` job, which | |
| # tools/audit/gates/guard-inventory.ts names as an R-11 instance and #1711 names as | |
| # skipped-as-passed on exactly the PRs it polices. This step is the gate. | |
| # | |
| # This is ALSO the one place DR-4's mechanism reads the wall clock: the | |
| # library takes `today` as a required argument so its unit tests are | |
| # deterministic, and the deadline reddens the MERGE rather than a | |
| # developer's local `vitest run`. Expect it to go red of its own accord | |
| # after VACUITY_EXPIRY_HORIZON (2027-02-28) — that is the deadline working, | |
| # and the repair is paying declarations down, never re-dating the horizon. | |
| # | |
| # Rides the deps tail (needs `tsx` + the MCP node_modules to resolve the | |
| # registry's Zod objects — a named binding launders a source-text grep, so | |
| # the schema OBJECT must be walked), not the zero-dep prefix. Must be | |
| # UNFILTERED: the allowlist, the pin and this guard all live under | |
| # the `mcp` filter, but the ratchet governs a repo-wide contract | |
| # and a `mcp`-filtered host skips-as-passed on any PR outside it. | |
| # See docs/guides/ci-gate-hosting.md. | |
| - name: outputSchema vacuity ratchet — G2 + enforced expiry (DR-4, task 017) | |
| run: npx --no-install tsx tools/audit/core/output-schema-ratchet-guard.ts | |
| # Its self-tests on the SAME host (DR-24: "each guard's self-test runs in | |
| # the same CI job as the guard", so guard-execution failure cannot pass as | |
| # success). Includes task 017's kill fixtures — the live 112-entry seed | |
| # carried one day past its horizon, and the blanket "bump every date in one | |
| # commit" renewal — both of which must be RED. Hosting the census and | |
| # allowlist suites here too closes their implementation-surface half: their | |
| # only other host is the `mcp`-filtered `test-mcp`. | |
| # | |
| # `output-schema-census.selftest.test.ts` (task 018) is the fourth file and | |
| # covers what the other three structurally cannot: they all call | |
| # `runGuard()` in-process and read its RETURN VALUE, so DR-4's entrypoint | |
| # tail — the `isDirectRun` predicate and the `process.exit` that consumes | |
| # it — was executed by nothing. It spawns the step ABOVE as a real process. | |
| # It must be hosted HERE and not only on the `mcp`-filtered `test-mcp`: a | |
| # self-test for an unfiltered gate that is itself path-filtered is #1711's | |
| # skipped-as-passed on exactly the PR that breaks the gate. | |
| # | |
| # SPLIT ACROSS TWO WORKSPACES by task 018a. The census and its selftest | |
| # moved to `tools/conformance` (root vitest, `conformance` project); the | |
| # ratchet guard's own test and the allowlist test stayed with the subject. | |
| # Both halves must remain hosted here — dropping either recreates the | |
| # skipped-as-passed hole this step was written to close. | |
| - name: outputSchema ratchet self-tests re-assert (DR-4/DR-24, unfiltered) | |
| run: >- | |
| npx --no-install vitest run | |
| tests/core/scripts/output-schema-ratchet-guard.test.ts | |
| src/output-schema-vacuity-allowlist.test.ts | |
| # THE WHOLE extracted conformance suite, unfiltered — G2's census and | |
| # selftest, G3, G5, and every other census task 018a moved out. | |
| # | |
| # One step for the package rather than one per guard, and the reason is | |
| # the two-surface subset rule rather than brevity. These censuses now live | |
| # under `tools/conformance/**`, which no `dorny/paths-filter` key matches, | |
| # so their only other host (`test-root`, filtered on `root`) is armed by | |
| # NO PR that edits them: #1711's skipped-as-passed, pointed at the | |
| # enforcement suite itself. An unfiltered host clears it for the whole | |
| # package at once, and cannot go stale as the package grows. | |
| # | |
| # The alternative was widening `root` to cover `tools/conformance/**`. | |
| # Rejected for the reason already recorded in GUARD_EXEMPTIONS: that key | |
| # also arms `test-windows-root`, the lane #1699 has never proven green. | |
| - name: Conformance suite — every extracted census (DR-2/DR-4/DR-6/DR-24, unfiltered) | |
| run: npm run test:conformance | |
| # G1 CLI-derivation allowlist ratchet (DR-5, task 023). Parses the CLI | |
| # composition root, derives the hand-written-literal population from that | |
| # parse, and holds it to a shrink-only allowlist: membership in BOTH | |
| # directions (an untracked literal fails; a tracked name that is no longer a | |
| # literal goes stale), a frozen key-set pin over `allowed ∪ retired` that | |
| # catches an in-place swap no count and no comparison-against-today can see, | |
| # and a per-entry ISO expiry that is ENFORCED and capped by one pinned | |
| # horizon so a waiver cannot renew itself. | |
| # | |
| # This is ALSO the one place DR-5's mechanism reads the wall clock: the | |
| # library takes `today` as a required argument so its unit tests are | |
| # deterministic, and the deadline reddens the MERGE rather than a | |
| # developer's local `vitest run`. Expect it to go red of its own accord | |
| # after CLI_DERIVATION_EXPIRY_HORIZON (2027-02-28) — that is the deadline | |
| # working, and the repair is registering verbs through a derivation helper, | |
| # never re-dating the horizon. | |
| # | |
| # This is the RATCHET entrypoint; its DERIVATION sibling is wired directly | |
| # below. The ratchet states the SHRINK-ONLY policy over the tolerated set; | |
| # the sibling states the DERIVATION policy ("no un-allowlisted literal | |
| # `.command(` at all"). Both are blocking as of task 076. | |
| # | |
| # Rides the deps tail (needs `tsx` + `typescript`; it parses the source and | |
| # never resolves `buildCli`, so unlike the sibling `cli-vocab-guard` it | |
| # needs neither Bun nor `bun:sqlite`), not the zero-dep prefix. Must be | |
| # UNFILTERED: the policy file, the pin and this guard all live under | |
| # the `mcp` filter, but a `mcp`-filtered host skips-as-passed on | |
| # any PR outside it. See docs/guides/ci-gate-hosting.md. | |
| - name: CLI derivation allowlist ratchet — G1 shrink-only + enforced expiry (DR-5, task 023) | |
| run: npx --no-install tsx tools/audit/core/cli-derivation-ratchet-guard.ts | |
| # G1 DERIVATION entrypoint — direct and BLOCKING as of task 076 (DR-5). | |
| # | |
| # This gate spent Wave 1 in GUARD_EXEMPTIONS as `unreachable`: it exited 1 | |
| # by design, because `merge-orchestrate` was declared twice (registry action | |
| # + a hand-written command in the composition root) and — being the kill | |
| # fixture — could not be allowlisted around. Task 023 paid the other ten | |
| # literals down to tracked debt; task 076 deleted the eleventh by moving the | |
| # promotion onto the registry's `cli.topLevel` hint, which is what let this | |
| # entrypoint go green on a clean tree. The exemption entry is REMOVED, not | |
| # re-dated, so `guard-inventory` now reports it `enforcement: blocks`, | |
| # `pathFilteredOnly: false`, `via=direct`. | |
| # | |
| # Invoked DIRECTLY (no `npm run` wrapper) so the inventory's indirection | |
| # resolver records `via=direct` rather than walking a shell shim. Same host | |
| # class as the ratchet above: deps tail, unfiltered, for the same reason. | |
| - name: CLI derivation guard — G1 no un-allowlisted literal commands (DR-5, task 076) | |
| run: npx --no-install tsx tools/audit/core/cli-derivation-guard.ts | |
| # Its self-tests, plus the DERIVATION guard's, on the SAME host (DR-24: | |
| # "each guard's self-test runs in the same CI job as the guard"). These | |
| # carry task 023's kill probes — a seeded eleventh entry, an in-place swap, | |
| # a paydown recorded as a deletion, the live seed carried one day past its | |
| # horizon, the blanket "bump every date" renewal and an emptied allowlist — | |
| # and task 021/022's kill fixture — RE-SEEDED by task 076, which deleted | |
| # the live subject by fixing the defect: the proof now splices the deleted | |
| # command back into the real composition root and re-runs the same guard, | |
| # while a separate assertion pins that the LIVE tree no longer carries it. | |
| # Both `merge-orchestrate` policy-map exclusions still assert. Hosting them | |
| # here closes their | |
| # implementation-surface half: their only other host is the `mcp`-filtered | |
| # `test-mcp`, and `changes.mcp` does not arm on a workflow-only PR. | |
| - name: CLI derivation guard + ratchet self-tests re-assert (DR-5/DR-24, unfiltered) | |
| run: >- | |
| npx --no-install vitest run | |
| tests/core/scripts/cli-derivation-ratchet-guard.test.ts | |
| tests/core/scripts/cli-derivation-guard.test.ts | |
| tests/core/scripts/cli-vocab-guard.test.ts | |
| # G3 report-coupled ratchet (DR-2, task 013). Enumerates the live event | |
| # registry, derives each registration's coupling through the DR-2 tier | |
| # union, and holds the report-coupled population to a shrink-only seed | |
| # (membership + ISO expiry + a frozen key-set pin against in-place swaps). | |
| # The census module is a pure library, so this test file IS the guard — and | |
| # it carries the kill fixtures (a seeded 26th report-coupled registration, | |
| # an emptied denominator, a seeded tier/source disagreement), which | |
| # satisfies DR-24's "each guard's self-test runs in the same CI job as the | |
| # guard" for free: guard-execution failure exits non-zero here rather than | |
| # passing as success. Must be UNFILTERED — the co-located test would | |
| # otherwise be hosted only by `test-mcp`, which is filtered on `mcp` and | |
| # skips-as-passed on exactly the PRs #1711 describes. Rides the deps tail, | |
| # not the zero-dep prefix: it resolves the MCP TypeScript source through | |
| # the MCP vitest installed by the two steps above. | |
| # See docs/guides/ci-gate-hosting.md. | |
| # G3's census moved to `tools/conformance` by task 018a and is now hosted | |
| # by the whole-suite step above, which is unfiltered for the same reason | |
| # this step was. | |
| # G4 effect-ledger bijection (DR-7) and G5 authority-topology census (DR-6), | |
| # hosted UNFILTERED — added by task 027 at the Wave-1 exit. | |
| # | |
| # Both guards were `pathFilteredOnly: true` until now: their only hosts were | |
| # `test-mcp` and `test-windows`, which are filtered on `mcp`. That is the | |
| # #1711 shape exactly — the gate is skipped-as-passed on the PRs it polices, | |
| # and `guard-inventory` reports the condition rather than accepting it. The | |
| # Wave-1 exit proof (`wave1-exit.test.ts`) asserts every declared Wave-1 | |
| # guard has at least one unfiltered host, and these two were the only | |
| # members failing it. | |
| # | |
| # This wires HOSTING, not enforcement severity. G5 remains observe-then- | |
| # enforce PER ROW via each authority-topology row's `enforceFrom` field | |
| # (§3a, reconciled in rev 3) — flipping it wholesale here would red-line CI | |
| # for four waves, which is the contradiction rev 3 was written to remove. | |
| # What changes is that the guard now RUNS on every PR instead of only on | |
| # `mcp`-touching ones. | |
| # | |
| # SPLIT ACROSS TWO WORKSPACES by task 018a. G5's census moved to | |
| # `tools/conformance`; G4's `effect-ledger.ts` did NOT — it has seven | |
| # production consumers, so extracting it would have inverted the | |
| # dependency direction between `src/` and `tools/`. | |
| # The SUBJECT still lives at `src/architecture/effect-ledger.ts`; task 030 | |
| # moved only its self-test into the `tests/unit/` mirror. | |
| - name: G4 effect-ledger self-test (DR-7/DR-24, unfiltered) | |
| run: npx --no-install vitest run tests/unit/architecture/effect-ledger.test.ts | |
| # G5's census moved with G3's; the whole-suite step above hosts it. | |
| # DR-3 event-name grammar census + two-way ratchet (task 015). Enumerates | |
| # the LIVE registry at runtime — `getValidEventTypes()`, so custom | |
| # `registerEventType` names are in the denominator, which is the population | |
| # no compile-time proof over `EventType` can see — decides every name | |
| # against task 014's grammar, and holds the recorded grammar concessions to | |
| # a two-way ratchet: a registered name the grammar rejects fails, and a | |
| # recorded concession no live name exercises fails as stale cover. The | |
| # census module is a pure library, so this test file IS the guard, and it | |
| # carries the kill fixtures (a REAL malformed custom registration that | |
| # `registerEventType` accepts today, an emptied denominator, an emptied | |
| # concession table, a repaired EVENT_NAME_PATTERN, a lapsed expiry) — which | |
| # satisfies DR-24's "each guard's self-test runs in the same CI job as the | |
| # guard" for free. | |
| # | |
| # This is also where DR-3's mechanism reads the wall clock: the library | |
| # takes `today` as a required argument so its unit cases are deterministic, | |
| # and the concession deadline reddens the MERGE. Expect it to go red of its | |
| # own accord after the seeded expiry (2027-02-28) — that is the deadline | |
| # working, and the repair is retiring the concession, never re-dating it. | |
| # | |
| # DR-3's event-name grammar census also moved to `tools/conformance` and | |
| # is hosted by the whole-suite step above — unfiltered, as it must be: the | |
| # census governs a repo-wide naming contract. | |
| # See docs/guides/ci-gate-hosting.md. | |
| # Audit-delivery closure (DR-4/DR-24, task 069). Holds every declared | |
| # delivery obligation to BOTH halves: the producing action's live | |
| # `outputSchema` must declare the delivered field and its enumerator as | |
| # required typed properties, and a declared reader document must carry the | |
| # whole instruction inside one section. It exists because | |
| # `check_invariant_conformance` computed `auditPrompt` and returned it | |
| # through a `vacuityWaiver` schema while NO skill, command, rule or doc told | |
| # anybody to read it — the field occurred in exactly five files repo-wide, | |
| # four of them its own tests. The module is a pure library, so this test file | |
| # IS the guard, and it carries the kill fixtures (the pre-069 vacuous | |
| # contract, a reader that only invokes the gate, scattered non-co-located | |
| # mentions, an emptied obligation list) — which satisfies DR-24's "each | |
| # guard's self-test runs in the same CI job as the guard" for free. | |
| # | |
| # Must be UNFILTERED: the obligation binds a file under | |
| # the `mcp` filter to one under `content/**`, so EITHER path | |
| # filter would skip-as-passed on exactly the PR that breaks the binding | |
| # from the other side. Rides the deps tail — it walks live Zod objects. | |
| # See docs/guides/ci-gate-hosting.md. | |
| - name: Audit-delivery closure (DR-4/DR-24, task 069) | |
| run: npx --no-install vitest run src/architecture/audit-delivery-closure.test.ts | |
| # ───────────────────────────────────────────────────────────────────── | |
| # e2e-process | |
| # ───────────────────────────────────────────────────────────────────── | |
| # Runs the `process` vitest project against a real compiled binary. | |
| # The project's setupFiles assert `exarchos` is on PATH, so we build | |
| # the host (linux-x64) binary and symlink it before invoking vitest. | |
| # | |
| # The job covers the full process-fidelity matrix landed in W1–W5 of | |
| # the v2.9.0 e2e harness: | |
| # - P1 fixtures (preflight, runCli, spawnMcpClient, normalizers) | |
| # - P2 saga primitives + #1208 regression | |
| # - P3 parity tests (workflow.describe, event.query, workflow.rehydrate | |
| # with F6.1 reconstructability) | |
| # - P4 CLI surface tests (version, doctor, install-skills, schema, | |
| # topology, emissions, mcp start/stop) | |
| # | |
| # Linux only — Windows process fidelity is v2.10 P5. | |
| # | |
| # DISPOSITION (DR-4, wave-S enforcement-substrate spec, corrects the stale | |
| # "Blocking gate as of T3.7" claim below, which was never true — this job | |
| # has never been in `ci-gate.needs`): NON-BLOCKING. Measured over the last | |
| # 60 completed `ci.yml` runs (2026-07-17) via `gh api .../actions/runs/<id>/jobs`: | |
| # executed 60/60 (no path filter), failed 2/60 — a 3.33% failure-rate-when- | |
| # executed, both failures in the "Run process-fidelity suite" step and | |
| # consistent with the known SQLITE_BUSY flake cluster. That is above the | |
| # spec's 2% blocking threshold, so this job stays non-blocking and is | |
| # recorded (with this measured rate) in the `tests/scripts/ci-topology.test.ts` | |
| # non-blocking allowlist. Follow-up to burn down the flake and flip this to | |
| # blocking: lvlup-sw/exarchos#1718. Host class: deps, unfiltered, | |
| # suite-shaped — see docs/guides/ci-gate-hosting.md. | |
| # ───────────────────────────────────────────────────────────────────── | |
| e2e-process: | |
| name: E2E Process (linux-x64) | |
| if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.3.13 | |
| # One closure since task 019: vitest and the build-binary toolchain sit | |
| # beside pino/commander/@modelcontextprotocol/zod/yaml in the same manifest. | |
| - name: Install dependencies | |
| run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh" | |
| - name: Build host binary (linux-x64) | |
| run: bun run tools/release/build-binary.ts --target linux-x64 | |
| - name: Symlink exarchos onto PATH | |
| run: sudo ln -s "$PWD/dist/bin/exarchos-linux-x64" /usr/local/bin/exarchos | |
| - name: Verify exarchos on PATH | |
| run: which exarchos && exarchos --version | |
| - name: Run process-fidelity suite | |
| run: npm run test:process | |
| # ───────────────────────────────────────────────────────────────────── | |
| # outcome-tests | |
| # ───────────────────────────────────────────────────────────────────── | |
| # Runs the `outcome` vitest project (Wave 1 data-safety substrate, | |
| # Phase B). The tier exercises operator-visible behaviour using real | |
| # OS state (tmpdir HOME, real git repos with sibling worktrees) via | |
| # helpers under `tests/outcome/_helpers/`. | |
| # | |
| # Linux-only by design: the helpers in T-010/T-011 use POSIX-style | |
| # path semantics and `git worktree` invocations that are not yet | |
| # validated on Windows. Cross-platform outcome coverage is deferred | |
| # to a later wave alongside the v2.10 Windows process-fidelity work. | |
| # | |
| # NOTE: marking this job as a required branch-protection check is a | |
| # manual repo-settings step (Settings → Branches → main → required | |
| # status checks). Capture that as a manual followup in the merge PR. | |
| # ───────────────────────────────────────────────────────────────────── | |
| outcome-tests: | |
| name: Outcome Tests (linux-x64) | |
| if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: 1.3.13 | |
| # One closure since task 019: vitest, the outcome project's `bun:sqlite` | |
| # alias target, and `better-sqlite3` (the shim's runtime dependency) all | |
| # come from the same manifest. | |
| - name: Install dependencies | |
| run: bash "$GITHUB_WORKSPACE/tools/release/npm-ci-retry.sh" | |
| # The install-skills outcome test invokes the platform binary at | |
| # `dist/bin/exarchos-<os>-<arch>`. Without this build step the test | |
| # silently degrades to "no on-disk effects → empty manifest → | |
| # assertion failure", which masks real regressions in the installer. | |
| - name: Build host binary (linux-x64) | |
| run: bun run tools/release/build-binary.ts --target linux-x64 | |
| - name: Configure git identity for outcome helpers | |
| run: | | |
| git config --global user.email "outcome-tests@example.com" | |
| git config --global user.name "outcome-tests" | |
| - name: Run outcome-tier suite | |
| run: npm run test:outcome | |
| ci-gate: | |
| name: CI Gate | |
| runs-on: ubuntu-latest | |
| needs: [changes, test-root, test-mcp, test-windows, test-windows-root, validate-no-legacy, manifest-gate, grep-gates, outcome-tests] | |
| if: (github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request') && always() | |
| steps: | |
| - name: Evaluate results | |
| run: | | |
| echo "changes=${{ needs.changes.result }}" | |
| echo "test-root=${{ needs.test-root.result }}" | |
| echo "test-mcp=${{ needs.test-mcp.result }}" | |
| echo "test-windows=${{ needs.test-windows.result }}" | |
| echo "test-windows-root=${{ needs.test-windows-root.result }}" | |
| echo "validate-no-legacy=${{ needs.validate-no-legacy.result }}" | |
| echo "manifest-gate=${{ needs.manifest-gate.result }}" | |
| echo "grep-gates=${{ needs.grep-gates.result }}" | |
| echo "outcome-tests=${{ needs.outcome-tests.result }}" | |
| if [[ "${{ needs.changes.result }}" =~ ^(failure|cancelled)$ ]]; then | |
| echo "::error::Change detection: ${{ needs.changes.result }}" | |
| exit 1 | |
| fi | |
| if [[ "${{ needs.test-root.result }}" =~ ^(failure|cancelled)$ ]]; then | |
| echo "::error::Root package tests: ${{ needs.test-root.result }}" | |
| exit 1 | |
| fi | |
| # DR-3 (wave-S skip-guard, mirrors the test-windows/test-windows-root | |
| # pattern below): test-root is REQUIRED for any root-touching PR. A | |
| # generic skip is OK (non-root PRs legitimately skip test-root via | |
| # `changes.outputs.root`, host class: deps, filtered — see | |
| # docs/guides/ci-gate-hosting.md). But if root code DID change and | |
| # the lane was skipped, that's a path-filter/matrix regression | |
| # silently dropping our only Linux root-package coverage | |
| # (skipped-as-passed hole) — fail closed. | |
| if [[ "${{ needs.changes.outputs.root }}" == 'true' \ | |
| && "${{ needs.test-root.result }}" == 'skipped' ]]; then | |
| echo "::error::Root package tests (test-root) skipped on a root-touching PR (path-filter/matrix regression dropped required root coverage)" | |
| exit 1 | |
| fi | |
| if [[ "${{ needs.test-mcp.result }}" =~ ^(failure|cancelled)$ ]]; then | |
| echo "::error::MCP server tests: ${{ needs.test-mcp.result }}" | |
| exit 1 | |
| fi | |
| # DR-3 (wave-S skip-guard, mirrors the test-windows/test-windows-root | |
| # pattern below): test-mcp is REQUIRED for any MCP-touching PR. A | |
| # generic skip is OK (non-MCP PRs legitimately skip test-mcp via | |
| # `changes.outputs.mcp`, host class: deps, filtered — see | |
| # docs/guides/ci-gate-hosting.md). But if MCP code DID change and | |
| # the lane was skipped, that's a path-filter/matrix regression | |
| # silently dropping our only Linux MCP-package coverage | |
| # (skipped-as-passed hole) — fail closed. | |
| if [[ "${{ needs.changes.outputs.mcp }}" == 'true' \ | |
| && "${{ needs.test-mcp.result }}" == 'skipped' ]]; then | |
| echo "::error::MCP server tests (test-mcp) skipped on an MCP-touching PR (path-filter/matrix regression dropped required MCP coverage)" | |
| exit 1 | |
| fi | |
| # test-windows is BLOCKING. The Windows-portability gap (#1620) is | |
| # closed — the windows-latest MCP suite is green — so a red Windows | |
| # job now fails CI like any other tier, guarding against regressions. | |
| if [[ "${{ needs.test-windows.result }}" =~ ^(failure|cancelled)$ ]]; then | |
| echo "::error::Windows unit tests: ${{ needs.test-windows.result }}" | |
| exit 1 | |
| fi | |
| # DR-5 (WLM slice 3): the windows lane is REQUIRED for any MCP change. | |
| # The aggregator treats generic skips as OK (non-MCP PRs legitimately | |
| # skip test-windows via `changes.outputs.mcp`). But if MCP code DID | |
| # change and the lane was skipped, that's a path-filter/matrix | |
| # regression dropping our only Windows coverage — fail closed. | |
| if [[ "${{ needs.changes.outputs.mcp }}" == 'true' \ | |
| && "${{ needs.test-windows.result }}" == 'skipped' ]]; then | |
| echo "::error::Windows lane skipped on an MCP-touching PR (path-filter/matrix regression dropped required Windows coverage)" | |
| exit 1 | |
| fi | |
| # test-windows-root (task 021) is BLOCKING — same posture as | |
| # test-windows. It gives the root package (installer surfaces, src/) | |
| # its first Windows coverage, so a red root Windows job fails CI. | |
| if [[ "${{ needs.test-windows-root.result }}" =~ ^(failure|cancelled)$ ]]; then | |
| echo "::error::Windows root-package tests: ${{ needs.test-windows-root.result }}" | |
| exit 1 | |
| fi | |
| # The root Windows lane is REQUIRED for any root-source change. A | |
| # generic skip is OK (non-root PRs legitimately skip test-windows-root | |
| # via `changes.outputs.root`). But if root code DID change and the | |
| # lane was skipped, that's a path-filter/matrix regression dropping | |
| # our only Windows coverage of the root package — fail closed. | |
| if [[ "${{ needs.changes.outputs.root }}" == 'true' \ | |
| && "${{ needs.test-windows-root.result }}" == 'skipped' ]]; then | |
| echo "::error::Windows root lane skipped on a root-touching PR (path-filter/matrix regression dropped required Windows root coverage)" | |
| exit 1 | |
| fi | |
| if [[ "${{ needs.validate-no-legacy.result }}" =~ ^(failure|cancelled)$ ]]; then | |
| echo "::error::Validate no legacy: ${{ needs.validate-no-legacy.result }}" | |
| exit 1 | |
| fi | |
| # manifest-gate (DR-2, Task 004) is BLOCKING. It runs on every PR and | |
| # is inert unless the PR touches a consolidation pair, so it needs no | |
| # path-filter skip-guard (its `if:` references no changes.outputs.*). | |
| if [[ "${{ needs.manifest-gate.result }}" =~ ^(failure|cancelled)$ ]]; then | |
| echo "::error::Manifest gate (textual case-preservation): ${{ needs.manifest-gate.result }}" | |
| exit 1 | |
| fi | |
| if [[ "${{ needs.grep-gates.result }}" =~ ^(failure|cancelled)$ ]]; then | |
| echo "::error::Grep gates: ${{ needs.grep-gates.result }}" | |
| exit 1 | |
| fi | |
| if [[ "${{ needs.outcome-tests.result }}" =~ ^(failure|cancelled)$ ]]; then | |
| echo "::error::Outcome tests: ${{ needs.outcome-tests.result }}" | |
| exit 1 | |
| fi | |
| echo "All checks passed (skipped jobs are OK)" |