From 056d362df81cb88ed5794216b7bb3a0978ea480e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 27 Jul 2026 14:35:12 +0000 Subject: [PATCH 01/11] test: ratchet mutation score over enumerated decision kernels Adds a Stryker (vitest runner) mutation lane scoped to the decision kernels, a per-module baseline with tool/config provenance, and a ratchet that only lets scores rise. Non-gating until two consecutive stable weekly sweeps. Refs #1415 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .fallowrc.json | 2 +- .github/workflows/mutation-affected.yml | 62 + .github/workflows/mutation-weekly.yml | 106 ++ docs/agents/testing.md | 35 + mutation-baselines/decision-kernels.json | 53 + package.json | 13 +- pnpm-lock.yaml | 1404 +++++++++++++++++++++- scripts/check-affected/run.ts | 21 +- scripts/lib/cli-args.ts | 26 + scripts/mutation/config.test.ts | 41 + scripts/mutation/modules.test.ts | 54 + scripts/mutation/modules.ts | 111 ++ scripts/mutation/ratchet.test.ts | 240 ++++ scripts/mutation/ratchet.ts | 208 ++++ scripts/mutation/report.ts | 81 ++ scripts/mutation/run.ts | 262 ++++ scripts/mutation/score.ts | 103 ++ scripts/mutation/test-scope.test.ts | 28 + scripts/mutation/test-scope.ts | 135 +++ scripts/mutation/workflow.test.ts | 45 + stryker.config.json | 41 + vitest.config.ts | 2 +- vitest.mutation.config.ts | 26 + 23 files changed, 3063 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/mutation-affected.yml create mode 100644 .github/workflows/mutation-weekly.yml create mode 100644 mutation-baselines/decision-kernels.json create mode 100644 scripts/lib/cli-args.ts create mode 100644 scripts/mutation/config.test.ts create mode 100644 scripts/mutation/modules.test.ts create mode 100644 scripts/mutation/modules.ts create mode 100644 scripts/mutation/ratchet.test.ts create mode 100644 scripts/mutation/ratchet.ts create mode 100644 scripts/mutation/report.ts create mode 100644 scripts/mutation/run.ts create mode 100644 scripts/mutation/score.ts create mode 100644 scripts/mutation/test-scope.test.ts create mode 100644 scripts/mutation/test-scope.ts create mode 100644 scripts/mutation/workflow.test.ts create mode 100644 stryker.config.json create mode 100644 vitest.mutation.config.ts diff --git a/.fallowrc.json b/.fallowrc.json index 80ce2306c..2b3536abf 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -66,7 +66,7 @@ }, { "comment": "Tool config default exports, loaded by the tool rather than imported.", - "file": "{tsdown.config.ts,website/rspress.config.ts,test/skillgym/skillgym.config.ts,test/skillgym/suites/*.ts}", + "file": "{tsdown.config.ts,vitest.mutation.config.ts,website/rspress.config.ts,test/skillgym/skillgym.config.ts,test/skillgym/suites/*.ts}", "exports": [ "default" ] diff --git a/.github/workflows/mutation-affected.yml b/.github/workflows/mutation-affected.yml new file mode 100644 index 000000000..ae24ef3df --- /dev/null +++ b/.github/workflows/mutation-affected.yml @@ -0,0 +1,62 @@ +name: Mutation Affected + +# PR-side half of the decision-kernel mutation lane (issue #1415). +# +# Graduation, not a flag day: this job runs on every PR that touches a kernel +# module, but `scripts/mutation/run.ts` only exits non-zero once the committed +# baseline reports `gating: true` — earned by two consecutive stable weekly +# sweeps (mutation-weekly.yml). Until then it is a report with the same numbers, +# so the gate's first failing day is not also its first running day. +# +# Scope is the AFFECTED modules only (changed file in the kernel registry -> its +# mutants). The weekly run stays the full sweep. + +on: + pull_request: + paths: + - "src/kernel/errors.ts" + - "src/daemon/ref-frame.ts" + - "src/commands/interaction/runtime/settle.ts" + - "src/utils/scroll-edge-state.ts" + - "src/selectors/**" + - "scripts/mutation/**" + - "stryker.config.json" + - "mutation-baselines/**" + - ".github/workflows/mutation-affected.yml" + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + mutation-affected: + name: Affected decision-kernel mutants + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Ratchet self-test + run: pnpm mutation:test + + - name: Run affected mutants + run: pnpm mutation:affected --base "origin/${{ github.event.pull_request.base.ref }}" + + - name: Upload mutation report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: mutation-affected + path: | + .tmp/mutation/mutation.json + .tmp/mutation/mutation.html + if-no-files-found: ignore diff --git a/.github/workflows/mutation-weekly.yml b/.github/workflows/mutation-weekly.yml new file mode 100644 index 000000000..5ffcb8e9d --- /dev/null +++ b/.github/workflows/mutation-weekly.yml @@ -0,0 +1,106 @@ +name: Mutation Weekly + +# Weekly mutation sweep over the enumerated decision kernels (issue #1415). +# Mutation score is the mechanical answer to "is this test load-bearing or +# decorative"; a full-suite sweep is unaffordable, so the scope is the kernel +# registry in scripts/mutation/modules.ts and nothing else. +# +# Sharded one job per module: the whole sweep is ~2,150 mutants, and the selector +# module alone is ~1,280 of them, so a single job would sit near the 30-minute +# acceptance budget on an ubuntu runner. The shards' JSON reports are merged into +# one verdict by the ratchet job (`--report-dir`), so the ratchet still sees a +# full sweep. +# +# The lane reports; it does not commit. The proposed baseline rides in the +# artifact and applying it is a reviewed `pnpm mutation:baseline` commit — a score +# can never lower itself. Gating (after two consecutive stable weekly runs) is +# enforced on PRs by mutation-affected.yml. + +on: + schedule: + # Sundays 05:00 UTC — after the nightly lanes, before the working week. + - cron: "0 5 * * 0" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + shard: + name: Mutants (${{ matrix.module }}) + runs-on: ubuntu-latest + # Budget is 30 minutes per shard; the headroom covers checkout/install so a + # slow runner still reports instead of being killed mid-sweep. + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + # Kept in step with KERNEL_MODULES by the shard-coverage assertion in + # scripts/mutation/workflow.test.ts. + module: + - kernel-errors + - daemon-ref-frame + - interaction-settle + - scroll-edge-state + - selectors + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Run mutants for ${{ matrix.module }} + run: pnpm mutation:run --modules ${{ matrix.module }} + + - name: Upload shard report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: mutation-shard-${{ matrix.module }} + path: .tmp/mutation/mutation.json + if-no-files-found: warn + + ratchet: + name: Mutation ratchet + runs-on: ubuntu-latest + needs: shard + if: always() + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Ratchet self-test + run: pnpm mutation:test + + - name: Download shard reports + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: mutation-shard-* + path: .tmp/mutation/shards + + # 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 + run: | + pnpm mutation:check --report-dir .tmp/mutation/shards --update + cp mutation-baselines/decision-kernels.json .tmp/mutation/proposed-baseline.json + git checkout -- mutation-baselines/decision-kernels.json + + - name: Upload mutation report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: mutation-decision-kernels + path: | + .tmp/mutation/shards + .tmp/mutation/proposed-baseline.json + if-no-files-found: warn diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 6e778d1b2..65def6b19 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -20,6 +20,7 @@ The mapping it encodes, for when you need to run a gate directly or reason about | Help benchmark cases (`scripts/help-conformance-*.mjs`) | `pnpm exec vitest run scripts/__tests__` (deterministic gates); model-backed: `pnpm bench:help-conformance` (paid LLM calls, local only) | | SkillGym prompts/assertions | `pnpm test:skillgym:case ` (broad: `pnpm test:skillgym`, filter with `-- --tag fixture-smoke` or `-- --tag skill-guidance`) — agentic routing + local-help-consumption proof only; command-planning knowledge checks belong in the help bench | | Anything in `src/`, `test/`, `skills/` | `pnpm format` | +| A decision kernel or its tests (`src/kernel/errors.ts`, `src/daemon/ref-frame.ts`, `src/commands/interaction/runtime/settle.ts`, `src/utils/scroll-edge-state.ts`, `src/selectors/`) | `pnpm mutation:affected --base origin/main` (minutes; GitHub runs it per PR — see the mutation ratchet section) | Two traps worth naming: @@ -119,6 +120,40 @@ The plan documents the rule and changed path behind every selected check. Model and catalog live under `scripts/check-affected/`; the derivation is guarded by `pnpm check:affected:test` (the `Affected-check Selector` CI job). +## Mutation ratchet over decision kernels + +Mutation score is the mechanical answer to "is this test load-bearing or decorative". A full-suite +sweep is unaffordable, so the scope is an enumerated list of pure decision kernels — modules where a +surviving mutant means a silently wrong agent-facing decision. The registry +(`scripts/mutation/modules.ts`) is the single source of truth: `stryker.config.json`'s `mutate` globs +are asserted against it, and PR-affected selection maps changed files through it. Modules that spawn +subprocesses or wait real time stay out by construction. + +```sh +pnpm mutation:test # ratchet self-test (fast, no Stryker) +pnpm mutation:run --modules selectors # one module locally (~7 min for selectors) +pnpm mutation:check # ratchet an existing .tmp/mutation/mutation.json +pnpm mutation:baseline # full sweep, then record it (reviewed commit) +``` + +- **Weekly full sweep** (`.github/workflows/mutation-weekly.yml`) shards one job per module so no job + approaches the 30-minute budget, then merges the shard reports (`--report-dir`) for one verdict, + reported as a job summary plus an artifact. It never commits: the proposed baseline rides in the + artifact, and applying it is a reviewed `pnpm mutation:baseline` commit, so a score cannot lower + itself. +- **PR lane** (`.github/workflows/mutation-affected.yml`) mutates only the modules the diff touches. +- **Ratchet**: scores may only rise. `mutation-baselines/decision-kernels.json` records the + high-water score per module plus the Stryker version and config content hash that produced it, so a + score change caused by a tool/config change is reported as provenance drift, never as a + test-strength regression. +- **Graduation, not a flag day**: gating is off until two consecutive comparable weekly sweeps pass + (`stableRuns`/`requiredStableRuns` in the baseline); the PR job runs the whole time and only starts + failing once the committed baseline says `gating: true`. A regression or provenance drift resets the + counter. +- **Test scope** is derived from Vitest's module graph (`vitest related` over the mutated files), the + same delegation `pnpm check:affected` uses; see `scripts/mutation/test-scope.ts` for the three + groups it drops and why dropping them cannot hide a surviving mutant. + ## Live web smoke The live web platform smoke runs the public built CLI against a local fixture page through the managed web backend: diff --git a/mutation-baselines/decision-kernels.json b/mutation-baselines/decision-kernels.json new file mode 100644 index 000000000..357333b17 --- /dev/null +++ b/mutation-baselines/decision-kernels.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 1, + "requiredStableRuns": 2, + "stableRuns": 0, + "gating": false, + "modules": { + "kernel-errors": { + "score": 55.19, + "killed": 101, + "survived": 82, + "total": 183, + "strykerVersion": "9.6.1", + "configHash": "sha256:806d9f2e657f", + "updatedAt": "2026-07-27T14:29:40.750Z" + }, + "daemon-ref-frame": { + "score": 100, + "killed": 55, + "survived": 0, + "total": 55, + "strykerVersion": "9.6.1", + "configHash": "sha256:806d9f2e657f", + "updatedAt": "2026-07-27T14:29:40.750Z" + }, + "interaction-settle": { + "score": 68.95, + "killed": 151, + "survived": 68, + "total": 219, + "strykerVersion": "9.6.1", + "configHash": "sha256:806d9f2e657f", + "updatedAt": "2026-07-27T14:29:40.750Z" + }, + "scroll-edge-state": { + "score": 28.75, + "killed": 92, + "survived": 228, + "total": 320, + "strykerVersion": "9.6.1", + "configHash": "sha256:806d9f2e657f", + "updatedAt": "2026-07-27T14:29:40.750Z" + }, + "selectors": { + "score": 70.56, + "killed": 901, + "survived": 376, + "total": 1277, + "strykerVersion": "9.6.1", + "configHash": "sha256:806d9f2e657f", + "updatedAt": "2026-07-27T14:29:40.750Z" + } + } +} diff --git a/package.json b/package.json index 27a3e9c46..49d2b369a 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "build:macos-helper": "swift build -c release --package-path apple/macos-helper", "build:all": "pnpm build:node && pnpm build:xcuitest", "ad": "node bin/agent-device.mjs", - "bench:help-conformance": "node scripts/help-conformance-bench.mjs", + "bench:help-conformance": "node scripts/mutation scripts/help-conformance-bench.mjs", "maestro:conformance": "node --experimental-strip-types --test scripts/maestro-conformance/verify.test.ts scripts/maestro-conformance/differential/run.test.ts scripts/maestro-conformance/differential/invariants.test.ts", "maestro:conformance:regenerate": "node scripts/maestro-conformance/regenerate.mjs", "maestro:conformance:differential": "node --experimental-strip-types scripts/maestro-conformance/differential/run.ts", @@ -108,9 +108,14 @@ "perf": "node --experimental-strip-types scripts/perf/run.ts", "perf:ios": "node --experimental-strip-types scripts/perf/run.ts --platform ios", "perf:android": "node --experimental-strip-types scripts/perf/run.ts --platform android", + "mutation:run": "node --experimental-strip-types scripts/mutation/run.ts", + "mutation:baseline": "node --experimental-strip-types scripts/mutation/run.ts --update", + "mutation:check": "node --experimental-strip-types scripts/mutation/run.ts --no-run", + "mutation:affected": "node --experimental-strip-types scripts/mutation/run.ts --affected", + "mutation:test": "node --experimental-strip-types --test scripts/mutation/*.test.ts", "lint": "oxlint . --deny-warnings", - "format": "node ./node_modules/oxfmt/bin/oxfmt --write src test skills scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", - "format:check": "node ./node_modules/oxfmt/bin/oxfmt --check src test skills scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", + "format": "node ./node_modules/oxfmt/bin/oxfmt --write src test skills scripts/mutation scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", + "format:check": "node ./node_modules/oxfmt/bin/oxfmt --check src test skills scripts/mutation scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", "fallow": "fallow audit --base origin/main", "fallow:all": "fallow --summary", "fallow:baseline": "(fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary || true) && (fallow health --save-baseline fallow-baselines/health.json --summary || true)", @@ -228,6 +233,8 @@ }, "devDependencies": { "@chenglou/freerange": "^0.0.1", + "@stryker-mutator/core": "9.6.1", + "@stryker-mutator/vitest-runner": "9.6.1", "@types/node": "^22.19.21", "@vitest/coverage-v8": "4.1.8", "fallow": "^2.95.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4d402a87..7137e0dee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,12 @@ importers: '@chenglou/freerange': specifier: ^0.0.1 version: 0.0.1 + '@stryker-mutator/core': + specifier: 9.6.1 + version: 9.6.1(@types/node@22.19.21) + '@stryker-mutator/vitest-runner': + specifier: 9.6.1 + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@22.19.21))(vitest@4.1.8) '@types/node': specifier: ^22.19.21 version: 22.19.21 @@ -72,23 +78,176 @@ importers: packages: + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.29.3': resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.7': + resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.29.7': + resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -167,6 +326,146 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -883,6 +1182,9 @@ packages: '@rspress/shared@2.0.12': resolution: {integrity: sha512-YTzaeMvxQRiMwCt5pk7CwkSBenp8HS+t1E82jFDhLwXPMChk7LHYazPGIuaNAoDMN1axW5EHtMUdZm7wVI8EdQ==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shikijs/core@4.0.2': resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} engines: {node: '>=20'} @@ -918,9 +1220,36 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stryker-mutator/api@9.6.1': + resolution: {integrity: sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==} + engines: {node: '>=20.0.0'} + + '@stryker-mutator/core@9.6.1': + resolution: {integrity: sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ==} + engines: {node: '>=20.0.0'} + hasBin: true + + '@stryker-mutator/instrumenter@9.6.1': + resolution: {integrity: sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA==} + engines: {node: '>=20.0.0'} + + '@stryker-mutator/util@9.6.1': + resolution: {integrity: sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ==} + + '@stryker-mutator/vitest-runner@9.6.1': + resolution: {integrity: sha512-eyUHTCf3Ui+SUn/tpFJwzw6MV391kyBLZk/cDHFUfKFELqKMLbvd7e81axArlApKqO6cOnLfrxlwED+2SRN0ow==} + engines: {node: '>=14.18.0'} + peerDependencies: + '@stryker-mutator/core': 9.6.1 + vitest: '>=2.0.0' + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -1304,6 +1633,13 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + angular-html-parser@10.4.0: + resolution: {integrity: sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==} + engines: {node: '>= 14'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -1326,13 +1662,42 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.4: + resolution: {integrity: sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==} + engines: {node: '>=6.0.0'} + hasBin: true + body-scroll-lock@4.0.0-beta.0: resolution: {integrity: sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1340,6 +1705,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1352,10 +1721,17 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + cli-spinners@3.4.0: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1370,6 +1746,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} @@ -1383,6 +1763,10 @@ packages: copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1405,6 +1789,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + des.js@1.1.0: + resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1412,6 +1799,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -1421,6 +1811,13 @@ packages: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.396: + resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -1432,15 +1829,31 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -1474,6 +1887,10 @@ packages: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1490,6 +1907,21 @@ packages: resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1499,6 +1931,10 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + flexsearch@0.8.212: resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==} @@ -1507,18 +1943,49 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-east-asian-width@1.5.0: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@5.0.0-beta.5: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} @@ -1571,6 +2038,14 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ignore@7.0.6: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} @@ -1579,6 +2054,9 @@ packages: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1602,6 +2080,17 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -1614,9 +2103,31 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + js-md4@0.3.2: + resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-rpc-2.0@1.7.1: + resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1691,9 +2202,15 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1711,6 +2228,10 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -1896,9 +2417,33 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mutation-server-protocol@0.4.1: + resolution: {integrity: sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==} + engines: {node: '>=18'} + + mutation-testing-elements@3.7.3: + resolution: {integrity: sha512-SMeIPxngJpfjfNYctFpYQQtlBlZaVO0aoB3FKdwrI8Ee/2bkyUuCZzAOCLv1U9fnmfA37dPFq0Owduoxs2XgGQ==} + + mutation-testing-metrics@3.7.3: + resolution: {integrity: sha512-B8QrP0ZomErzTPNlhrzKWPNBln+3afwBZPHv0Q7N8wZZTYxMptzb/Gdm3ExXVmioVYrtZAtsDs7W/T/b2AixOQ==} + + mutation-testing-report-schema@3.7.3: + resolution: {integrity: sha512-BHm3MYq+ckO+t5CtlG8zpqxc75rdJCkxVlE+fGuGJM3F7tNCQ/OW2N+TQVHN3BHsYa84+BFc6g3AwDYkUsw2MA==} + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + nano-spawn@2.1.0: resolution: {integrity: sha512-yTW+2okrElHiH4fsiz/+/zc0EDo9BDDoC3iKk8dpv1GeRc9nUWzUZHx6TofMWErchhUQR8hY9/Eu1Uja9x1nqA==} engines: {node: '>=20.17'} @@ -1908,9 +2453,21 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + nprogress@0.2.0: resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -1956,6 +2513,14 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1978,6 +2543,10 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -1988,6 +2557,10 @@ packages: pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -2102,6 +2675,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2142,12 +2719,22 @@ packages: '@rsbuild/core': optional: true + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -2161,13 +2748,41 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + shiki@4.0.2: resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} engines: {node: '>=20'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + skillgym@0.9.1: resolution: {integrity: sha512-vEFioLv7JQHWhPW7Cs2u7WIGl7GhE4D3K+IOAUEnuIewIdjAsmjnZXK0BS5oyY9TvKJECZh6a0XY46+GNaptpw==} engines: {node: '>=22.18.0'} @@ -2201,6 +2816,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -2288,6 +2907,18 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + + typed-inject@5.0.0: + resolution: {integrity: sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==} + engines: {node: '>=18'} + + typed-rest-client@2.3.1: + resolution: {integrity: sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==} + engines: {node: '>= 16.0.0'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -2301,6 +2932,9 @@ packages: unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -2311,6 +2945,10 @@ packages: unhead@2.1.15: resolution: {integrity: sha512-MCt5T90mCWyr3Z6pUCdM9lVRXoMoVBlL7z7U4CYVIiaDiuzad/UCfLuMqz5MeNmpZUgoBCQnrucJimU7EZR+XA==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -2338,6 +2976,12 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -2431,9 +3075,17 @@ packages: jsdom: optional: true + weapon-regex@1.3.6: + resolution: {integrity: sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -2451,41 +3103,264 @@ packages: utf-8-validate: optional: true + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true - yuku-ast@0.1.7: - resolution: {integrity: sha512-2RiMEWv500TixY5rJy6OZd4fSy9WYZKWh6gGbIJ7y7vAGcuCugWOWwOLGaQcRZrXcPUfqtLtvpaJ3SdXtWlhKA==} + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + + yuku-ast@0.1.7: + resolution: {integrity: sha512-2RiMEWv500TixY5rJy6OZd4fSy9WYZKWh6gGbIJ7y7vAGcuCugWOWwOLGaQcRZrXcPUfqtLtvpaJ3SdXtWlhKA==} + + yuku-codegen@0.5.44: + resolution: {integrity: sha512-0rhtgWGz+bR3Pe7xqJ5E4VqxI1vqNtkeJRkeXM0Qd3tgldYClQipxa9bRZyuNOOfk0Ri02scrjnkoAHM16/f2g==} + + yuku-parser@0.5.44: + resolution: {integrity: sha512-mAhpQZ/bXjxZmKiGUqEWskC9mZTcTBv6/fdzVdzdjM6XuD1DP3IavdLVWdM39L9ewK9vS9OtJmaKNeWgRzpy0w==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - yuku-codegen@0.5.44: - resolution: {integrity: sha512-0rhtgWGz+bR3Pe7xqJ5E4VqxI1vqNtkeJRkeXM0Qd3tgldYClQipxa9bRZyuNOOfk0Ri02scrjnkoAHM16/f2g==} + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - yuku-parser@0.5.44: - resolution: {integrity: sha512-mAhpQZ/bXjxZmKiGUqEWskC9mZTcTBv6/fdzVdzdjM6XuD1DP3IavdLVWdM39L9ewK9vS9OtJmaKNeWgRzpy0w==} + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color -snapshots: + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color - '@babel/helper-string-parser@7.27.1': {} + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color - '@babel/helper-validator-identifier@7.28.5': {} + '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color - '@babel/parser@7.29.3': + '@babel/template@7.29.7': dependencies: - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} '@callstack/rspress-preset@0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': @@ -2574,6 +3449,135 @@ snapshots: '@fallow-cli/win32-x64-msvc@2.95.0': optional: true + '@inquirer/ansi@2.0.7': {} + + '@inquirer/checkbox@5.2.1(@types/node@22.19.21)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.19.21) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.19.21) + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/confirm@6.1.1(@types/node@22.19.21)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.21) + '@inquirer/type': 4.0.7(@types/node@22.19.21) + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/core@11.2.1(@types/node@22.19.21)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.19.21) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/editor@5.2.2(@types/node@22.19.21)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.21) + '@inquirer/external-editor': 3.0.3(@types/node@22.19.21) + '@inquirer/type': 4.0.7(@types/node@22.19.21) + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/expand@5.1.1(@types/node@22.19.21)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.21) + '@inquirer/type': 4.0.7(@types/node@22.19.21) + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/external-editor@3.0.3(@types/node@22.19.21)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/input@5.1.2(@types/node@22.19.21)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.21) + '@inquirer/type': 4.0.7(@types/node@22.19.21) + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/number@4.1.1(@types/node@22.19.21)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.21) + '@inquirer/type': 4.0.7(@types/node@22.19.21) + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/password@5.1.1(@types/node@22.19.21)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.19.21) + '@inquirer/type': 4.0.7(@types/node@22.19.21) + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/prompts@8.5.2(@types/node@22.19.21)': + dependencies: + '@inquirer/checkbox': 5.2.1(@types/node@22.19.21) + '@inquirer/confirm': 6.1.1(@types/node@22.19.21) + '@inquirer/editor': 5.2.2(@types/node@22.19.21) + '@inquirer/expand': 5.1.1(@types/node@22.19.21) + '@inquirer/input': 5.1.2(@types/node@22.19.21) + '@inquirer/number': 4.1.1(@types/node@22.19.21) + '@inquirer/password': 5.1.1(@types/node@22.19.21) + '@inquirer/rawlist': 5.3.1(@types/node@22.19.21) + '@inquirer/search': 4.2.1(@types/node@22.19.21) + '@inquirer/select': 5.2.1(@types/node@22.19.21) + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/rawlist@5.3.1(@types/node@22.19.21)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.21) + '@inquirer/type': 4.0.7(@types/node@22.19.21) + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/search@4.2.1(@types/node@22.19.21)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.19.21) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.19.21) + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/select@5.2.1(@types/node@22.19.21)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@22.19.21) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.19.21) + optionalDependencies: + '@types/node': 22.19.21 + + '@inquirer/type@4.0.7(@types/node@22.19.21)': + optionalDependencies: + '@types/node': 22.19.21 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -3077,6 +4081,8 @@ snapshots: - '@module-federation/runtime-tools' - core-js + '@sec-ant/readable-stream@0.4.1': {} + '@shikijs/core@4.0.2': dependencies: '@shikijs/primitive': 4.0.2 @@ -3126,8 +4132,77 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@stryker-mutator/api@9.6.1': + dependencies: + mutation-testing-metrics: 3.7.3 + mutation-testing-report-schema: 3.7.3 + tslib: 2.8.1 + typed-inject: 5.0.0 + + '@stryker-mutator/core@9.6.1(@types/node@22.19.21)': + dependencies: + '@inquirer/prompts': 8.5.2(@types/node@22.19.21) + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/instrumenter': 9.6.1 + '@stryker-mutator/util': 9.6.1 + ajv: 8.18.0 + chalk: 5.6.2 + commander: 14.0.3 + diff-match-patch: 1.0.5 + emoji-regex: 10.6.0 + execa: 9.6.1 + json-rpc-2.0: 1.7.1 + lodash.groupby: 4.6.0 + minimatch: 10.2.5 + mutation-server-protocol: 0.4.1 + mutation-testing-elements: 3.7.3 + mutation-testing-metrics: 3.7.3 + mutation-testing-report-schema: 3.7.3 + npm-run-path: 6.0.0 + progress: 2.0.3 + rxjs: 7.8.2 + semver: 7.8.5 + source-map: 0.7.6 + tree-kill: 1.2.2 + tslib: 2.8.1 + typed-inject: 5.0.0 + typed-rest-client: 2.3.1 + transitivePeerDependencies: + - '@types/node' + - supports-color + + '@stryker-mutator/instrumenter@9.6.1': + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.3 + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/util': 9.6.1 + angular-html-parser: 10.4.0 + semver: 7.7.4 + tslib: 2.8.1 + weapon-regex: 1.3.6 + transitivePeerDependencies: + - supports-color + + '@stryker-mutator/util@9.6.1': {} + + '@stryker-mutator/vitest-runner@9.6.1(@stryker-mutator/core@9.6.1(@types/node@22.19.21))(vitest@4.1.8)': + dependencies: + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/core': 9.6.1(@types/node@22.19.21) + '@stryker-mutator/util': 9.6.1 + semver: 7.8.5 + tslib: 2.8.1 + vitest: 4.1.8(@types/node@22.19.21)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.19.21)(yaml@2.9.0)) + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -3384,6 +4459,15 @@ snapshots: agent-base@7.1.4: {} + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + angular-html-parser@10.4.0: {} + ansi-regex@6.2.2: {} ansis@4.3.1: {} @@ -3400,14 +4484,44 @@ snapshots: bail@2.0.2: {} + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.4: {} + body-scroll-lock@4.0.0-beta.0: {} + brace-expansion@5.0.8: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.4 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.396 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + cac@7.0.0: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001806: {} + ccount@2.0.1: {} chai@6.2.2: {} + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -3416,8 +4530,12 @@ snapshots: character-reference-invalid@2.0.1: {} + chardet@2.2.0: {} + cli-spinners@3.4.0: {} + cli-width@4.1.0: {} + clsx@2.1.1: {} collapse-white-space@2.1.0: {} @@ -3426,6 +4544,8 @@ snapshots: commander@13.1.0: {} + commander@14.0.3: {} + compute-scroll-into-view@3.1.1: {} convert-source-map@2.0.0: {} @@ -3436,6 +4556,12 @@ snapshots: dependencies: toggle-selection: 1.0.6 + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + csstype@3.2.3: {} debug@4.4.3: @@ -3450,22 +4576,45 @@ snapshots: dequal@2.0.3: {} + des.js@1.1.0: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + detect-libc@2.1.2: {} devlop@1.1.0: dependencies: dequal: 2.0.3 + diff-match-patch@1.0.5: {} + dts-resolver@3.0.0: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.396: {} + emoji-regex@10.6.0: {} empathic@2.0.1: {} entities@6.0.1: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esast-util-from-estree@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -3480,6 +4629,8 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + escalade@3.2.0: {} + escape-string-regexp@5.0.0: {} estree-util-attach-comments@3.0.0: @@ -3521,6 +4672,21 @@ snapshots: eventsource-parser@3.1.0: {} + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + expect-type@1.3.0: {} extend@3.0.2: {} @@ -3542,23 +4708,76 @@ snapshots: dependencies: pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.4: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + flexsearch@0.8.212: {} fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@5.0.0-beta.5: dependencies: resolve-pkg-maps: 1.0.0 + gopd@1.2.0: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: dependencies: '@types/hast': 3.0.4 @@ -3694,10 +4913,18 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@8.0.1: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ignore@7.0.6: {} import-without-cache@0.4.0: {} + inherits@2.0.4: {} + inline-style-parser@0.2.7: {} is-absolute-url@4.0.1: {} @@ -3715,6 +4942,12 @@ snapshots: is-plain-obj@4.1.0: {} + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -3728,8 +4961,20 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + js-md4@0.3.2: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-rpc-2.0@1.7.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -3779,8 +5024,14 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lodash.groupby@4.6.0: {} + longest-streak@3.1.0: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3793,12 +5044,14 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 markdown-extensions@2.0.0: {} markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -4260,14 +5513,43 @@ snapshots: transitivePeerDependencies: - supports-color + minimalistic-assert@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.8 + ms@2.1.3: {} + mutation-server-protocol@0.4.1: + dependencies: + zod: 4.3.6 + + mutation-testing-elements@3.7.3: {} + + mutation-testing-metrics@3.7.3: + dependencies: + mutation-testing-report-schema: 3.7.3 + + mutation-testing-report-schema@3.7.3: {} + + mute-stream@3.0.0: {} + nano-spawn@2.1.0: {} nanoid@3.3.12: {} + node-releases@2.0.51: {} + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + nprogress@0.2.0: {} + object-inspect@1.13.4: {} + obug@2.1.1: {} obug@2.1.3: {} @@ -4367,6 +5649,10 @@ snapshots: dependencies: entities: 6.0.1 + path-key@3.1.1: {} + + path-key@4.0.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -4385,12 +5671,18 @@ snapshots: dependencies: parse-ms: 4.0.0 + progress@2.0.3: {} + property-information@7.1.0: {} proxy-from-env@2.1.0: {} pure-rand@8.4.2: {} + qs@6.15.1: + dependencies: + side-channel: 1.1.1 + quansync@1.0.0: {} react-dom@19.2.7(react@19.2.7): @@ -4551,6 +5843,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} rolldown-plugin-dts@0.27.1(rolldown@1.1.4)(typescript@7.0.2): @@ -4613,18 +5907,32 @@ snapshots: optionalDependencies: '@rsbuild/core': 2.0.11 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safer-buffer@2.1.2: {} + scheduler@0.27.0: {} scroll-into-view-if-needed@3.1.0: dependencies: compute-scroll-into-view: 3.1.1 + semver@6.3.1: {} + semver@7.7.4: {} semver@7.8.5: {} set-cookie-parser@2.7.2: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + shiki@4.0.2: dependencies: '@shikijs/core': 4.0.2 @@ -4636,8 +5944,38 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + skillgym@0.9.1: dependencies: '@types/node': 22.19.7 @@ -4673,6 +6011,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@4.0.0: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -4740,6 +6080,18 @@ snapshots: tslib@2.8.1: {} + tunnel@0.0.6: {} + + typed-inject@5.0.0: {} + + typed-rest-client@2.3.1: + dependencies: + des.js: 1.1.0 + js-md4: 0.3.2 + qs: 6.15.1 + tunnel: 0.0.6 + underscore: 1.13.8 + typescript@6.0.3: {} typescript@7.0.2: @@ -4770,6 +6122,8 @@ snapshots: '@quansync/fs': 1.0.0 quansync: 1.0.0 + underscore@1.13.8: {} + undici-types@6.21.0: {} undici@7.28.0: {} @@ -4778,6 +6132,8 @@ snapshots: dependencies: hookable: 6.1.0 + unicorn-magic@0.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -4825,6 +6181,12 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -4880,8 +6242,14 @@ snapshots: transitivePeerDependencies: - msw + weapon-regex@1.3.6: {} + web-namespaces@2.0.1: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -4889,8 +6257,12 @@ snapshots: ws@8.21.0: {} + yallist@3.1.1: {} + yaml@2.9.0: {} + yoctocolors@2.2.0: {} + yuku-ast@0.1.7: dependencies: '@yuku-toolchain/types': 0.5.43 diff --git a/scripts/check-affected/run.ts b/scripts/check-affected/run.ts index 86d087497..2299ed50e 100644 --- a/scripts/check-affected/run.ts +++ b/scripts/check-affected/run.ts @@ -8,8 +8,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { parseArgs as parseNodeArgs } from 'node:util'; import { runCmdStreaming, runCmdSync } from '../../src/utils/exec.ts'; +import { parseScriptArgs } from '../lib/cli-args.ts'; import { assertCatalogComplete, CHECK_CATALOG, @@ -26,21 +26,12 @@ const repoRoot = runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim const USAGE = 'Usage: pnpm check:affected [--base ] [--head ] [--json] [--run]\n'; function parseArgs(argv: readonly string[]): Args { - const { values } = parseNodeArgs({ - args: [...argv], - options: { - base: { type: 'string', default: 'origin/main' }, - head: { type: 'string', default: 'HEAD' }, - json: { type: 'boolean', default: false }, - run: { type: 'boolean', default: false }, - help: { type: 'boolean', short: 'h', default: false }, - }, - allowPositionals: false, + const values = parseScriptArgs(argv, USAGE, { + base: { type: 'string', default: 'origin/main' }, + head: { type: 'string', default: 'HEAD' }, + json: { type: 'boolean', default: false }, + run: { type: 'boolean', default: false }, }); - if (values.help) { - process.stdout.write(USAGE); - process.exit(0); - } return { base: values.base ?? 'origin/main', head: values.head ?? 'HEAD', diff --git a/scripts/lib/cli-args.ts b/scripts/lib/cli-args.ts new file mode 100644 index 000000000..acd555ee5 --- /dev/null +++ b/scripts/lib/cli-args.ts @@ -0,0 +1,26 @@ +import { parseArgs, type ParseArgsConfig } from 'node:util'; + +type Options = NonNullable; + +/** + * `parseArgs` plus the shared script `--help` contract: `-h`/`--help` prints the + * usage the caller owns and exits 0, so no script re-implements the flag. + */ +export function parseScriptArgs( + argv: readonly string[], + usage: string, + options: T, +): ReturnType>['values'] { + const { values } = parseArgs({ + args: [...argv], + options: { ...options, help: { type: 'boolean', short: 'h', default: false } }, + allowPositionals: false, + }); + if (values.help) { + process.stdout.write(usage); + process.exit(0); + } + return values as ReturnType< + typeof parseArgs<{ options: T & { help: { type: 'boolean' } } }> + >['values']; +} diff --git a/scripts/mutation/config.test.ts b/scripts/mutation/config.test.ts new file mode 100644 index 000000000..ff37d06b3 --- /dev/null +++ b/scripts/mutation/config.test.ts @@ -0,0 +1,41 @@ +// The Stryker config and the module registry are two views of one scope. If +// they drift, the weekly sweep silently measures something other than the +// enumerated decision kernels — the exact failure this lane exists to catch. + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { mutateGlobs } from './modules.ts'; +import { configHash } from './run.ts'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +function readConfig(): Record { + const text = fs.readFileSync(path.join(repoRoot, 'stryker.config.json'), 'utf8'); + return JSON.parse(text) as Record; +} + +test('stryker mutate globs mirror the kernel-module registry', () => { + assert.deepEqual( + readConfig().mutate, + mutateGlobs(), + 'stryker.config.json `mutate` drifted from KERNEL_MODULES — update scripts/mutation/modules.ts and the config together.', + ); +}); + +test('stryker owns no pass/fail threshold — the ratchet does', () => { + const thresholds = readConfig().thresholds as { break?: number | null } | undefined; + assert.equal( + thresholds?.break ?? null, + null, + "A Stryker `break` threshold would fail runs on an absolute score; gating is the ratchet's job (scripts/mutation/ratchet.ts).", + ); +}); + +test('the config content hash is stable and content-addressed', () => { + assert.equal(configHash('a'), configHash('a')); + assert.notEqual(configHash('a'), configHash('b')); + assert.match(configHash('a'), /^sha256:[0-9a-f]{12}$/); +}); diff --git a/scripts/mutation/modules.test.ts b/scripts/mutation/modules.test.ts new file mode 100644 index 000000000..708efa39e --- /dev/null +++ b/scripts/mutation/modules.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + affectedModules, + ALL_MODULE_IDS, + KERNEL_MODULES, + moduleForFile, + mutateGlobs, +} from './modules.ts'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +test('every enumerated kernel path exists', () => { + for (const module of KERNEL_MODULES) { + for (const owned of module.owns) { + assert.ok( + fs.existsSync(path.join(repoRoot, owned)), + `${module.id} owns a path that no longer exists: ${owned}`, + ); + } + } +}); + +test('changed files map onto the module that owns them', () => { + assert.equal(moduleForFile('src/kernel/errors.ts'), 'kernel-errors'); + assert.equal(moduleForFile('./src/daemon/ref-frame.ts'), 'daemon-ref-frame'); + assert.equal(moduleForFile('src/selectors/parse.ts'), 'selectors'); + // Tests of a kernel are owned too: a weakened test is exactly the change + // whose mutation score must be re-measured. + assert.equal(moduleForFile('src/selectors/__tests__/resolve.test.ts'), 'selectors'); + assert.equal(moduleForFile('src/kernel/rect.ts'), undefined); + assert.equal(moduleForFile('README.md'), undefined); +}); + +test('affected selection is deduplicated and registry-ordered', () => { + assert.deepEqual( + affectedModules([ + 'src/selectors/parse.ts', + 'src/selectors/match.ts', + 'src/kernel/errors.ts', + 'docs/agents/testing.md', + ]), + ['kernel-errors', 'selectors'], + ); + assert.deepEqual(affectedModules(['docs/agents/testing.md']), []); +}); + +test('mutate globs default to every module and narrow on request', () => { + assert.deepEqual(mutateGlobs(), mutateGlobs(ALL_MODULE_IDS)); + assert.deepEqual(mutateGlobs(['kernel-errors']), ['src/kernel/errors.ts']); +}); diff --git a/scripts/mutation/modules.ts b/scripts/mutation/modules.ts new file mode 100644 index 000000000..100aefdef --- /dev/null +++ b/scripts/mutation/modules.ts @@ -0,0 +1,111 @@ +// Enumerated decision kernels the mutation lane measures (issue #1415). +// +// Mutation score is the only mechanical answer to "is this test load-bearing or +// decorative", but a full-suite sweep is unaffordable. This registry is the +// single source of truth for what Stryker mutates: `stryker.config.json`'s +// `mutate` globs are asserted against it, and PR-affected gating maps changed +// files onto modules through it. +// +// Membership rule: pure decision kernels only — a surviving mutant here means a +// silently wrong agent-facing decision. Anything that spawns subprocesses or +// waits real time is out of scope by construction (its mutants would be timeout +// noise, not test-strength signal). + +export type ModuleId = + | 'kernel-errors' + | 'daemon-ref-frame' + | 'interaction-settle' + | 'scroll-edge-state' + | 'selectors'; + +export type KernelModule = { + readonly id: ModuleId; + readonly label: string; + /** Globs handed to Stryker's `mutate`. */ + readonly mutate: readonly string[]; + /** Paths this module owns; a trailing `/` marks a directory prefix. */ + readonly owns: readonly string[]; +}; + +export const KERNEL_MODULES: readonly KernelModule[] = [ + { + id: 'kernel-errors', + label: 'Error retriability + hints', + mutate: ['src/kernel/errors.ts'], + owns: ['src/kernel/errors.ts'], + }, + { + id: 'daemon-ref-frame', + label: 'Ref-frame admission matrix (ADR 0014)', + mutate: ['src/daemon/ref-frame.ts'], + owns: ['src/daemon/ref-frame.ts'], + }, + { + id: 'interaction-settle', + label: 'Interaction settle decisions', + mutate: ['src/commands/interaction/runtime/settle.ts'], + owns: ['src/commands/interaction/runtime/settle.ts'], + }, + { + id: 'scroll-edge-state', + label: 'Scroll edge-state detection', + mutate: ['src/utils/scroll-edge-state.ts'], + owns: ['src/utils/scroll-edge-state.ts'], + }, + { + id: 'selectors', + label: 'Selector parsing + matching', + mutate: ['src/selectors/**/*.ts', '!src/selectors/**/*.test.ts', '!src/selectors/__tests__/**'], + owns: ['src/selectors/'], + }, +]; + +export const ALL_MODULE_IDS: readonly ModuleId[] = KERNEL_MODULES.map((module) => module.id); + +export function moduleById(id: ModuleId): KernelModule { + const found = KERNEL_MODULES.find((module) => module.id === id); + if (!found) throw new Error(`Unknown mutation module: ${id}`); + return found; +} + +export function isModuleId(value: string): value is ModuleId { + return ALL_MODULE_IDS.includes(value as ModuleId); +} + +/** Mutate globs for a module subset, in registry order. */ +export function mutateGlobs(ids: readonly ModuleId[] = ALL_MODULE_IDS): string[] { + return KERNEL_MODULES.filter((module) => ids.includes(module.id)).flatMap((module) => [ + ...module.mutate, + ]); +} + +export function normalizePath(filePath: string): string { + return filePath.replaceAll('\\', '/').replace(/^\.\//, ''); +} + +/** + * Which kernel module owns a repository-relative path, if any. + * + * Test files under an owned root count as owned: strengthening (or weakening) a + * kernel's tests is exactly the change whose mutation score must be re-measured. + */ +export function moduleForFile(filePath: string): ModuleId | undefined { + const normalized = normalizePath(filePath); + for (const module of KERNEL_MODULES) { + for (const owned of module.owns) { + const match = owned.endsWith('/') ? normalized.startsWith(owned) : normalized === owned; + if (match) return module.id; + } + } + return undefined; +} + +/** Kernel modules affected by a diff, for PR-scoped runs. */ +export function affectedModules(changedFiles: readonly string[]): ModuleId[] { + const ids = new Set(); + for (const file of changedFiles) { + const id = moduleForFile(file); + if (id) ids.add(id); + } + return KERNEL_MODULES.filter((module) => ids.has(module.id)).map((module) => module.id); +} diff --git a/scripts/mutation/ratchet.test.ts b/scripts/mutation/ratchet.test.ts new file mode 100644 index 000000000..40dd3a92b --- /dev/null +++ b/scripts/mutation/ratchet.test.ts @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { ALL_MODULE_IDS } from './modules.ts'; +import { renderReport } from './report.ts'; +import { + applyRun, + BASELINE_SCHEMA_VERSION, + DEFAULT_REQUIRED_STABLE_RUNS, + emptyBaseline, + evaluateRatchet, + type Baseline, + type ModuleBaseline, + type Provenance, +} from './ratchet.ts'; +import { summarizeReport, type StrykerReport } from './score.ts'; + +const PROVENANCE: Provenance = { strykerVersion: '9.6.1', configHash: 'sha256:abcdef123456' }; +const NOW = '2026-07-27T00:00:00.000Z'; + +function mutants(statuses: readonly string[]): StrykerReport { + return { + files: { + 'src/kernel/errors.ts': { + mutants: statuses.map((status, index) => ({ + status, + mutatorName: 'ConditionalExpression', + location: { start: { line: index + 1 } }, + })), + }, + }, + }; +} + +function baselineWith(entry: Partial): Baseline { + return { + ...emptyBaseline(), + modules: { + 'kernel-errors': { + score: 75, + killed: 3, + survived: 1, + total: 4, + strykerVersion: PROVENANCE.strykerVersion, + configHash: PROVENANCE.configHash, + updatedAt: NOW, + ...entry, + }, + }, + }; +} + +test('scores count timeouts as killed and uncovered mutants as survived', () => { + const [score] = summarizeReport(mutants(['Killed', 'Timeout', 'Survived', 'NoCoverage']), [ + 'kernel-errors', + ]); + assert.deepEqual( + { score: score?.score, killed: score?.killed, survived: score?.survived, total: score?.total }, + { score: 50, killed: 2, survived: 2, total: 4 }, + ); +}); + +test('statuses outside the score (Ignored, CompileError) leave the denominator', () => { + const [score] = summarizeReport(mutants(['Killed', 'Ignored', 'CompileError']), [ + 'kernel-errors', + ]); + assert.equal(score?.total, 1); + assert.equal(score?.score, 100); +}); + +test('a lowered score is a regression, and gating makes it fail with its survivors', () => { + const baseline = { ...baselineWith({ score: 100 }), stableRuns: 2, gating: true }; + const scores = summarizeReport(mutants(['Killed', 'Survived']), ['kernel-errors']); + const result = evaluateRatchet(scores, baseline, PROVENANCE); + + assert.equal(result.failed, true); + assert.deepEqual( + result.regressions.map((verdict) => verdict.module), + ['kernel-errors'], + ); + assert.match(result.regressions[0]?.detail ?? '', /fell 100% -> 50%/); + + const markdown = renderReport(result, baseline, PROVENANCE); + assert.match(markdown, /Surviving mutants:/); + assert.match(markdown, /`src\/kernel\/errors\.ts:2` ConditionalExpression/); + assert.match(markdown, /scores may only rise/); +}); + +test('a lowered score is reported but does not fail while the lane is non-gating', () => { + const scores = summarizeReport(mutants(['Killed', 'Survived']), ['kernel-errors']); + const baseline = baselineWith({ score: 100 }); + const result = evaluateRatchet(scores, baseline, PROVENANCE); + + assert.equal(result.regressions.length, 1); + assert.equal(result.failed, false); + assert.match(renderReport(result, baseline, PROVENANCE), /still non-gating/); +}); + +test('a regression never rewrites the baseline high-water mark', () => { + const baseline = baselineWith({ score: 100 }); + const scores = summarizeReport(mutants(['Killed', 'Survived']), ['kernel-errors']); + const next = applyRun(baseline, scores, evaluateRatchet(scores, baseline, PROVENANCE), { + provenance: PROVENANCE, + now: NOW, + countsTowardGraduation: true, + }); + assert.equal(next.modules['kernel-errors']?.score, 100); + assert.equal(next.stableRuns, 0); +}); + +test('a risen score is recorded with its provenance', () => { + const baseline = baselineWith({ score: 50 }); + const scores = summarizeReport(mutants(['Killed', 'Killed']), ['kernel-errors']); + const result = evaluateRatchet(scores, baseline, PROVENANCE); + assert.equal(result.verdicts[0]?.status, 'improved'); + + const next = applyRun(baseline, scores, result, { + provenance: PROVENANCE, + now: NOW, + countsTowardGraduation: true, + }); + assert.deepEqual(next.modules['kernel-errors'], { + score: 100, + killed: 2, + survived: 0, + total: 2, + strykerVersion: PROVENANCE.strykerVersion, + configHash: PROVENANCE.configHash, + updatedAt: NOW, + }); +}); + +test('gating graduates after the required consecutive stable full sweeps', () => { + const scores = summarizeReport(mutants(['Killed', 'Killed']), ['kernel-errors']); + let baseline = baselineWith({ score: 100 }); + assert.equal(baseline.gating, false); + + for (let run = 1; run <= baseline.requiredStableRuns; run += 1) { + const result = evaluateRatchet(scores, baseline, PROVENANCE); + baseline = applyRun(baseline, scores, result, { + provenance: PROVENANCE, + now: NOW, + countsTowardGraduation: true, + }); + assert.equal(baseline.stableRuns, run); + } + assert.equal(baseline.gating, true); + + // A later regression resets graduation, so gating has to be re-earned. + const dropped = summarizeReport(mutants(['Killed', 'Survived']), ['kernel-errors']); + const regressed = evaluateRatchet(dropped, baseline, PROVENANCE); + assert.equal(regressed.failed, true); + const after = applyRun(baseline, dropped, regressed, { + provenance: PROVENANCE, + now: NOW, + countsTowardGraduation: true, + }); + assert.equal(after.stableRuns, 0); + assert.equal(after.gating, false); +}); + +test('an affected PR run never advances graduation', () => { + const scores = summarizeReport(mutants(['Killed', 'Killed']), ['kernel-errors']); + const baseline = baselineWith({ score: 100 }); + const next = applyRun(baseline, scores, evaluateRatchet(scores, baseline, PROVENANCE), { + provenance: PROVENANCE, + now: NOW, + countsTowardGraduation: false, + }); + assert.equal(next.stableRuns, 0); +}); + +test('a tool or config change is provenance drift, not a test-strength regression', () => { + const scores = summarizeReport(mutants(['Killed', 'Survived']), ['kernel-errors']); + for (const drift of [{ strykerVersion: '9.5.0' }, { configHash: 'sha256:000000000000' }]) { + const baseline = { ...baselineWith({ score: 100, ...drift }), stableRuns: 2, gating: true }; + const result = evaluateRatchet(scores, baseline, PROVENANCE); + assert.equal(result.regressions.length, 0); + assert.equal(result.failed, false); + assert.equal(result.comparable, false); + assert.match(result.drifted[0]?.detail ?? '', /not attributable to test strength/); + + // Drift rebases onto the new tool/config and costs the graduation streak. + const next = applyRun(baseline, scores, result, { + provenance: PROVENANCE, + now: NOW, + countsTowardGraduation: true, + }); + assert.equal(next.modules['kernel-errors']?.score, 50); + assert.equal(next.modules['kernel-errors']?.configHash, PROVENANCE.configHash); + assert.equal(next.stableRuns, 0); + } +}); + +test('a module with no baseline yet is new, recorded, and blocks graduation', () => { + const scores = summarizeReport(mutants(['Killed', 'Survived']), ['kernel-errors']); + const baseline = emptyBaseline(); + const result = evaluateRatchet(scores, baseline, PROVENANCE); + assert.equal(result.verdicts[0]?.status, 'new'); + assert.equal(result.comparable, false); + + const next = applyRun(baseline, scores, result, { + provenance: PROVENANCE, + now: NOW, + countsTowardGraduation: true, + }); + assert.equal(next.modules['kernel-errors']?.score, 50); + assert.equal(next.stableRuns, 0); +}); + +test('the report names the fix command and the graduation state', () => { + const scores = summarizeReport(mutants(['Killed', 'Killed']), ['kernel-errors']); + const baseline = { ...baselineWith({ score: 100 }), stableRuns: 1 }; + const markdown = renderReport( + evaluateRatchet(scores, baseline, PROVENANCE), + baseline, + PROVENANCE, + ); + assert.match(markdown, /gating \*\*off\*\* \(1\/2 stable weekly runs\)/); + assert.match(markdown, /Stryker `9\.6\.1` · config `sha256:abcdef123456`/); +}); + +test('the committed baseline is the shape the lane graduates from', () => { + const baseline = JSON.parse( + fs.readFileSync( + path.resolve(import.meta.dirname, '../../mutation-baselines/decision-kernels.json'), + 'utf8', + ), + ) as Baseline; + assert.equal(baseline.schemaVersion, BASELINE_SCHEMA_VERSION); + // Issue #1415's N: gating is earned by two consecutive stable weekly sweeps. + assert.equal(baseline.requiredStableRuns, DEFAULT_REQUIRED_STABLE_RUNS); + assert.deepEqual(Object.keys(baseline.modules), [...ALL_MODULE_IDS]); + for (const [id, module] of Object.entries(baseline.modules)) { + assert.ok(module.total > 0, `${id} has no mutants`); + assert.ok(module.strykerVersion.length > 0, `${id} has no Stryker version`); + assert.match(module.configHash, /^sha256:[0-9a-f]{12}$/); + } +}); diff --git a/scripts/mutation/ratchet.ts b/scripts/mutation/ratchet.ts new file mode 100644 index 000000000..63700d1df --- /dev/null +++ b/scripts/mutation/ratchet.ts @@ -0,0 +1,208 @@ +// The ratchet itself: scores may only rise, gating is earned, and a score that +// moved for tool/config reasons is never mistaken for test-strength change. +// +// Graduation (issue #1415): the lane starts non-gating. Each comparable weekly +// full sweep with no regression increments `stableRuns`; once it reaches +// `requiredStableRuns` the baseline flips to `gating: true`, after which a +// regression fails the run — weekly as a full sweep, PRs for AFFECTED modules +// only. A regression or a non-comparable run resets the counter to zero. + +import { roundScore, type ModuleScore, type SurvivingMutant } from './score.ts'; +import type { ModuleId } from './modules.ts'; + +export const BASELINE_SCHEMA_VERSION = 1; +export const DEFAULT_REQUIRED_STABLE_RUNS = 2; + +export type ModuleBaseline = { + score: number; + killed: number; + survived: number; + total: number; + // Provenance travels with every module baseline so a tool or config change is + // distinguishable from a test-strength change. + strykerVersion: string; + configHash: string; + updatedAt: string; +}; + +export type Baseline = { + schemaVersion: number; + requiredStableRuns: number; + stableRuns: number; + gating: boolean; + modules: Record; +}; + +export type Provenance = { readonly strykerVersion: string; readonly configHash: string }; + +export type VerdictStatus = 'new' | 'improved' | 'held' | 'regressed' | 'provenance-drift'; + +export type ModuleVerdict = { + readonly module: ModuleId; + readonly score: number; + readonly killed: number; + readonly total: number; + readonly baselineScore: number | undefined; + readonly delta: number | undefined; + readonly status: VerdictStatus; + readonly surviving: readonly SurvivingMutant[]; + readonly detail: string; +}; + +export type RatchetResult = { + readonly verdicts: readonly ModuleVerdict[]; + readonly regressions: readonly ModuleVerdict[]; + readonly drifted: readonly ModuleVerdict[]; + /** A run is comparable when every module has a same-provenance baseline. */ + readonly comparable: boolean; + readonly gating: boolean; + /** True when the caller must exit non-zero. */ + readonly failed: boolean; +}; + +export function emptyBaseline(requiredStableRuns: number = DEFAULT_REQUIRED_STABLE_RUNS): Baseline { + return { + schemaVersion: BASELINE_SCHEMA_VERSION, + requiredStableRuns, + stableRuns: 0, + gating: false, + modules: {}, + }; +} + +function driftDetail(previous: ModuleBaseline, provenance: Provenance): string { + return ( + `baseline recorded by stryker ${previous.strykerVersion} / config ${previous.configHash}, ` + + `this run used stryker ${provenance.strykerVersion} / config ${provenance.configHash}; ` + + 'the score change is not attributable to test strength — re-record with `pnpm mutation:baseline`' + ); +} + +function verdictFor( + score: ModuleScore, + previous: ModuleBaseline | undefined, + provenance: Provenance, +): ModuleVerdict { + const base = { + module: score.module, + score: score.score, + killed: score.killed, + total: score.total, + surviving: score.surviving, + }; + if (!previous) { + return { + ...base, + baselineScore: undefined, + delta: undefined, + status: 'new', + detail: `no baseline recorded yet (measured ${score.score}%)`, + }; + } + const delta = roundScore(score.score - previous.score); + if ( + previous.strykerVersion !== provenance.strykerVersion || + previous.configHash !== provenance.configHash + ) { + return { + ...base, + baselineScore: previous.score, + delta, + status: 'provenance-drift', + detail: driftDetail(previous, provenance), + }; + } + if (delta < 0) { + return { + ...base, + baselineScore: previous.score, + delta, + status: 'regressed', + detail: + `mutation score fell ${previous.score}% -> ${score.score}% ` + + `(${score.survived} surviving mutants)`, + }; + } + return { + ...base, + baselineScore: previous.score, + delta, + status: delta > 0 ? 'improved' : 'held', + detail: + delta > 0 + ? `mutation score rose ${previous.score}% -> ${score.score}%` + : `mutation score held at ${score.score}%`, + }; +} + +export function evaluateRatchet( + scores: readonly ModuleScore[], + baseline: Baseline, + provenance: Provenance, +): RatchetResult { + const verdicts = scores.map((score) => + verdictFor(score, baseline.modules[score.module], provenance), + ); + const regressions = verdicts.filter((verdict) => verdict.status === 'regressed'); + const drifted = verdicts.filter((verdict) => verdict.status === 'provenance-drift'); + const comparable = drifted.length === 0 && verdicts.every((v) => v.status !== 'new'); + return { + verdicts, + regressions, + drifted, + comparable, + gating: baseline.gating, + failed: baseline.gating && regressions.length > 0, + }; +} + +export type ApplyOptions = { + readonly provenance: Provenance; + readonly now: string; + /** Only the weekly full sweep may advance graduation; affected runs may not. */ + readonly countsTowardGraduation: boolean; +}; + +/** + * Fold a run into the baseline: keep the high-water score per module (a + * regression never rewrites it, so the ratchet keeps failing until the tests are + * restored), rebase modules whose provenance drifted onto the new tool/config, + * then advance or reset graduation. + */ +export function applyRun( + baseline: Baseline, + scores: readonly ModuleScore[], + result: RatchetResult, + options: ApplyOptions, +): Baseline { + const modules: Record = { ...baseline.modules }; + for (const score of scores) { + const previous = modules[score.module]; + const status = result.verdicts.find((verdict) => verdict.module === score.module)?.status; + const rebase = !previous || status === 'provenance-drift'; + if (!rebase && score.score < previous.score) continue; + modules[score.module] = { + score: rebase ? score.score : Math.max(previous.score, score.score), + killed: score.killed, + survived: score.survived, + total: score.total, + strykerVersion: options.provenance.strykerVersion, + configHash: options.provenance.configHash, + updatedAt: options.now, + }; + } + + const stable = result.comparable && result.regressions.length === 0; + const stableRuns = !options.countsTowardGraduation + ? baseline.stableRuns + : stable + ? baseline.stableRuns + 1 + : 0; + return { + ...baseline, + schemaVersion: BASELINE_SCHEMA_VERSION, + stableRuns, + gating: stableRuns >= baseline.requiredStableRuns, + modules, + }; +} diff --git a/scripts/mutation/report.ts b/scripts/mutation/report.ts new file mode 100644 index 000000000..31d761fcf --- /dev/null +++ b/scripts/mutation/report.ts @@ -0,0 +1,81 @@ +// Markdown rendering for the mutation lane: GitHub job summary and terminal +// output share one renderer, so the artifact and the console never disagree. + +import { moduleById } from './modules.ts'; +import type { Baseline, ModuleVerdict, Provenance, RatchetResult } from './ratchet.ts'; + +const DEFAULT_MAX_SURVIVING_LISTED = 20; + +function formatDelta(delta: number | undefined): string { + if (delta === undefined) return '—'; + return delta > 0 ? `+${delta}` : String(delta); +} + +function renderRow(verdict: ModuleVerdict): string { + const module = moduleById(verdict.module); + const baseline = verdict.baselineScore === undefined ? '—' : `${verdict.baselineScore}%`; + return ( + `| \`${verdict.module}\` — ${module.label} | ${verdict.score}% | ${baseline} | ` + + `${formatDelta(verdict.delta)} | ${verdict.killed}/${verdict.total} | ` + + `${verdict.surviving.length} | ${verdict.status} |` + ); +} + +function renderDetail(verdict: ModuleVerdict, maxListed: number): string[] { + const lines = ['', `### \`${verdict.module}\` — ${verdict.status}`, '', verdict.detail]; + if (verdict.surviving.length > 0) { + lines.push('', 'Surviving mutants:', ''); + for (const mutant of verdict.surviving.slice(0, maxListed)) { + lines.push(`- \`${mutant.file}:${mutant.line}\` ${mutant.mutator}`); + } + if (verdict.surviving.length > maxListed) { + lines.push(`- …and ${verdict.surviving.length - maxListed} more`); + } + } + return lines; +} + +export type RenderOptions = { + readonly title?: string; + readonly maxSurvivingListed?: number; +}; + +export function renderReport( + result: RatchetResult, + baseline: Baseline, + provenance: Provenance, + options: RenderOptions = {}, +): string { + const maxListed = options.maxSurvivingListed ?? DEFAULT_MAX_SURVIVING_LISTED; + const lines: string[] = [ + `## ${options.title ?? 'Mutation score — decision kernels'}`, + '', + `Stryker \`${provenance.strykerVersion}\` · config \`${provenance.configHash}\` · ` + + `gating **${baseline.gating ? 'on' : 'off'}** ` + + `(${baseline.stableRuns}/${baseline.requiredStableRuns} stable weekly runs)`, + '', + '| Module | Score | Baseline | Δ | Killed/Total | Surviving | Status |', + '| --- | --- | --- | --- | --- | --- | --- |', + ...result.verdicts.map(renderRow), + ]; + + for (const verdict of [...result.regressions, ...result.drifted]) { + lines.push(...renderDetail(verdict, maxListed)); + } + + if (result.failed) { + lines.push( + '', + 'Mutation ratchet failed: scores may only rise. Kill the surviving mutants listed above, ' + + 'then re-run `pnpm mutation:run` (full sweep) or `pnpm mutation:check --report ` ' + + 'against an existing Stryker report.', + ); + } else if (result.regressions.length > 0) { + lines.push( + '', + 'Scores regressed while the lane is still non-gating — no failure recorded, but the ' + + 'surviving mutants above are the tests to strengthen before gating turns on.', + ); + } + return `${lines.join('\n')}\n`; +} diff --git a/scripts/mutation/run.ts b/scripts/mutation/run.ts new file mode 100644 index 000000000..56ea27e5c --- /dev/null +++ b/scripts/mutation/run.ts @@ -0,0 +1,262 @@ +// Entrypoint for the decision-kernel mutation lane (issue #1415). +// +// pnpm mutation:run full sweep + ratchet check +// pnpm mutation:baseline full sweep, then record the scores +// pnpm mutation:check --report ratchet an existing Stryker report +// pnpm mutation:affected --base origin/main +// PR lane: mutate only the kernel +// modules the diff touches +// +// The weekly workflow runs the full sweep and writes the rendered report to the +// job summary plus an artifact; the PR lane runs the affected subset and only +// fails once the baseline has graduated to `gating: true`. + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +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 { + affectedModules, + ALL_MODULE_IDS, + isModuleId, + mutateGlobs, + type ModuleId, +} from './modules.ts'; +import { renderReport } from './report.ts'; +import { + applyRun, + emptyBaseline, + evaluateRatchet, + type Baseline, + type Provenance, +} from './ratchet.ts'; +import { mergeReports, summarizeReport, type StrykerReport } from './score.ts'; +import { + expandMutateFiles, + relatedTestFiles, + TEST_SCOPE_ENV, + writeTestScope, +} from './test-scope.ts'; + +const repoRoot = runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim(); + +const CONFIG_PATH = 'stryker.config.json'; +const BASELINE_PATH = 'mutation-baselines/decision-kernels.json'; +const DEFAULT_REPORT_PATH = '.tmp/mutation/mutation.json'; +const TEST_SCOPE_PATH = '.tmp/mutation/test-scope.json'; + +const USAGE = `Usage: pnpm mutation:run [options] + + --modules Restrict to specific kernel modules (default: all) + --affected Restrict to the modules touched between --base and HEAD + --base Base ref for --affected (default: origin/main) + --report Read an existing Stryker JSON report instead of running Stryker + --report-dir Merge every *.json Stryker report under (weekly shards) + --update Record the run into the baseline (ratchet + graduation) + --summary Also write the markdown report to + --no-run Alias for --report with the default report path +`; + +type Args = { + modules: readonly ModuleId[]; + affected: boolean; + base: string; + report: string | undefined; + reportDir: string | undefined; + update: boolean; + summary: string | undefined; +}; + +function parseModules(value: string | undefined): readonly ModuleId[] { + if (!value) return ALL_MODULE_IDS; + const ids = value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + const unknown = ids.filter((id) => !isModuleId(id)); + if (unknown.length > 0) { + throw new Error( + `Unknown mutation module(s): ${unknown.join(', ')}. Known: ${ALL_MODULE_IDS.join(', ')}`, + ); + } + return ids.filter(isModuleId); +} + +function parseMutationArgs(argv: readonly string[]): Args { + const values = parseScriptArgs(argv, USAGE, { + modules: { type: 'string' }, + affected: { type: 'boolean', default: false }, + base: { type: 'string', default: 'origin/main' }, + report: { type: 'string' }, + 'report-dir': { type: 'string' }, + update: { type: 'boolean', default: false }, + summary: { type: 'string' }, + 'no-run': { type: 'boolean', default: false }, + }); + return { + modules: parseModules(values.modules), + affected: Boolean(values.affected), + base: values.base ?? 'origin/main', + report: values.report ?? (values['no-run'] ? DEFAULT_REPORT_PATH : undefined), + reportDir: values['report-dir'], + update: Boolean(values.update), + summary: values.summary, + }; +} + +/** Short, stable content hash of the Stryker config — half of a run's provenance. */ +export function configHash(configText: string): string { + return `sha256:${crypto.createHash('sha256').update(configText).digest('hex').slice(0, 12)}`; +} + +function readProvenance(root: string = repoRoot): Provenance { + const pkgPath = path.join(root, 'node_modules/@stryker-mutator/core/package.json'); + const version = fs.existsSync(pkgPath) + ? (JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string }).version + : 'unknown'; + return { + strykerVersion: version, + configHash: configHash(fs.readFileSync(path.join(root, CONFIG_PATH), 'utf8')), + }; +} + +function readBaseline(root: string = repoRoot): Baseline { + const file = path.join(root, BASELINE_PATH); + if (!fs.existsSync(file)) return emptyBaseline(); + return JSON.parse(fs.readFileSync(file, 'utf8')) as Baseline; +} + +function writeBaseline(baseline: Baseline, root: string = repoRoot): void { + const file = path.join(root, BASELINE_PATH); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`); +} + +function changedFiles(base: string): string[] { + const result = runCmdSync('git', ['diff', '--name-only', '--merge-base', base, 'HEAD'], { + cwd: repoRoot, + allowFailure: true, + }); + return result.stdout.split('\n').filter(Boolean); +} + +// The JSON report path comes from the config (Stryker's CLI takes no nested +// reporter options), so a run always writes DEFAULT_REPORT_PATH. +async function runStryker(modules: readonly ModuleId[], reportPath: string): Promise { + const absolute = path.isAbsolute(reportPath) ? reportPath : path.join(repoRoot, reportPath); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.rmSync(absolute, { force: true }); + // The suite Stryker replays per mutant is derived from Vitest's module graph + // over the mutated files, not hand-listed; see scripts/mutation/test-scope.ts. + const globs = mutateGlobs(modules); + const scopePath = path.join(repoRoot, TEST_SCOPE_PATH); + const testFiles = relatedTestFiles(expandMutateFiles(globs, repoRoot), repoRoot); + writeTestScope(testFiles, scopePath); + process.stdout.write( + `mutation: ${modules.join(', ')} -> ${testFiles.length} related test file(s)\n`, + ); + + // Stryker's `--mutate` takes one comma-separated value, not repeated args. + const args = ['exec', 'stryker', 'run', CONFIG_PATH, '--mutate', globs.join(',')]; + const result = await runCmdStreaming('pnpm', args, { + cwd: repoRoot, + allowFailure: true, + env: { ...process.env, [TEST_SCOPE_ENV]: scopePath }, + onStdoutChunk: (chunk) => void process.stdout.write(chunk), + onStderrChunk: (chunk) => void process.stderr.write(chunk), + }); + // Stryker exits non-zero on a low score too; the ratchet — not Stryker's own + // thresholds — owns the verdict, so only a missing report is fatal here. + if (!fs.existsSync(absolute)) { + throw new Error( + `Stryker produced no report at ${reportPath} (exit ${result.exitCode}). See output above.`, + ); + } +} + +function emit(markdown: string, summaryPath: string | undefined): void { + process.stdout.write(`\n${markdown}`); + const targets = [summaryPath, process.env.GITHUB_STEP_SUMMARY].filter( + (target): target is string => Boolean(target), + ); + for (const target of targets) { + fs.mkdirSync(path.dirname(path.resolve(target)), { recursive: true }); + fs.appendFileSync(target, markdown); + } +} + +function readReport(file: string): StrykerReport { + const absolute = path.isAbsolute(file) ? file : path.join(repoRoot, file); + return JSON.parse(fs.readFileSync(absolute, 'utf8')) as StrykerReport; +} + +function readShardedReports(dir: string): StrykerReport { + const root = path.isAbsolute(dir) ? dir : path.join(repoRoot, dir); + const files = fs + .globSync('**/*.json', { cwd: root }) + .filter((file) => !file.endsWith('proposed-baseline.json') && !file.endsWith('test-scope.json')) + .map((file) => path.join(root, file)) + .sort(); + if (files.length === 0) throw new Error(`No Stryker JSON reports found under ${dir}`); + process.stdout.write(`mutation: merging ${files.length} shard report(s) from ${dir}\n`); + return mergeReports(files.map(readReport)); +} + +async function produceReport( + modules: readonly ModuleId[], + reportPath: string | undefined, +): Promise { + if (!reportPath) await runStryker(modules, DEFAULT_REPORT_PATH); + return readReport(reportPath ?? DEFAULT_REPORT_PATH); +} + +async function main(argv = process.argv.slice(2)): Promise { + const args = parseMutationArgs(argv); + const modules = args.affected ? affectedModules(changedFiles(args.base)) : args.modules; + if (modules.length === 0) { + process.stdout.write('mutation: no decision-kernel modules affected — nothing to mutate.\n'); + return 0; + } + + const report = args.reportDir + ? readShardedReports(args.reportDir) + : await produceReport(modules, args.report); + const provenance = readProvenance(); + const baseline = readBaseline(); + const scores = summarizeReport(report, modules); + const result = evaluateRatchet(scores, baseline, provenance); + + const title = args.affected + ? 'Mutation score — affected decision kernels' + : 'Mutation score — decision kernels'; + emit(renderReport(result, baseline, provenance, { title }), args.summary); + + if (args.update) { + const next = applyRun(baseline, scores, result, { + provenance, + now: new Date().toISOString(), + // Only the full sweep proves stability; an affected subset says nothing + // about the modules it skipped. + countsTowardGraduation: !args.affected && modules.length === ALL_MODULE_IDS.length, + }); + writeBaseline(next); + process.stdout.write( + `\nBaseline updated (${BASELINE_PATH}): ${next.stableRuns}/${next.requiredStableRuns} ` + + `stable runs, gating ${next.gating ? 'on' : 'off'}.\n`, + ); + } + return result.failed ? 1 : 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + main() + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + process.stderr.write(`mutation: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/mutation/score.ts b/scripts/mutation/score.ts new file mode 100644 index 000000000..514f5ee4b --- /dev/null +++ b/scripts/mutation/score.ts @@ -0,0 +1,103 @@ +// Per-module mutation scores derived from a Stryker JSON report. + +import { ALL_MODULE_IDS, moduleForFile, normalizePath, type ModuleId } from './modules.ts'; + +/** The subset of Stryker's JSON report schema this lane reads. */ +export type StrykerMutant = { + readonly mutatorName?: string; + readonly status: string; + readonly location?: { readonly start?: { readonly line?: number } }; +}; + +export type StrykerReport = { + readonly files: Record; +}; + +export type SurvivingMutant = { + readonly file: string; + readonly line: number; + readonly mutator: string; +}; + +export type ModuleScore = { + readonly module: ModuleId; + readonly score: number; + readonly killed: number; + readonly survived: number; + readonly total: number; + readonly surviving: readonly SurvivingMutant[]; +}; + +// Stryker counts a timeout as killed. `NoCoverage` counts as survived here: an +// uncovered mutant is precisely the "decorative test" signal this lane exists to +// surface. Every other status (Ignored, CompileError, RuntimeError) leaves the +// denominator, so tool-side noise cannot move the score. +const KILLED_STATUSES = new Set(['Killed', 'Timeout']); +const SURVIVED_STATUSES = new Set(['Survived', 'NoCoverage']); + +/** + * Merge sharded Stryker reports into one. The weekly sweep runs one shard per + * kernel module so no single job approaches its time budget; the ratchet still + * evaluates a single full-sweep report. + */ +export function mergeReports(reports: readonly StrykerReport[]): StrykerReport { + const files: Record = {}; + for (const report of reports) { + for (const [file, entry] of Object.entries(report.files)) { + const existing = files[file]; + if (existing) existing.mutants.push(...entry.mutants); + else files[file] = { mutants: [...entry.mutants] }; + } + } + return { files }; +} + +export function roundScore(value: number): number { + return Math.round(value * 100) / 100; +} + +function compareMutants(a: SurvivingMutant, b: SurvivingMutant): number { + return a.file.localeCompare(b.file) || a.line - b.line || a.mutator.localeCompare(b.mutator); +} + +export function summarizeReport( + report: StrykerReport, + ids: readonly ModuleId[] = ALL_MODULE_IDS, +): ModuleScore[] { + const buckets = new Map< + ModuleId, + { killed: number; survived: number; surviving: SurvivingMutant[] } + >(); + for (const id of ids) buckets.set(id, { killed: 0, survived: 0, surviving: [] }); + + for (const [file, entry] of Object.entries(report.files)) { + const id = moduleForFile(file); + if (!id) continue; + const bucket = buckets.get(id); + if (!bucket) continue; + for (const mutant of entry.mutants) { + if (KILLED_STATUSES.has(mutant.status)) { + bucket.killed += 1; + } else if (SURVIVED_STATUSES.has(mutant.status)) { + bucket.survived += 1; + bucket.surviving.push({ + file: normalizePath(file), + line: mutant.location?.start?.line ?? 0, + mutator: mutant.mutatorName ?? 'unknown', + }); + } + } + } + + return [...buckets].map(([module, bucket]) => { + const total = bucket.killed + bucket.survived; + return { + module, + score: total === 0 ? 0 : roundScore((bucket.killed / total) * 100), + killed: bucket.killed, + survived: bucket.survived, + total, + surviving: bucket.surviving.sort(compareMutants), + }; + }); +} diff --git a/scripts/mutation/test-scope.test.ts b/scripts/mutation/test-scope.test.ts new file mode 100644 index 000000000..271f9e645 --- /dev/null +++ b/scripts/mutation/test-scope.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { test } from 'node:test'; +import { expandMutateFiles, threadHostileTestFiles } from './test-scope.ts'; +import { mutateGlobs } from './modules.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); + +test('mutate globs expand to real kernel sources and exclude selector tests', () => { + const files = expandMutateFiles(mutateGlobs(), repoRoot); + assert.ok(files.includes('src/kernel/errors.ts')); + assert.ok(files.some((file) => file.startsWith('src/selectors/'))); + assert.deepEqual( + files.filter((file) => file.endsWith('.test.ts')), + [], + ); +}); + +test('the thread-hostile exclusion is derived from imports, not listed', () => { + const files = threadHostileTestFiles(repoRoot); + // Both reasons are represented: process.chdir (CLI capture harness) and a + // worker inside a worker (the node:worker_threads PNG pipeline). + assert.ok(files.includes('src/__tests__/cli-help.test.ts')); + assert.ok(files.includes('src/utils/__tests__/png-worker.test.ts')); + // A pure decision-kernel test is never excluded — that would hide survivors. + assert.ok(!files.includes('src/daemon/__tests__/ref-frame.test.ts')); + assert.ok(files.every((file) => file.endsWith('.test.ts'))); +}); diff --git a/scripts/mutation/test-scope.ts b/scripts/mutation/test-scope.ts new file mode 100644 index 000000000..c70177aa7 --- /dev/null +++ b/scripts/mutation/test-scope.ts @@ -0,0 +1,135 @@ +// Which tests the mutation lane runs, derived from Vitest's module graph. +// +// Stryker replays the configured suite for every mutant, so the suite it is +// pointed at decides whether the weekly sweep fits its 30-minute budget. Pointing +// it at the whole unit suite (487 files) makes the initial dry run alone cost +// minutes; hand-listing per-kernel test files would be a second source of truth +// that silently rots. So the scope is derived the same way `pnpm check:affected` +// derives affected tests: `vitest related` over the mutated files, i.e. Vitest's +// own static module graph. +// +// Two files are removed from whatever Vitest returns: +// - the subprocess-stub group (it spawns stubbed binaries and waits real +// subprocess/retry/poll time — out of scope by the issue's constraint, and +// thousands of mutant runs would turn it into timeout noise); +// - tests that cannot run in the thread pool Stryker's vitest runner forces: +// the in-process CLI-capture tests (`process.chdir` throws in a worker +// thread) and the `node:worker_threads` PNG pipeline tests (a worker inside +// a worker raises uncaught MessagePort errors that kill the runner); +// - anything outside `src/`: the unit suite also hosts the help-conformance +// gates from `scripts/__tests__`, which assert over the repo's own registries +// rather than over any decision kernel and own their CI job. +// +// Nothing here weakens the ratchet: a mutant only an excluded test could kill +// shows up as a survivor — visible work, never a silent pass. + +import fs from 'node:fs'; +import path from 'node:path'; +import { runCmdSync } from '../../src/utils/exec.ts'; +import { walkFiles } from '../lib/walk-files.ts'; +import { normalizePath } from './modules.ts'; + +/** Env var carrying the resolved scope file to `vitest.mutation.config.ts`. */ +export const TEST_SCOPE_ENV = 'AGENT_DEVICE_MUTATION_TEST_FILES'; +const CLI_CAPTURE_HARNESS = 'src/__tests__/cli-capture.ts'; + +/** Source modules that own a `node:worker_threads` worker. */ +function workerThreadModules(repoRoot: string): string[] { + return walkFiles( + path.join(repoRoot, 'src'), + (file) => file.endsWith('.ts') && !file.endsWith('.test.ts'), + ) + .filter((file) => fs.readFileSync(file, 'utf8').includes('node:worker_threads')) + .map((file) => path.basename(file, '.ts')); +} + +/** + * Repo-relative test files that cannot survive Stryker's thread pool, derived + * from what they import rather than listed: the chdir-using CLI capture harness + * and any module that itself starts a worker thread. + */ +export function threadHostileTestFiles(repoRoot: string): string[] { + const modules = [path.basename(CLI_CAPTURE_HARNESS, '.ts'), ...workerThreadModules(repoRoot)]; + const importsHostileModule = new RegExp(`from '[^']*/(${modules.join('|')})(\\.ts)?'`); + return walkFiles(path.join(repoRoot, 'src'), (file) => file.endsWith('.test.ts')) + .filter((file) => importsHostileModule.test(fs.readFileSync(file, 'utf8'))) + .map((file) => normalizePath(path.relative(repoRoot, file))) + .sort(); +} + +/** + * Concrete source files behind a module's mutate globs. Stryker's `!`-prefixed + * exclusions are applied here too — `fs.globSync` has no notion of them, and + * dropping them would feed selector *tests* into the scope derivation. + */ +export function expandMutateFiles(globs: readonly string[], repoRoot: string): string[] { + const negated = globs.filter((glob) => glob.startsWith('!')).map((glob) => glob.slice(1)); + const excluded = new Set(fs.globSync(negated, { cwd: repoRoot }).map(normalizePath)); + return fs + .globSync( + globs.filter((glob) => !glob.startsWith('!')), + { cwd: repoRoot }, + ) + .map(normalizePath) + .filter((file) => !excluded.has(file)) + .sort(); +} + +type VitestJsonReport = { testResults?: readonly { name: string }[] }; + +/** + * Test files Vitest considers related to `sourceFiles`, minus the groups this + * lane cannot run. `vitest related` executes them once (seconds), which also + * proves the scope is green before Stryker's dry run depends on it. + */ +export function relatedTestFiles( + sourceFiles: readonly string[], + repoRoot: string, + excluded: readonly string[] = threadHostileTestFiles(repoRoot), +): string[] { + const reportFile = path.join(repoRoot, '.tmp/mutation/related-tests.json'); + fs.mkdirSync(path.dirname(reportFile), { recursive: true }); + fs.rmSync(reportFile, { force: true }); + runCmdSync( + 'pnpm', + [ + 'exec', + 'vitest', + 'related', + ...sourceFiles, + '--project', + 'unit-core', + '--run', + '--reporter=json', + `--outputFile=${reportFile}`, + ], + { cwd: repoRoot, allowFailure: true }, + ); + if (!fs.existsSync(reportFile)) { + throw new Error( + `vitest related produced no report at ${reportFile} — cannot derive the mutation test scope.`, + ); + } + const report = JSON.parse(fs.readFileSync(reportFile, 'utf8')) as VitestJsonReport; + const excludedSet = new Set(excluded); + return [ + ...new Set( + (report.testResults ?? []) + .map((result) => normalizePath(path.relative(repoRoot, result.name))) + .filter((file) => file.startsWith('src/') && !excludedSet.has(file)), + ), + ].sort(); +} + +/** Read the scope Stryker was handed, or `undefined` for "whole unit suite". */ +export function readTestScope(): string[] | undefined { + const file = process.env[TEST_SCOPE_ENV]; + if (!file || !fs.existsSync(file)) return undefined; + const files = JSON.parse(fs.readFileSync(file, 'utf8')) as string[]; + return files.length > 0 ? files : undefined; +} + +export function writeTestScope(files: readonly string[], file: string): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(files, null, 2)}\n`); +} diff --git a/scripts/mutation/workflow.test.ts b/scripts/mutation/workflow.test.ts new file mode 100644 index 000000000..237f98beb --- /dev/null +++ b/scripts/mutation/workflow.test.ts @@ -0,0 +1,45 @@ +// The workflows' YAML cannot read the kernel registry, so these assertions keep +// the two in step: a module added to KERNEL_MODULES that no weekly shard runs +// would silently drop out of the sweep, and one no PR path filter selects would +// silently stop gating once the ratchet graduates. + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { ALL_MODULE_IDS, KERNEL_MODULES } from './modules.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); + +function workflow(name: string): string { + return fs.readFileSync(path.join(repoRoot, '.github/workflows', name), 'utf8'); +} + +test('the weekly sweep shards exactly the registry modules', () => { + const yaml = workflow('mutation-weekly.yml'); + const shards = [...yaml.matchAll(/^ {10}- (?[a-z-]+)$/gm)].map((match) => match.groups!.id); + assert.deepEqual(shards, [...ALL_MODULE_IDS]); +}); + +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, /GITHUB_STEP_SUMMARY|\$GITHUB_STEP_SUMMARY/); +}); + +test('every kernel path a PR can touch selects the affected mutation job', () => { + const paths = [...workflow('mutation-affected.yml').matchAll(/^ {6}- "(?[^"]+)"$/gm)].map( + (match) => match.groups!.glob, + ); + for (const module of KERNEL_MODULES) { + for (const owned of module.owns) { + const selected = paths.some((glob) => glob === owned || glob === `${owned}**`); + assert.ok(selected, `no path filter selects ${owned} (module ${module.id})`); + } + } + // 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/**']) { + assert.ok(paths.includes(own), `missing path filter ${own}`); + } +}); diff --git a/stryker.config.json b/stryker.config.json new file mode 100644 index 000000000..b12a35340 --- /dev/null +++ b/stryker.config.json @@ -0,0 +1,41 @@ +{ + "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "_comment": "Decision-kernel mutation lane (issue #1415). The mutate list below MUST mirror KERNEL_MODULES in scripts/mutation/modules.ts — scripts/mutation/config.test.ts asserts it. Ratchet verdicts come from scripts/mutation/run.ts, never from Stryker's own thresholds. Any edit here changes the config content hash, which invalidates every recorded baseline as non-comparable until re-recorded with `pnpm mutation:baseline`.", + "packageManager": "pnpm", + "tsconfigFile_comment": "Points at a path that does not exist on purpose. Stryker's sandbox rewrites tsconfig `extends`/`references` through the classic TypeScript API, which the repo's TypeScript 7 native package no longer exposes (`ts.parseConfigFileTextToJson is not a function`). Our tsconfig has neither extends nor references, so there is nothing to rewrite — skipping the preprocessor is exact, not a workaround for a real dependency.", + "tsconfigFile": "tsconfig.stryker-absent.json", + "plugins_comment": "Named explicitly: the default `@stryker-mutator/*` glob does not resolve under pnpm's non-flat node_modules, so plugin discovery finds no test runner.", + "plugins": ["@stryker-mutator/vitest-runner"], + "testRunner": "vitest", + "vitest": { + "configFile": "vitest.mutation.config.ts", + "related": true + }, + "reporters": ["progress", "clear-text", "json"], + "jsonReporter": { + "fileName": ".tmp/mutation/mutation.json" + }, + "htmlReporter": { + "fileName": ".tmp/mutation/mutation.html" + }, + "coverageAnalysis": "perTest", + "ignoreStatic": true, + "tempDirName": ".tmp/stryker", + "cleanTempDir": true, + "timeoutMS": 20000, + "timeoutFactor": 2, + "thresholds": { + "high": 90, + "low": 70, + "break": null + }, + "mutate": [ + "src/kernel/errors.ts", + "src/daemon/ref-frame.ts", + "src/commands/interaction/runtime/settle.ts", + "src/utils/scroll-edge-state.ts", + "src/selectors/**/*.ts", + "!src/selectors/**/*.test.ts", + "!src/selectors/__tests__/**" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts index 14db5736b..ab8ffb402 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,7 +10,7 @@ import slowTestGateReporter from './scripts/vitest-slow-test-reporter.ts'; // docs/agents/testing.md "tests must not wait real time"). Serialized below with // per-file isolation so only one such file spawns stubs at a time, the same // execution contract the pre-split android index.test.ts aggregation provided. -const SUBPROCESS_STUB_TESTS = [ +export const SUBPROCESS_STUB_TESTS = [ 'src/platforms/android/__tests__/{app-lifecycle-install,app-lifecycle-open,device-input-state,input-actions,notifications,settings}.test.ts', 'src/daemon/__tests__/runtime-hints.test.ts', 'src/platforms/apple/core/__tests__/index.test.ts', diff --git a/vitest.mutation.config.ts b/vitest.mutation.config.ts new file mode 100644 index 000000000..8f8cd7b87 --- /dev/null +++ b/vitest.mutation.config.ts @@ -0,0 +1,26 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import { readTestScope, threadHostileTestFiles } from './scripts/mutation/test-scope.ts'; +import { SUBPROCESS_STUB_TESTS } from './vitest.config.ts'; + +const repoRoot = path.dirname(fileURLToPath(import.meta.url)); + +// Test scope for the decision-kernel mutation lane (issue #1415). +// +// `scripts/mutation/run.ts` derives the per-run scope from Vitest's module graph +// (`vitest related` over the mutated files) and hands it over through +// AGENT_DEVICE_MUTATION_TEST_FILES; the fallback is the deterministic unit suite, +// which keeps `pnpm exec stryker run` usable by hand. Excluded either way: the +// subprocess-stub group and the CLI-capture tests — see +// scripts/mutation/test-scope.ts for why, and why excluding them cannot hide a +// surviving mutant. +const scope = readTestScope(); + +export default defineConfig({ + test: { + include: scope ?? ['src/**/*.test.ts'], + exclude: [...SUBPROCESS_STUB_TESTS, ...threadHostileTestFiles(repoRoot), '**/node_modules/**'], + setupFiles: ['src/__tests__/hermetic-env-setup.ts', 'src/__tests__/process-memo-setup.ts'], + }, +}); From 421e32e83725656476ed9d495f2f77737e7558c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 27 Jul 2026 14:37:34 +0000 Subject: [PATCH 02/11] chore: declare the mutation test-scope seam for production-export analysis Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .fallowrc.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.fallowrc.json b/.fallowrc.json index 2b3536abf..9b26fbf0e 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -81,6 +81,13 @@ "GatedKeysAreResolverKeys" ] }, + { + "comment": "Mutation-lane seam: readTestScope is consumed by vitest.mutation.config.ts, a tool config outside --production analysis.", + "file": "scripts/mutation/test-scope.ts", + "exports": [ + "readTestScope" + ] + }, { "comment": "Help-benchmark conformance seams: helpTopicIds feeds the topic-coverage gate and PRIVATE_AX_RECOVERY_SAMPLE feeds the skillgym suite; both consumers are test-tree files outside --production analysis.", "file": "{src/cli/parser/cli-help.ts,scripts/help-conformance-sample-outputs.mjs}", From a5ad8ad1038ef3bc11e3d7d4de001541bbfe666b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 27 Jul 2026 15:21:29 +0000 Subject: [PATCH 03/11] test: own kernel tests in the mutation registry and ship the #1430 lane envelope - restore bench:help-conformance, broken by a formatting-path edit - kernel test files select their module on PRs (registry `tests` + workflow paths), asserted to reach the kernel through the import graph - every mutation run writes the standard scheduled-lane artifact envelope - move src/utils/__tests__/errors.test.ts beside its source per the mirror rule Refs #1415, #1430 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/mutation-affected.yml | 10 +++ .github/workflows/mutation-weekly.yml | 20 +++++- docs/agents/testing.md | 9 +++ package.json | 2 +- scripts/lib/lane-envelope.ts | 69 ++++++++++++++++++ scripts/mutation/envelope.test.ts | 67 ++++++++++++++++++ scripts/mutation/modules.test.ts | 70 ++++++++++++++++++- scripts/mutation/modules.ts | 26 ++++++- scripts/mutation/run.ts | 58 ++++++++++++++- scripts/mutation/workflow.test.ts | 9 ++- .../__tests__/errors.test.ts | 2 +- 11 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 scripts/lib/lane-envelope.ts create mode 100644 scripts/mutation/envelope.test.ts rename src/{utils => kernel}/__tests__/errors.test.ts (98%) diff --git a/.github/workflows/mutation-affected.yml b/.github/workflows/mutation-affected.yml index ae24ef3df..e203c7a1b 100644 --- a/.github/workflows/mutation-affected.yml +++ b/.github/workflows/mutation-affected.yml @@ -14,10 +14,19 @@ name: Mutation Affected on: pull_request: paths: + # Kernel sources and the tests whose strength they score, mirroring + # `KERNEL_MODULES` (scripts/mutation/workflow.test.ts asserts the parity). - "src/kernel/errors.ts" + - "src/kernel/__tests__/errors.test.ts" - "src/daemon/ref-frame.ts" + - "src/daemon/__tests__/ref-frame.test.ts" + - "src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts" - "src/commands/interaction/runtime/settle.ts" + - "src/commands/interaction/runtime/settle.test.ts" + - "src/daemon/handlers/__tests__/interaction-settle.test.ts" - "src/utils/scroll-edge-state.ts" + - "src/commands/interaction/runtime/gestures.test.ts" + - "src/core/__tests__/dispatch-scroll.test.ts" - "src/selectors/**" - "scripts/mutation/**" - "stryker.config.json" @@ -59,4 +68,5 @@ jobs: path: | .tmp/mutation/mutation.json .tmp/mutation/mutation.html + .tmp/mutation/lane-envelope.json if-no-files-found: ignore diff --git a/.github/workflows/mutation-weekly.yml b/.github/workflows/mutation-weekly.yml index 5ffcb8e9d..1ab73c4ea 100644 --- a/.github/workflows/mutation-weekly.yml +++ b/.github/workflows/mutation-weekly.yml @@ -62,7 +62,9 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: mutation-shard-${{ matrix.module }} - path: .tmp/mutation/mutation.json + path: | + .tmp/mutation/mutation.json + .tmp/mutation/lane-envelope.json if-no-files-found: warn ratchet: @@ -95,6 +97,21 @@ jobs: cp mutation-baselines/decision-kernels.json .tmp/mutation/proposed-baseline.json git checkout -- mutation-baselines/decision-kernels.json + # Freshness/drift telemetry (#1430): the envelope states commit, Stryker + # version, config hash, duration and result, so a lane going dark or a tool + # bump is visible without reading these logs. + - name: Lane envelope + if: always() + run: | + { + echo '
Lane envelope (schema #1430)' + echo + echo '```json' + cat .tmp/mutation/lane-envelope.json + echo '```' + echo '
' + } >> "$GITHUB_STEP_SUMMARY" + - name: Upload mutation report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -102,5 +119,6 @@ jobs: name: mutation-decision-kernels path: | .tmp/mutation/shards + .tmp/mutation/lane-envelope.json .tmp/mutation/proposed-baseline.json if-no-files-found: warn diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 65def6b19..86b623830 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -153,6 +153,15 @@ pnpm mutation:baseline # full sweep, then record it (reviewed c - **Test scope** is derived from Vitest's module graph (`vitest related` over the mutated files), the same delegation `pnpm check:affected` uses; see `scripts/mutation/test-scope.ts` for the three groups it drops and why dropping them cannot hide a surviving mutant. +- **Kernel tests own their module too**: a diff that touches `src/kernel/__tests__/errors.test.ts` or + `src/commands/interaction/runtime/settle.test.ts` selects that kernel's mutants, because weakening a + test is exactly the change whose score must be re-measured. The registry's `tests` field lists them + and `scripts/mutation/modules.test.ts` asserts each listed file actually reaches the kernel through + the import graph. +- **Lane envelope** (`scripts/lib/lane-envelope.ts`, issue #1430): every run writes + `.tmp/mutation/lane-envelope.json` — schema version, commit, Stryker version, config hash, seed + (`null`; the input is enumerated, not randomized), duration, result, per-module scores — and both + workflows upload it, so lane freshness and tool drift are readable without parsing logs. ## Live web smoke diff --git a/package.json b/package.json index 49d2b369a..afce7d088 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "build:macos-helper": "swift build -c release --package-path apple/macos-helper", "build:all": "pnpm build:node && pnpm build:xcuitest", "ad": "node bin/agent-device.mjs", - "bench:help-conformance": "node scripts/mutation scripts/help-conformance-bench.mjs", + "bench:help-conformance": "node scripts/help-conformance-bench.mjs", "maestro:conformance": "node --experimental-strip-types --test scripts/maestro-conformance/verify.test.ts scripts/maestro-conformance/differential/run.test.ts scripts/maestro-conformance/differential/invariants.test.ts", "maestro:conformance:regenerate": "node scripts/maestro-conformance/regenerate.mjs", "maestro:conformance:differential": "node --experimental-strip-types scripts/maestro-conformance/differential/run.ts", diff --git a/scripts/lib/lane-envelope.ts b/scripts/lib/lane-envelope.ts new file mode 100644 index 000000000..8c0c24342 --- /dev/null +++ b/scripts/lib/lane-envelope.ts @@ -0,0 +1,69 @@ +// Standard artifact envelope for scheduled lanes (issue #1430). +// +// A scheduled lane can go dark or drift for weeks while PR CI stays green, so +// every artifact it uploads must say — without a human reading logs — which +// commit produced it, which tool/config version measured it, how long it took, +// and whether it passed. Freshness monitoring reads `finishedAt`/`result`; drift +// analysis reads `tool`/`configHash`. +// +// Lanes that adopt the envelope later should import this module rather than +// re-deriving the field names. + +export const LANE_ENVELOPE_SCHEMA_VERSION = 1; + +export type LaneResult = 'pass' | 'fail'; + +export type LaneEnvelope = { + schemaVersion: number; + /** Stable lane id — the metric key freshness/health jobs group by. */ + lane: string; + commit: string; + ref: string | undefined; + runUrl: string | undefined; + /** Tool versions, or content hashes where a version is not enough. */ + tool: Record; + configHash: string; + /** Only lanes with randomized input record a seed; `null` states "not applicable". */ + seed: string | null; + startedAt: string; + finishedAt: string; + durationMs: number; + result: LaneResult; + /** Lane-specific payload; kept separate so the envelope shape stays stable. */ + data: T; +}; + +export type LaneEnvelopeInput = { + lane: string; + commit: string; + tool: Record; + configHash: string; + startedAtMs: number; + result: LaneResult; + data: T; + seed?: string | null; + now?: number; +}; + +export function laneEnvelope(input: LaneEnvelopeInput): LaneEnvelope { + const finished = input.now ?? Date.now(); + const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_REF_NAME } = process.env; + return { + schemaVersion: LANE_ENVELOPE_SCHEMA_VERSION, + lane: input.lane, + commit: input.commit, + ref: GITHUB_REF_NAME, + runUrl: + GITHUB_SERVER_URL && GITHUB_REPOSITORY && GITHUB_RUN_ID + ? `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}` + : undefined, + tool: input.tool, + configHash: input.configHash, + seed: input.seed ?? null, + startedAt: new Date(input.startedAtMs).toISOString(), + finishedAt: new Date(finished).toISOString(), + durationMs: Math.max(0, finished - input.startedAtMs), + result: input.result, + data: input.data, + }; +} diff --git a/scripts/mutation/envelope.test.ts b/scripts/mutation/envelope.test.ts new file mode 100644 index 000000000..de92f51a3 --- /dev/null +++ b/scripts/mutation/envelope.test.ts @@ -0,0 +1,67 @@ +// The envelope is the only thing a downloaded scheduled-lane artifact can be +// interpreted from months later (#1430), so its required fields are asserted +// rather than assumed: a lane that stops emitting one of them makes freshness +// and tool-drift monitoring silently useless. + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { laneEnvelope, LANE_ENVELOPE_SCHEMA_VERSION } from '../lib/lane-envelope.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); + +function workflow(name: string): string { + return fs.readFileSync(path.join(repoRoot, '.github/workflows', name), 'utf8'); +} + +test('the envelope carries schema, commit, tool/config provenance, duration and result', () => { + const envelope = laneEnvelope({ + lane: 'mutation-decision-kernels', + commit: 'a'.repeat(40), + tool: { stryker: '9.6.1' }, + configHash: 'sha256:abcdef123456', + startedAtMs: 1_000, + now: 61_000, + result: 'pass', + data: { scope: 'full-sweep' }, + }); + assert.equal(envelope.schemaVersion, LANE_ENVELOPE_SCHEMA_VERSION); + assert.equal(envelope.lane, 'mutation-decision-kernels'); + assert.equal(envelope.commit, 'a'.repeat(40)); + assert.deepEqual(envelope.tool, { stryker: '9.6.1' }); + assert.equal(envelope.configHash, 'sha256:abcdef123456'); + // Mutation input is enumerated, not randomized: `null` is an explicit + // "not applicable", not a forgotten field. + assert.equal(envelope.seed, null); + assert.equal(envelope.durationMs, 60_000); + assert.equal(envelope.finishedAt, '1970-01-01T00:01:01.000Z'); + assert.equal(envelope.result, 'pass'); + assert.deepEqual(envelope.data, { scope: 'full-sweep' }); +}); + +test('a failed ratchet is recorded as a failed lane run', () => { + const envelope = laneEnvelope({ + lane: 'mutation-decision-kernels', + commit: 'b'.repeat(40), + tool: { stryker: '9.6.1' }, + configHash: 'sha256:abcdef123456', + startedAtMs: 0, + now: 0, + result: 'fail', + data: {}, + }); + assert.equal(envelope.result, 'fail'); + assert.equal(envelope.durationMs, 0); +}); + +test('both mutation lanes publish the envelope', () => { + for (const name of ['mutation-weekly.yml', 'mutation-affected.yml']) { + assert.match( + workflow(name), + /\.tmp\/mutation\/lane-envelope\.json/, + `${name} does not upload the lane envelope`, + ); + } + assert.match(workflow('mutation-weekly.yml'), /Lane envelope/); +}); diff --git a/scripts/mutation/modules.test.ts b/scripts/mutation/modules.test.ts index 708efa39e..1e6ce9196 100644 --- a/scripts/mutation/modules.test.ts +++ b/scripts/mutation/modules.test.ts @@ -15,7 +15,7 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../ test('every enumerated kernel path exists', () => { for (const module of KERNEL_MODULES) { - for (const owned of module.owns) { + for (const owned of [...module.owns, ...module.tests]) { assert.ok( fs.existsSync(path.join(repoRoot, owned)), `${module.id} owns a path that no longer exists: ${owned}`, @@ -24,6 +24,64 @@ test('every enumerated kernel path exists', () => { } }); +/** Repository-relative modules a file imports, following relative specifiers only. */ +function imports(file: string): string[] { + const text = fs.readFileSync(path.join(repoRoot, file), 'utf8'); + return [...text.matchAll(/from '(?\.[^']+)'/g)] + .map((match) => + path.posix.normalize(path.posix.join(path.posix.dirname(file), match.groups!.spec)), + ) + .filter((resolved) => fs.existsSync(path.join(repoRoot, resolved))); +} + +/** Whether `file` reaches `target` through the import graph. */ +function reaches(file: string, target: string): boolean { + const seen = new Set(); + const queue = [file]; + while (queue.length > 0) { + const current = queue.shift()!; + if (current === target) return true; + if (seen.has(current)) continue; + seen.add(current); + queue.push(...imports(current)); + } + return false; +} + +test('each owned test file exercises the module it is listed under', () => { + for (const module of KERNEL_MODULES) { + const sources = module.mutate.filter((glob) => !glob.startsWith('!') && !glob.includes('*')); + if (sources.length === 0) continue; // directory module: ownership is the prefix + for (const testFile of module.tests) { + assert.ok( + sources.some((source) => reaches(testFile, source)), + `${module.id} lists ${testFile}, which never reaches ${sources.join(', ')}`, + ); + } + } +}); + +test('a kernel source with a mirrored test file lists that test', () => { + for (const module of KERNEL_MODULES) { + for (const source of module.mutate.filter( + (glob) => !glob.startsWith('!') && !glob.includes('*'), + )) { + const dir = path.dirname(source); + const name = path.basename(source, '.ts'); + const mirrors = [`${dir}/${name}.test.ts`, `${dir}/__tests__/${name}.test.ts`].filter( + (candidate) => fs.existsSync(path.join(repoRoot, candidate)), + ); + for (const mirror of mirrors) { + assert.equal( + moduleForFile(mirror), + module.id, + `${mirror} mirrors ${source} but does not select ${module.id}`, + ); + } + } + } +}); + test('changed files map onto the module that owns them', () => { assert.equal(moduleForFile('src/kernel/errors.ts'), 'kernel-errors'); assert.equal(moduleForFile('./src/daemon/ref-frame.ts'), 'daemon-ref-frame'); @@ -31,6 +89,16 @@ test('changed files map onto the module that owns them', () => { // Tests of a kernel are owned too: a weakened test is exactly the change // whose mutation score must be re-measured. assert.equal(moduleForFile('src/selectors/__tests__/resolve.test.ts'), 'selectors'); + assert.equal(moduleForFile('src/kernel/__tests__/errors.test.ts'), 'kernel-errors'); + assert.equal(moduleForFile('src/daemon/__tests__/ref-frame.test.ts'), 'daemon-ref-frame'); + assert.equal( + moduleForFile('src/commands/interaction/runtime/settle.test.ts'), + 'interaction-settle', + ); + assert.equal( + moduleForFile('src/commands/interaction/runtime/gestures.test.ts'), + 'scroll-edge-state', + ); assert.equal(moduleForFile('src/kernel/rect.ts'), undefined); assert.equal(moduleForFile('README.md'), undefined); }); diff --git a/scripts/mutation/modules.ts b/scripts/mutation/modules.ts index 100aefdef..061586e5a 100644 --- a/scripts/mutation/modules.ts +++ b/scripts/mutation/modules.ts @@ -25,6 +25,13 @@ export type KernelModule = { readonly mutate: readonly string[]; /** Paths this module owns; a trailing `/` marks a directory prefix. */ readonly owns: readonly string[]; + /** + * Test files whose strength this module's score measures. Editing one of + * these is exactly the change the PR lane must re-measure, so they own the + * module too. `modules.test.ts` asserts each one exists and actually exercises + * the module's sources — no stale or aspirational entries. + */ + readonly tests: readonly string[]; }; export const KERNEL_MODULES: readonly KernelModule[] = [ @@ -33,30 +40,47 @@ export const KERNEL_MODULES: readonly KernelModule[] = [ label: 'Error retriability + hints', mutate: ['src/kernel/errors.ts'], owns: ['src/kernel/errors.ts'], + tests: ['src/kernel/__tests__/errors.test.ts'], }, { id: 'daemon-ref-frame', label: 'Ref-frame admission matrix (ADR 0014)', mutate: ['src/daemon/ref-frame.ts'], owns: ['src/daemon/ref-frame.ts'], + tests: [ + 'src/daemon/__tests__/ref-frame.test.ts', + 'src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts', + ], }, { id: 'interaction-settle', label: 'Interaction settle decisions', mutate: ['src/commands/interaction/runtime/settle.ts'], owns: ['src/commands/interaction/runtime/settle.ts'], + tests: [ + 'src/commands/interaction/runtime/settle.test.ts', + 'src/daemon/handlers/__tests__/interaction-settle.test.ts', + ], }, { id: 'scroll-edge-state', label: 'Scroll edge-state detection', mutate: ['src/utils/scroll-edge-state.ts'], owns: ['src/utils/scroll-edge-state.ts'], + // No mirrored test file: edge-state decisions are only reached through the + // gesture/dispatch callers, which is also why this kernel scores lowest. + tests: [ + 'src/commands/interaction/runtime/gestures.test.ts', + 'src/core/__tests__/dispatch-scroll.test.ts', + ], }, { id: 'selectors', label: 'Selector parsing + matching', mutate: ['src/selectors/**/*.ts', '!src/selectors/**/*.test.ts', '!src/selectors/__tests__/**'], + // Selector tests live under the owned directory, so the prefix covers them. owns: ['src/selectors/'], + tests: ['src/selectors/__tests__/'], }, ]; @@ -92,7 +116,7 @@ export function normalizePath(filePath: string): string { export function moduleForFile(filePath: string): ModuleId | undefined { const normalized = normalizePath(filePath); for (const module of KERNEL_MODULES) { - for (const owned of module.owns) { + for (const owned of [...module.owns, ...module.tests]) { const match = owned.endsWith('/') ? normalized.startsWith(owned) : normalized === owned; if (match) return module.id; } diff --git a/scripts/mutation/run.ts b/scripts/mutation/run.ts index 56ea27e5c..d9b483c1f 100644 --- a/scripts/mutation/run.ts +++ b/scripts/mutation/run.ts @@ -17,6 +17,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 { laneEnvelope } from '../lib/lane-envelope.ts'; import { affectedModules, ALL_MODULE_IDS, @@ -31,8 +32,9 @@ import { evaluateRatchet, type Baseline, type Provenance, + type RatchetResult, } from './ratchet.ts'; -import { mergeReports, summarizeReport, type StrykerReport } from './score.ts'; +import { mergeReports, summarizeReport, type ModuleScore, type StrykerReport } from './score.ts'; import { expandMutateFiles, relatedTestFiles, @@ -46,6 +48,8 @@ const CONFIG_PATH = 'stryker.config.json'; const BASELINE_PATH = 'mutation-baselines/decision-kernels.json'; const DEFAULT_REPORT_PATH = '.tmp/mutation/mutation.json'; const TEST_SCOPE_PATH = '.tmp/mutation/test-scope.json'; +const ENVELOPE_PATH = '.tmp/mutation/lane-envelope.json'; +const LANE_ID = 'mutation-decision-kernels'; const USAGE = `Usage: pnpm mutation:run [options] @@ -187,6 +191,52 @@ function emit(markdown: string, summaryPath: string | undefined): void { } } +/** + * Scheduled-lane artifact envelope (#1430). Without it a downloaded report cannot + * say which commit or Stryker/config version produced it, how long the sweep took, + * or whether it passed — freshness and tool-drift monitoring would have to parse + * logs. + */ +function writeEnvelope( + provenance: Provenance, + scores: readonly ModuleScore[], + baseline: Baseline, + result: RatchetResult, + options: { startedAtMs: number; affected: boolean; modules: readonly ModuleId[] }, +): void { + const envelope = laneEnvelope({ + lane: LANE_ID, + commit: runCmdSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot }).stdout.trim(), + tool: { stryker: provenance.strykerVersion }, + configHash: provenance.configHash, + startedAtMs: options.startedAtMs, + result: result.failed ? 'fail' : 'pass', + data: { + scope: options.affected ? 'affected' : 'full-sweep', + modules: options.modules.map((id) => { + const score = scores.find((entry) => entry.module === id); + return { + id, + score: score?.score ?? null, + killed: score?.killed ?? null, + total: score?.total ?? null, + status: result.verdicts.find((verdict) => verdict.module === id)?.status ?? null, + }; + }), + gating: baseline.gating, + stableRuns: baseline.stableRuns, + requiredStableRuns: baseline.requiredStableRuns, + }, + }); + const file = path.join(repoRoot, ENVELOPE_PATH); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(envelope, null, 2)}\n`); + process.stdout.write( + `\nLane envelope (${ENVELOPE_PATH}): ${envelope.result} in ` + + `${Math.round(envelope.durationMs / 1000)}s at ${envelope.commit.slice(0, 12)}.\n`, + ); +} + function readReport(file: string): StrykerReport { const absolute = path.isAbsolute(file) ? file : path.join(repoRoot, file); return JSON.parse(fs.readFileSync(absolute, 'utf8')) as StrykerReport; @@ -213,6 +263,7 @@ async function produceReport( } async function main(argv = process.argv.slice(2)): Promise { + const startedAtMs = Date.now(); const args = parseMutationArgs(argv); const modules = args.affected ? affectedModules(changedFiles(args.base)) : args.modules; if (modules.length === 0) { @@ -232,6 +283,11 @@ async function main(argv = process.argv.slice(2)): Promise { ? 'Mutation score — affected decision kernels' : 'Mutation score — decision kernels'; emit(renderReport(result, baseline, provenance, { title }), args.summary); + writeEnvelope(provenance, scores, baseline, result, { + startedAtMs, + affected: args.affected, + modules, + }); if (args.update) { const next = applyRun(baseline, scores, result, { diff --git a/scripts/mutation/workflow.test.ts b/scripts/mutation/workflow.test.ts index 237f98beb..c56da046e 100644 --- a/scripts/mutation/workflow.test.ts +++ b/scripts/mutation/workflow.test.ts @@ -32,8 +32,13 @@ test('every kernel path a PR can touch selects the affected mutation job', () => (match) => match.groups!.glob, ); for (const module of KERNEL_MODULES) { - for (const owned of module.owns) { - const selected = paths.some((glob) => glob === owned || glob === `${owned}**`); + for (const owned of [...module.owns, ...module.tests]) { + const selected = paths.some( + (glob) => + glob === owned || + glob === `${owned}**` || + (glob.endsWith('/**') && owned.startsWith(glob.slice(0, -2))), + ); assert.ok(selected, `no path filter selects ${owned} (module ${module.id})`); } } diff --git a/src/utils/__tests__/errors.test.ts b/src/kernel/__tests__/errors.test.ts similarity index 98% rename from src/utils/__tests__/errors.test.ts rename to src/kernel/__tests__/errors.test.ts index 6c1499aae..a92dfc802 100644 --- a/src/utils/__tests__/errors.test.ts +++ b/src/kernel/__tests__/errors.test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { AppError, normalizeError, toAppErrorCode } from '../../kernel/errors.ts'; +import { AppError, normalizeError, toAppErrorCode } from '../errors.ts'; test('normalizeError adds default hint and strips diagnostic metadata from details', () => { const err = new AppError('COMMAND_FAILED', 'runner failed', { From 8c1dbe79412628ef29f8d3dd3448af2ff3940216 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:30:28 +0000 Subject: [PATCH 04/11] test: derive kernel test ownership and land the scheduled-lane health monitor Ownership of a kernel's tests is now computed from the static import graph (scripts/mutation/ownership.ts) instead of a hand-listed set, so a test that reaches a kernel indirectly -- src/__tests__/daemon-error.test.ts through src/daemon.ts -- selects that kernel on a PR. The PR lane triggers on every src test and shards the derived modules, keeping wall clock at one module. The lane envelope (#1430) is now written on every exit path with the stage it reached, so a crash before any mutant runs is distinguishable from a lane that never ran. Adds the derived cadence monitor (scripts/lane-health, daily workflow): scheduled lanes are enumerated from .github/workflows/ and reported dark, failing, or never-run against their own cron cadence. --- .fallowrc.json | 7 + .github/workflows/lane-health.yml | 46 ++++++ .github/workflows/mutation-affected.yml | 105 +++++++++++--- docs/agents/testing.md | 37 +++-- package.json | 6 +- scripts/lane-health/cadence.test.ts | 138 ++++++++++++++++++ scripts/lane-health/cadence.ts | 109 +++++++++++++++ scripts/lane-health/run.ts | 179 ++++++++++++++++++++++++ scripts/mutation/envelope.test.ts | 35 +++++ scripts/mutation/modules.test.ts | 76 +--------- scripts/mutation/modules.ts | 37 ++--- scripts/mutation/ownership.test.ts | 90 ++++++++++++ scripts/mutation/ownership.ts | 136 ++++++++++++++++++ scripts/mutation/run.ts | 163 ++++++++++++++------- scripts/mutation/workflow.test.ts | 10 +- 15 files changed, 991 insertions(+), 183 deletions(-) create mode 100644 .github/workflows/lane-health.yml create mode 100644 scripts/lane-health/cadence.test.ts create mode 100644 scripts/lane-health/cadence.ts create mode 100644 scripts/lane-health/run.ts create mode 100644 scripts/mutation/ownership.test.ts create mode 100644 scripts/mutation/ownership.ts diff --git a/.fallowrc.json b/.fallowrc.json index 9b26fbf0e..8c9d51fdd 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -88,6 +88,13 @@ "readTestScope" ] }, + { + "comment": "Mutation-lane completeness gate: ownedTestFiles enumerates the whole derived ownership map, which only the assertion in ownership.test.ts consumes — a production consumer would defeat the point.", + "file": "scripts/mutation/ownership.ts", + "exports": [ + "ownedTestFiles" + ] + }, { "comment": "Help-benchmark conformance seams: helpTopicIds feeds the topic-coverage gate and PRIVATE_AX_RECOVERY_SAMPLE feeds the skillgym suite; both consumers are test-tree files outside --production analysis.", "file": "{src/cli/parser/cli-help.ts,scripts/help-conformance-sample-outputs.mjs}", diff --git a/.github/workflows/lane-health.yml b/.github/workflows/lane-health.yml new file mode 100644 index 000000000..b91500692 --- /dev/null +++ b/.github/workflows/lane-health.yml @@ -0,0 +1,46 @@ +name: Lane Health + +# Watches the watchers (issue #1430): every `schedule:`-triggered workflow is +# checked for freshness and for two consecutive failures. Nightly and weekly lanes +# can go dark for weeks — a disabled schedule, a 60-day inactivity suspension, a +# rename — while PR CI stays green, so no other job can notice. +# +# The lane list is derived from .github/workflows/, not hand-maintained, so a lane +# added in the same PR as its first run is already watched. + +on: + schedule: + # Daily 06:00 UTC — after the nightlies and the weekly sweep have reported. + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + contents: read + actions: read + issues: write + +concurrency: + group: ci-${{ github.workflow }} + cancel-in-progress: true + +jobs: + lane-health: + name: Scheduled lane freshness + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Self-test + run: pnpm lane-health:test + + # Reports to the job summary and pings one tracking issue; it never fails + # the build, because a second red lane nobody watches is not an alert. + - name: Check scheduled lane freshness + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: pnpm lane-health --alert diff --git a/.github/workflows/mutation-affected.yml b/.github/workflows/mutation-affected.yml index e203c7a1b..a7fe8f99f 100644 --- a/.github/workflows/mutation-affected.yml +++ b/.github/workflows/mutation-affected.yml @@ -8,27 +8,26 @@ name: Mutation Affected # sweeps (mutation-weekly.yml). Until then it is a report with the same numbers, # so the gate's first failing day is not also its first running day. # -# Scope is the AFFECTED modules only (changed file in the kernel registry -> its -# mutants). The weekly run stays the full sweep. +# Scope is the AFFECTED modules only, and affectedness is DERIVED from the import +# graph (scripts/mutation/ownership.ts): a kernel source, or any test that reaches +# one. Reaching a kernel is a superset of killing its mutants, so the `select` job +# frequently returns several modules — sharded like the weekly sweep so the PR's +# wall clock is one module, not their sum. The weekly run stays the full sweep. on: pull_request: paths: - # Kernel sources and the tests whose strength they score, mirroring - # `KERNEL_MODULES` (scripts/mutation/workflow.test.ts asserts the parity). + # Kernel sources, every src test (ownership is derived, so any test may own + # a kernel — `select` decides, not this filter), and the lane's own tooling. + # scripts/mutation/workflow.test.ts asserts this covers the registry. - "src/kernel/errors.ts" - - "src/kernel/__tests__/errors.test.ts" - "src/daemon/ref-frame.ts" - - "src/daemon/__tests__/ref-frame.test.ts" - - "src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts" - "src/commands/interaction/runtime/settle.ts" - - "src/commands/interaction/runtime/settle.test.ts" - - "src/daemon/handlers/__tests__/interaction-settle.test.ts" - "src/utils/scroll-edge-state.ts" - - "src/commands/interaction/runtime/gestures.test.ts" - - "src/core/__tests__/dispatch-scroll.test.ts" - "src/selectors/**" + - "src/**/*.test.ts" - "scripts/mutation/**" + - "scripts/lib/**" - "stryker.config.json" - "mutation-baselines/**" - ".github/workflows/mutation-affected.yml" @@ -41,10 +40,12 @@ concurrency: cancel-in-progress: true jobs: - mutation-affected: - name: Affected decision-kernel mutants + select: + name: Select affected kernels runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 10 + outputs: + modules: ${{ steps.select.outputs.modules }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -57,16 +58,84 @@ jobs: - name: Ratchet self-test run: pnpm mutation:test - - name: Run affected mutants - run: pnpm mutation:affected --base "origin/${{ github.event.pull_request.base.ref }}" + - id: select + name: Derive affected modules + 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 decision kernels: $modules" >> "$GITHUB_STEP_SUMMARY" - - name: Upload mutation report + mutants: + name: Mutants (${{ matrix.module }}) + needs: select + if: needs.select.outputs.modules != '[]' + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + module: ${{ fromJSON(needs.select.outputs.modules) }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Run mutants for ${{ matrix.module }} + run: pnpm mutation:run --modules ${{ matrix.module }} + + - name: Upload shard report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: mutation-affected + name: mutation-affected-shard-${{ matrix.module }} path: | .tmp/mutation/mutation.json .tmp/mutation/mutation.html .tmp/mutation/lane-envelope.json + if-no-files-found: warn + + ratchet: + name: Affected decision-kernel mutants + needs: [select, mutants] + if: always() && needs.select.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Download shard reports + if: needs.select.outputs.modules != '[]' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: mutation-affected-shard-* + path: .tmp/mutation/shards + + # No shards means no affected kernel: run.ts reports "nothing to mutate" + # and still writes the envelope, so the lane is never silently absent. + - name: Ratchet the affected modules + run: | + if [ -d .tmp/mutation/shards ]; then + pnpm mutation:check --report-dir .tmp/mutation/shards --affected \ + --base "origin/${{ github.event.pull_request.base.ref }}" + else + pnpm mutation:affected --base "origin/${{ github.event.pull_request.base.ref }}" + fi + + - name: Upload mutation report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: mutation-affected + path: | + .tmp/mutation/shards + .tmp/mutation/lane-envelope.json if-no-files-found: ignore diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 86b623830..ad6af0753 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -141,7 +141,8 @@ pnpm mutation:baseline # full sweep, then record it (reviewed c reported as a job summary plus an artifact. It never commits: the proposed baseline rides in the artifact, and applying it is a reviewed `pnpm mutation:baseline` commit, so a score cannot lower itself. -- **PR lane** (`.github/workflows/mutation-affected.yml`) mutates only the modules the diff touches. +- **PR lane** (`.github/workflows/mutation-affected.yml`) derives the affected modules + (`--list-affected`), shards one job per module, and merges them into one verdict. - **Ratchet**: scores may only rise. `mutation-baselines/decision-kernels.json` records the high-water score per module plus the Stryker version and config content hash that produced it, so a score change caused by a tool/config change is reported as provenance drift, never as a @@ -153,15 +154,35 @@ pnpm mutation:baseline # full sweep, then record it (reviewed c - **Test scope** is derived from Vitest's module graph (`vitest related` over the mutated files), the same delegation `pnpm check:affected` uses; see `scripts/mutation/test-scope.ts` for the three groups it drops and why dropping them cannot hide a surviving mutant. -- **Kernel tests own their module too**: a diff that touches `src/kernel/__tests__/errors.test.ts` or - `src/commands/interaction/runtime/settle.test.ts` selects that kernel's mutants, because weakening a - test is exactly the change whose score must be re-measured. The registry's `tests` field lists them - and `scripts/mutation/modules.test.ts` asserts each listed file actually reaches the kernel through - the import graph. +- **Test ownership is derived, never listed** (`scripts/mutation/ownership.ts`): a test owns every + kernel its imports reach, so `src/__tests__/daemon-error.test.ts` selects `kernel-errors` through + `src/daemon.ts` without naming it. A listed set of test files would silently omit exactly those + indirect tests and rot as tests are added — weakening one would skip the ratchet. Reaching a kernel + is a superset of killing its mutants, so the PR lane over-selects on purpose and shards the + selected modules; a false positive costs runner minutes, a false negative costs the gate. Non-kernel + *sources* are not owned: they can only move a score through those tests, and the weekly sweep + re-measures the whole surface. - **Lane envelope** (`scripts/lib/lane-envelope.ts`, issue #1430): every run writes `.tmp/mutation/lane-envelope.json` — schema version, commit, Stryker version, config hash, seed - (`null`; the input is enumerated, not randomized), duration, result, per-module scores — and both - workflows upload it, so lane freshness and tool drift are readable without parsing logs. + (`null`; the input is enumerated, not randomized), duration, result, stage, per-module scores — and + both workflows upload it, so lane freshness and tool drift are readable without parsing logs. It is + written on every exit path, including a crash before any mutant runs: an absent envelope would be + indistinguishable from a lane that never ran. + +## Scheduled lane health (#1430) + +Nightly and weekly lanes can stop running — a disabled schedule, a rename, GitHub's 60-day inactivity +suspension — while PR CI stays green, so nothing fails and nobody notices. `pnpm lane-health` +(`.github/workflows/lane-health.yml`, daily) derives the lane list from `.github/workflows/` rather +than a hand-maintained list, reads each lane's scheduled runs through `gh`, and reports: + +- `dark` — two consecutive cadences missed (the cadence comes from the lane's own `cron`); +- `failing` — two consecutive scheduled runs concluded non-success (cancellations excluded); +- `never-run` — declared longer than two cadences ago and still never fired. + +One miss is a runner hiccup; two is the lane not doing its job. Unhealthy lanes ping a single tracking +issue (`--alert`) instead of opening one per run, and the job never fails — a second red lane nobody +watches is not an alert. `pnpm lane-health:test` covers the model. ## Live web smoke diff --git a/package.json b/package.json index afce7d088..e752e7e36 100644 --- a/package.json +++ b/package.json @@ -113,9 +113,11 @@ "mutation:check": "node --experimental-strip-types scripts/mutation/run.ts --no-run", "mutation:affected": "node --experimental-strip-types scripts/mutation/run.ts --affected", "mutation:test": "node --experimental-strip-types --test scripts/mutation/*.test.ts", + "lane-health": "node --experimental-strip-types scripts/lane-health/run.ts", + "lane-health:test": "node --experimental-strip-types --test scripts/lane-health/*.test.ts", "lint": "oxlint . --deny-warnings", - "format": "node ./node_modules/oxfmt/bin/oxfmt --write src test skills scripts/mutation scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", - "format:check": "node ./node_modules/oxfmt/bin/oxfmt --check src test skills scripts/mutation scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", + "format": "node ./node_modules/oxfmt/bin/oxfmt --write src test skills scripts/mutation scripts/lane-health scripts/lib scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", + "format:check": "node ./node_modules/oxfmt/bin/oxfmt --check src test skills scripts/mutation scripts/lane-health scripts/lib scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", "fallow": "fallow audit --base origin/main", "fallow:all": "fallow --summary", "fallow:baseline": "(fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary || true) && (fallow health --save-baseline fallow-baselines/health.json --summary || true)", diff --git a/scripts/lane-health/cadence.test.ts b/scripts/lane-health/cadence.test.ts new file mode 100644 index 000000000..bb8f86c42 --- /dev/null +++ b/scripts/lane-health/cadence.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + cadenceHours, + evaluateLane, + laneCadenceHours, + renderLaneHealth, + unhealthy, +} from './cadence.ts'; +import { observe, scheduledLanes } from './run.ts'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const HOUR = 3600_000; +const now = Date.parse('2026-02-01T00:00:00Z'); + +test('cadence comes from the coarsest set cron field', () => { + assert.equal(cadenceHours('0 5 * * 0'), 24 * 7); + assert.equal(cadenceHours('0 3 * * *'), 24); + assert.equal(cadenceHours('*/15 * * * *'), 1); + assert.equal(cadenceHours('0 0 1 * *'), 24 * 30); + assert.equal(laneCadenceHours(['0 5 * * 0', '0 3 * * *']), 24); +}); + +test('the scheduled lane list is derived from the workflow directory', () => { + const lanes = scheduledLanes(path.join(repoRoot, '.github/workflows')); + // The lane this PR adds must be watched by the monitor without being listed. + assert.deepEqual(lanes.get('mutation-weekly.yml'), ['0 5 * * 0']); + assert.ok(lanes.size >= 3, `expected several scheduled lanes, found ${lanes.size}`); + assert.ok(!lanes.has('ci.yml'), 'PR CI is not a scheduled lane'); +}); + +test('a lane that missed two cadences is dark', () => { + const weekly = { lane: 'mutation-weekly.yml', cadenceHours: 24 * 7 }; + const fresh = evaluateLane( + { + ...weekly, + lastRunAt: new Date(now - 24 * HOUR).toISOString(), + lastSuccessAt: undefined, + recentConclusions: ['success'], + declaredAt: undefined, + }, + now, + ); + assert.equal(fresh.status, 'fresh'); + const dark = evaluateLane( + { + ...weekly, + lastRunAt: new Date(now - 15 * 24 * HOUR).toISOString(), + lastSuccessAt: undefined, + recentConclusions: ['success'], + declaredAt: undefined, + }, + now, + ); + assert.equal(dark.status, 'dark'); + assert.match(dark.detail, /15\.0d ago/); + // One missed cadence is a hiccup, not an alert. + assert.equal( + evaluateLane( + { + ...weekly, + lastRunAt: new Date(now - 9 * 24 * HOUR).toISOString(), + lastSuccessAt: undefined, + recentConclusions: ['success'], + declaredAt: undefined, + }, + now, + ).status, + 'fresh', + ); +}); + +test('two consecutive non-success conclusions are failing; cancellations do not count', () => { + const base = { + lane: 'perf-nightly.yml', + cadenceHours: 24, + lastRunAt: new Date(now - HOUR).toISOString(), + lastSuccessAt: undefined, + declaredAt: undefined, + }; + assert.equal( + evaluateLane({ ...base, recentConclusions: ['failure', 'success'] }, now).status, + 'fresh', + ); + assert.equal( + evaluateLane({ ...base, recentConclusions: ['failure', 'cancelled', 'timed_out'] }, now).status, + 'failing', + ); + assert.equal(evaluateLane({ ...base, recentConclusions: [] }, now).status, 'fresh'); +}); + +test('a lane that never ran is reported once it is overdue, not while it is new', () => { + const never = { + lane: 'mutation-weekly.yml', + cadenceHours: 24 * 7, + lastRunAt: undefined, + lastSuccessAt: undefined, + recentConclusions: [], + }; + // Merged an hour ago: it has legitimately not fired yet. + assert.equal( + evaluateLane({ ...never, declaredAt: new Date(now - HOUR).toISOString() }, now).status, + 'fresh', + ); + const overdue = evaluateLane( + { ...never, declaredAt: new Date(now - 30 * 24 * HOUR).toISOString() }, + now, + ); + assert.equal(overdue.status, 'never-run'); + assert.equal(unhealthy([overdue]).length, 1); +}); + +test('observations map API runs onto the model, ignoring in-flight runs', () => { + const observation = observe( + 'mutation-weekly.yml', + ['0 5 * * 0'], + [ + { conclusion: null, createdAt: '2026-02-01T05:00:00Z', status: 'in_progress' }, + { conclusion: 'failure', createdAt: '2026-01-25T05:00:00Z', status: 'completed' }, + { conclusion: 'success', createdAt: '2026-01-18T05:00:00Z', status: 'completed' }, + ], + ); + assert.equal(observation.lastRunAt, '2026-02-01T05:00:00Z'); + assert.equal(observation.lastSuccessAt, '2026-01-18T05:00:00Z'); + assert.deepEqual(observation.recentConclusions, ['failure', 'success']); + assert.equal(observation.cadenceHours, 24 * 7); +}); + +test('the rendered report names the unhealthy lanes', () => { + const markdown = renderLaneHealth([ + { lane: 'mutation-weekly.yml', status: 'dark', detail: 'last scheduled run 20.0d ago' }, + { lane: 'perf-nightly.yml', status: 'fresh', detail: 'last scheduled run now' }, + ]); + assert.match(markdown, /1 of 2 scheduled lane\(s\) need attention: mutation-weekly\.yml/); + assert.match(markdown, /\| `mutation-weekly\.yml` \| dark \|/); +}); diff --git a/scripts/lane-health/cadence.ts b/scripts/lane-health/cadence.ts new file mode 100644 index 000000000..ee4668fba --- /dev/null +++ b/scripts/lane-health/cadence.ts @@ -0,0 +1,109 @@ +// Cadence + staleness model for scheduled lanes (issue #1430). +// +// A scheduled lane can stop running — a disabled schedule, a workflow renamed +// out of the trigger, a repo-wide 60-day schedule suspension — while PR CI stays +// green, so nothing fails and nobody notices for weeks. The observatory watches +// the watchers: derive the expected cadence from each workflow's own `cron`, then +// compare it against the last observed run. +// +// Pure model, no IO: `run.ts` supplies the observations from the GitHub API. + +/** Hours between fires, derived from a cron expression's coarsest set field. */ +export function cadenceHours(cron: string): number { + const [minute, hour, dayOfMonth, month, dayOfWeek] = cron.trim().split(/\s+/); + if (!minute || !hour) throw new Error(`Unparseable cron: ${cron}`); + const set = (field: string | undefined): boolean => Boolean(field) && field !== '*'; + if (set(month)) return 24 * 365; + if (set(dayOfMonth)) return 24 * 30; + if (set(dayOfWeek)) return 24 * 7; + if (set(hour)) return 24; + return 1; +} + +/** The cadence of a lane that declares several crons: its most frequent one. */ +export function laneCadenceHours(crons: readonly string[]): number { + const hours = crons.map(cadenceHours); + if (hours.length === 0) throw new Error('A scheduled lane must declare at least one cron'); + return Math.min(...hours); +} + +export type LaneObservation = { + /** Workflow file name, e.g. `mutation-weekly.yml`. */ + readonly lane: string; + readonly cadenceHours: number; + /** ISO timestamp of the most recent scheduled run, or undefined if never. */ + readonly lastRunAt: string | undefined; + /** ISO timestamp of the most recent successful scheduled run. */ + readonly lastSuccessAt: string | undefined; + /** Conclusions of recent scheduled runs, newest first. */ + readonly recentConclusions: readonly string[]; + /** + * When the workflow file was last committed. A lane merged an hour ago has + * legitimately never run; only one that has been declared for two cadences + * without firing is dark. + */ + readonly declaredAt: string | undefined; +}; + +export type LaneVerdict = { + readonly lane: string; + /** `fresh` | `dark` (missed two cadences) | `failing` (two consecutive) | `never-run`. */ + readonly status: 'fresh' | 'dark' | 'failing' | 'never-run'; + readonly detail: string; +}; + +/** + * A lane is unhealthy when it misses two consecutive cadences (dark) or fails + * two consecutive cadences (failing) — one miss is a runner hiccup, two is the + * lane not doing its job. Both are the alert condition #1430 specifies. + */ +export function evaluateLane(observation: LaneObservation, now: number): LaneVerdict { + const { lane, cadenceHours: cadence, lastRunAt, recentConclusions, declaredAt } = observation; + const window = cadence * 2 * 3600_000; + if (!lastRunAt) { + const declaredMs = declaredAt ? now - Date.parse(declaredAt) : Number.POSITIVE_INFINITY; + if (declaredMs <= window) { + return { lane, status: 'fresh', detail: 'declared recently, awaiting its first run' }; + } + return { lane, status: 'never-run', detail: 'no scheduled run recorded since it was declared' }; + } + const ageMs = now - Date.parse(lastRunAt); + if (ageMs > window) { + const days = (ageMs / 86_400_000).toFixed(1); + return { + lane, + status: 'dark', + detail: `last scheduled run ${days}d ago, cadence ${cadence}h (two cadences missed)`, + }; + } + const failures = recentConclusions.filter((conclusion) => conclusion !== 'cancelled'); + if ( + failures.length >= 2 && + failures.slice(0, 2).every((conclusion) => conclusion !== 'success') + ) { + return { + lane, + status: 'failing', + detail: `two consecutive scheduled runs concluded ${failures.slice(0, 2).join(', ')}`, + }; + } + return { lane, status: 'fresh', detail: `last scheduled run ${lastRunAt}` }; +} + +export function unhealthy(verdicts: readonly LaneVerdict[]): LaneVerdict[] { + return verdicts.filter((verdict) => verdict.status !== 'fresh'); +} + +export function renderLaneHealth(verdicts: readonly LaneVerdict[]): string { + const rows = [...verdicts] + .sort((a, b) => a.lane.localeCompare(b.lane)) + .map((verdict) => `| \`${verdict.lane}\` | ${verdict.status} | ${verdict.detail} |`) + .join('\n'); + const bad = unhealthy(verdicts); + const headline = + bad.length === 0 + ? `All ${verdicts.length} scheduled lane(s) fresh.` + : `${bad.length} of ${verdicts.length} scheduled lane(s) need attention: ` + + bad.map((verdict) => verdict.lane).join(', '); + return `## Scheduled lane health\n\n${headline}\n\n| Lane | Status | Detail |\n| --- | --- | --- |\n${rows}\n`; +} diff --git a/scripts/lane-health/run.ts b/scripts/lane-health/run.ts new file mode 100644 index 000000000..c36a3e020 --- /dev/null +++ b/scripts/lane-health/run.ts @@ -0,0 +1,179 @@ +// Scheduled-lane health check (issue #1430): does every `schedule:`-triggered +// workflow still run, and still pass? +// +// pnpm lane-health report to stdout + job summary +// pnpm lane-health --alert also open/ping the tracking issue +// +// The lane list is DERIVED from .github/workflows/ — a hand-maintained list would +// omit exactly the new lane nobody is watching yet. Run observations come from +// the GitHub API through `gh`, so this needs no elevated scope beyond the token +// the workflow already has. + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { runCmdSync } from '../../src/utils/exec.ts'; +import { parseScriptArgs } from '../lib/cli-args.ts'; +import { + evaluateLane, + laneCadenceHours, + renderLaneHealth, + unhealthy, + type LaneObservation, + type LaneVerdict, +} from './cadence.ts'; + +const repoRoot = runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim(); +const WORKFLOW_DIR = '.github/workflows'; +const ALERT_TITLE = 'Scheduled lane health: a lane is dark or failing'; +const ALERT_LABEL = 'ci'; + +const USAGE = `Usage: pnpm lane-health [options] + + --alert Open or ping the tracking issue when a lane is unhealthy + --repo Repository to query (default: the checkout's origin) +`; + +/** + * The cron expressions each workflow declares, keyed by file name. Parsed by + * shape rather than with a YAML dependency: `schedule:` blocks are a flat list of + * `- cron: "..."` lines, and `scheduledLanes` is asserted against the real + * workflow directory in the tests. + */ +export function scheduledLanes(dir: string): Map { + const lanes = new Map(); + for (const file of fs.readdirSync(dir).sort()) { + if (!file.endsWith('.yml') && !file.endsWith('.yaml')) continue; + const yaml = fs.readFileSync(path.join(dir, file), 'utf8'); + const crons = [...yaml.matchAll(/^\s*-\s*cron:\s*["']?(?[^"'\n]+?)["']?\s*$/gm)].map( + (match) => match.groups!.cron, + ); + if (crons.length > 0) lanes.set(file, crons); + } + return lanes; +} + +type WorkflowRun = { conclusion: string | null; createdAt: string; status: string }; + +function gh(args: readonly string[]): string { + return runCmdSync('gh', [...args], { cwd: repoRoot, allowFailure: true }).stdout; +} + +/** Recent scheduled runs of one workflow, newest first. */ +function recentRuns(lane: string, repo: string): WorkflowRun[] { + const raw = gh([ + 'run', + 'list', + '--workflow', + lane, + '--event', + 'schedule', + '--limit', + '10', + '--json', + 'conclusion,createdAt,status', + '--repo', + repo, + ]); + if (!raw.trim()) return []; + return JSON.parse(raw) as WorkflowRun[]; +} + +export function observe( + lane: string, + crons: readonly string[], + runs: readonly WorkflowRun[], + declaredAt?: string, +): LaneObservation { + const finished = runs.filter((run) => run.status === 'completed'); + return { + lane, + cadenceHours: laneCadenceHours(crons), + lastRunAt: runs[0]?.createdAt, + lastSuccessAt: finished.find((run) => run.conclusion === 'success')?.createdAt, + recentConclusions: finished.map((run) => run.conclusion ?? 'unknown'), + declaredAt, + }; +} + +/** When the workflow file last changed — the earliest its schedule could fire. */ +function declaredAt(lane: string): string | undefined { + const iso = runCmdSync('git', ['log', '-1', '--format=%cI', '--', `${WORKFLOW_DIR}/${lane}`], { + cwd: repoRoot, + allowFailure: true, + }).stdout.trim(); + return iso || undefined; +} + +function appendSummary(markdown: string): void { + const summary = process.env.GITHUB_STEP_SUMMARY; + if (summary) fs.appendFileSync(summary, markdown); + process.stdout.write(markdown); +} + +/** One tracking issue, pinged on every unhealthy sweep — never a new issue per run. */ +function alert(verdicts: readonly LaneVerdict[], repo: string): void { + const body = renderLaneHealth(verdicts); + const existing = gh([ + 'issue', + 'list', + '--search', + `${ALERT_TITLE} in:title`, + '--state', + 'open', + '--json', + 'number', + '--repo', + repo, + ]); + const open = (existing.trim() ? (JSON.parse(existing) as { number: number }[]) : [])[0]; + if (open) { + gh(['issue', 'comment', String(open.number), '--body', body, '--repo', repo]); + process.stdout.write(`lane-health: pinged issue #${open.number}\n`); + return; + } + gh([ + 'issue', + 'create', + '--title', + ALERT_TITLE, + '--body', + body, + '--label', + ALERT_LABEL, + '--repo', + repo, + ]); + process.stdout.write('lane-health: opened the tracking issue\n'); +} + +function originRepo(): string { + const url = runCmdSync('git', ['remote', 'get-url', 'origin'], { + cwd: repoRoot, + allowFailure: true, + }).stdout.trim(); + return url.replace(/^.*[:/]([^/]+\/[^/]+?)(?:\.git)?$/, '$1'); +} + +function main(argv = process.argv.slice(2)): number { + const values = parseScriptArgs(argv, USAGE, { + alert: { type: 'boolean', default: false }, + repo: { type: 'string' }, + }); + const repo = values.repo ?? process.env.GITHUB_REPOSITORY ?? originRepo(); + const lanes = scheduledLanes(path.join(repoRoot, WORKFLOW_DIR)); + const now = Date.now(); + const verdicts = [...lanes].map(([lane, crons]) => + evaluateLane(observe(lane, crons, recentRuns(lane, repo), declaredAt(lane)), now), + ); + appendSummary(renderLaneHealth(verdicts)); + const bad = unhealthy(verdicts); + if (bad.length > 0 && values.alert) alert(bad, repo); + // Reporting, not gating: a dark lane is tracked in the issue, and failing this + // job would only add a second red lane nobody is watching. + return 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + process.exitCode = main(); +} diff --git a/scripts/mutation/envelope.test.ts b/scripts/mutation/envelope.test.ts index de92f51a3..8dcdb7019 100644 --- a/scripts/mutation/envelope.test.ts +++ b/scripts/mutation/envelope.test.ts @@ -7,6 +7,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; +import { runCmdSync } from '../../src/utils/exec.ts'; import { laneEnvelope, LANE_ENVELOPE_SCHEMA_VERSION } from '../lib/lane-envelope.ts'; const repoRoot = path.resolve(import.meta.dirname, '../..'); @@ -55,6 +56,40 @@ test('a failed ratchet is recorded as a failed lane run', () => { assert.equal(envelope.durationMs, 0); }); +// A lane that crashes before it can measure anything is the dark-lane case: an +// absent envelope is indistinguishable from a lane that never ran, so the run +// script must emit one from its failure path too. +test('a crashed run still writes an envelope naming the stage it died in', () => { + const envelopePath = path.join(repoRoot, '.tmp/mutation/lane-envelope.json'); + fs.rmSync(envelopePath, { force: true }); + const result = runCmdSync( + 'node', + [ + '--experimental-strip-types', + 'scripts/mutation/run.ts', + '--report', + '.tmp/mutation/absent-report.json', + '--modules', + 'kernel-errors', + ], + { cwd: repoRoot, allowFailure: true }, + ); + assert.notEqual(result.exitCode, 0, 'a missing report must fail the run'); + assert.ok(fs.existsSync(envelopePath), 'no envelope written for a crashed run'); + const envelope = JSON.parse(fs.readFileSync(envelopePath, 'utf8')) as { + result: string; + tool: Record; + configHash: string; + data: { stage: string; error: string | null }; + }; + assert.equal(envelope.result, 'fail'); + assert.equal(envelope.data.stage, 'report'); + assert.match(envelope.data.error ?? '', /absent-report\.json/); + // Provenance is read before the work, so a crash still reports its tool/config. + assert.ok(envelope.tool.stryker); + assert.match(envelope.configHash, /^sha256:/); +}); + test('both mutation lanes publish the envelope', () => { for (const name of ['mutation-weekly.yml', 'mutation-affected.yml']) { assert.match( diff --git a/scripts/mutation/modules.test.ts b/scripts/mutation/modules.test.ts index 1e6ce9196..05e6062a6 100644 --- a/scripts/mutation/modules.test.ts +++ b/scripts/mutation/modules.test.ts @@ -15,7 +15,7 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../ test('every enumerated kernel path exists', () => { for (const module of KERNEL_MODULES) { - for (const owned of [...module.owns, ...module.tests]) { + for (const owned of module.owns) { assert.ok( fs.existsSync(path.join(repoRoot, owned)), `${module.id} owns a path that no longer exists: ${owned}`, @@ -24,81 +24,13 @@ test('every enumerated kernel path exists', () => { } }); -/** Repository-relative modules a file imports, following relative specifiers only. */ -function imports(file: string): string[] { - const text = fs.readFileSync(path.join(repoRoot, file), 'utf8'); - return [...text.matchAll(/from '(?\.[^']+)'/g)] - .map((match) => - path.posix.normalize(path.posix.join(path.posix.dirname(file), match.groups!.spec)), - ) - .filter((resolved) => fs.existsSync(path.join(repoRoot, resolved))); -} - -/** Whether `file` reaches `target` through the import graph. */ -function reaches(file: string, target: string): boolean { - const seen = new Set(); - const queue = [file]; - while (queue.length > 0) { - const current = queue.shift()!; - if (current === target) return true; - if (seen.has(current)) continue; - seen.add(current); - queue.push(...imports(current)); - } - return false; -} - -test('each owned test file exercises the module it is listed under', () => { - for (const module of KERNEL_MODULES) { - const sources = module.mutate.filter((glob) => !glob.startsWith('!') && !glob.includes('*')); - if (sources.length === 0) continue; // directory module: ownership is the prefix - for (const testFile of module.tests) { - assert.ok( - sources.some((source) => reaches(testFile, source)), - `${module.id} lists ${testFile}, which never reaches ${sources.join(', ')}`, - ); - } - } -}); - -test('a kernel source with a mirrored test file lists that test', () => { - for (const module of KERNEL_MODULES) { - for (const source of module.mutate.filter( - (glob) => !glob.startsWith('!') && !glob.includes('*'), - )) { - const dir = path.dirname(source); - const name = path.basename(source, '.ts'); - const mirrors = [`${dir}/${name}.test.ts`, `${dir}/__tests__/${name}.test.ts`].filter( - (candidate) => fs.existsSync(path.join(repoRoot, candidate)), - ); - for (const mirror of mirrors) { - assert.equal( - moduleForFile(mirror), - module.id, - `${mirror} mirrors ${source} but does not select ${module.id}`, - ); - } - } - } -}); - -test('changed files map onto the module that owns them', () => { +test('changed sources map onto the module that owns them', () => { assert.equal(moduleForFile('src/kernel/errors.ts'), 'kernel-errors'); assert.equal(moduleForFile('./src/daemon/ref-frame.ts'), 'daemon-ref-frame'); assert.equal(moduleForFile('src/selectors/parse.ts'), 'selectors'); - // Tests of a kernel are owned too: a weakened test is exactly the change - // whose mutation score must be re-measured. + // Selector tests live under the owned prefix; every other kernel's tests are + // attributed by ownership.ts, not by this path match. assert.equal(moduleForFile('src/selectors/__tests__/resolve.test.ts'), 'selectors'); - assert.equal(moduleForFile('src/kernel/__tests__/errors.test.ts'), 'kernel-errors'); - assert.equal(moduleForFile('src/daemon/__tests__/ref-frame.test.ts'), 'daemon-ref-frame'); - assert.equal( - moduleForFile('src/commands/interaction/runtime/settle.test.ts'), - 'interaction-settle', - ); - assert.equal( - moduleForFile('src/commands/interaction/runtime/gestures.test.ts'), - 'scroll-edge-state', - ); assert.equal(moduleForFile('src/kernel/rect.ts'), undefined); assert.equal(moduleForFile('README.md'), undefined); }); diff --git a/scripts/mutation/modules.ts b/scripts/mutation/modules.ts index 061586e5a..fa127eba8 100644 --- a/scripts/mutation/modules.ts +++ b/scripts/mutation/modules.ts @@ -23,15 +23,12 @@ export type KernelModule = { readonly label: string; /** Globs handed to Stryker's `mutate`. */ readonly mutate: readonly string[]; - /** Paths this module owns; a trailing `/` marks a directory prefix. */ - readonly owns: readonly string[]; /** - * Test files whose strength this module's score measures. Editing one of - * these is exactly the change the PR lane must re-measure, so they own the - * module too. `modules.test.ts` asserts each one exists and actually exercises - * the module's sources — no stale or aspirational entries. + * Paths this module owns; a trailing `/` marks a directory prefix. Sources + * only — the tests whose strength the score measures are derived from the + * import graph in `ownership.ts`, never listed here. */ - readonly tests: readonly string[]; + readonly owns: readonly string[]; }; export const KERNEL_MODULES: readonly KernelModule[] = [ @@ -40,39 +37,24 @@ export const KERNEL_MODULES: readonly KernelModule[] = [ label: 'Error retriability + hints', mutate: ['src/kernel/errors.ts'], owns: ['src/kernel/errors.ts'], - tests: ['src/kernel/__tests__/errors.test.ts'], }, { id: 'daemon-ref-frame', label: 'Ref-frame admission matrix (ADR 0014)', mutate: ['src/daemon/ref-frame.ts'], owns: ['src/daemon/ref-frame.ts'], - tests: [ - 'src/daemon/__tests__/ref-frame.test.ts', - 'src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts', - ], }, { id: 'interaction-settle', label: 'Interaction settle decisions', mutate: ['src/commands/interaction/runtime/settle.ts'], owns: ['src/commands/interaction/runtime/settle.ts'], - tests: [ - 'src/commands/interaction/runtime/settle.test.ts', - 'src/daemon/handlers/__tests__/interaction-settle.test.ts', - ], }, { id: 'scroll-edge-state', label: 'Scroll edge-state detection', mutate: ['src/utils/scroll-edge-state.ts'], owns: ['src/utils/scroll-edge-state.ts'], - // No mirrored test file: edge-state decisions are only reached through the - // gesture/dispatch callers, which is also why this kernel scores lowest. - tests: [ - 'src/commands/interaction/runtime/gestures.test.ts', - 'src/core/__tests__/dispatch-scroll.test.ts', - ], }, { id: 'selectors', @@ -80,7 +62,6 @@ export const KERNEL_MODULES: readonly KernelModule[] = [ mutate: ['src/selectors/**/*.ts', '!src/selectors/**/*.test.ts', '!src/selectors/__tests__/**'], // Selector tests live under the owned directory, so the prefix covers them. owns: ['src/selectors/'], - tests: ['src/selectors/__tests__/'], }, ]; @@ -108,15 +89,15 @@ export function normalizePath(filePath: string): string { } /** - * Which kernel module owns a repository-relative path, if any. + * Which kernel module owns a repository-relative *source* path, if any. * - * Test files under an owned root count as owned: strengthening (or weakening) a - * kernel's tests is exactly the change whose mutation score must be re-measured. + * Test-file attribution is derived from the import graph — see + * `derivedAffectedModules` in `ownership.ts`, which is what the PR lane calls. */ export function moduleForFile(filePath: string): ModuleId | undefined { const normalized = normalizePath(filePath); for (const module of KERNEL_MODULES) { - for (const owned of [...module.owns, ...module.tests]) { + for (const owned of module.owns) { const match = owned.endsWith('/') ? normalized.startsWith(owned) : normalized === owned; if (match) return module.id; } @@ -124,7 +105,7 @@ export function moduleForFile(filePath: string): ModuleId | undefined { return undefined; } -/** Kernel modules affected by a diff, for PR-scoped runs. */ +/** Kernel modules whose registry-owned paths a diff touches. */ export function affectedModules(changedFiles: readonly string[]): ModuleId[] { const ids = new Set(); for (const file of changedFiles) { diff --git a/scripts/mutation/ownership.test.ts b/scripts/mutation/ownership.test.ts new file mode 100644 index 000000000..096c54e85 --- /dev/null +++ b/scripts/mutation/ownership.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { KERNEL_MODULES } from './modules.ts'; +import { + derivedAffectedModules, + isTestFile, + mutatedSources, + ownedTestFiles, + ownershipDeriver, + reachableFrom, +} from './ownership.ts'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +test('a kernel is owned by the mirrored test that imports it directly', () => { + const deriver = ownershipDeriver(repoRoot); + assert.deepEqual(deriver.ownersOf('src/kernel/__tests__/errors.test.ts'), ['kernel-errors']); + assert.ok( + deriver.ownersOf('src/daemon/__tests__/ref-frame.test.ts').includes('daemon-ref-frame'), + ); + assert.ok( + deriver + .ownersOf('src/commands/interaction/runtime/settle.test.ts') + .includes('interaction-settle'), + ); +}); + +// The omission that hand-listed ownership could not see: this test asserts over +// normalizeError without importing src/kernel/errors.ts itself. +test('a kernel is owned by tests that reach it indirectly', () => { + const deriver = ownershipDeriver(repoRoot); + assert.ok( + deriver.ownersOf('src/__tests__/daemon-error.test.ts').includes('kernel-errors'), + 'daemon-error.test.ts exercises normalizeError but does not own kernel-errors', + ); + assert.ok( + deriver + .ownersOf('src/commands/interaction/runtime/gestures.test.ts') + .includes('scroll-edge-state'), + ); +}); + +test('ownership is complete: every test reaching a kernel owns it', () => { + const owned = ownedTestFiles(repoRoot); + const cache = new Map(); + for (const module of KERNEL_MODULES) { + const sources = mutatedSources(module, repoRoot); + assert.ok(sources.length > 0, `${module.id} mutates nothing`); + const files = owned.get(module.id) ?? []; + assert.ok( + files.length > 0, + `${module.id} has no owning tests — its score cannot be attributed`, + ); + for (const testFile of files) { + const reachable = reachableFrom(testFile, repoRoot, cache); + assert.ok( + sources.some((source) => reachable.has(source)), + `${testFile} owns ${module.id} without reaching it`, + ); + } + // The converse: a diff to an owning test selects the module on a PR. + assert.ok( + derivedAffectedModules([files[0]!], repoRoot).includes(module.id), + `changing ${files[0]} does not select ${module.id}`, + ); + } +}); + +test('non-kernel sources and non-tests are not owned', () => { + assert.deepEqual(derivedAffectedModules(['README.md', 'src/kernel/rect.ts'], repoRoot), []); + assert.ok(!isTestFile('src/kernel/errors.ts')); + assert.ok(!isTestFile('scripts/mutation/ownership.test.ts')); + assert.ok(isTestFile('src/kernel/__tests__/errors.test.ts')); +}); + +test('derived selection unions source ownership with test reachability', () => { + assert.deepEqual( + derivedAffectedModules( + [ + 'src/selectors/parse.ts', + 'src/daemon/__tests__/ref-frame.test.ts', + 'docs/agents/testing.md', + ], + repoRoot, + ).filter((id) => id === 'selectors' || id === 'daemon-ref-frame'), + ['daemon-ref-frame', 'selectors'], + ); +}); diff --git a/scripts/mutation/ownership.ts b/scripts/mutation/ownership.ts new file mode 100644 index 000000000..571c71e1c --- /dev/null +++ b/scripts/mutation/ownership.ts @@ -0,0 +1,136 @@ +// Which kernel module a changed file belongs to, DERIVED — never hand-listed. +// +// A mutation score is a statement about the tests that kill the mutants, so the +// PR lane must re-measure a kernel whenever one of *those* tests changes. An +// enumerated list of test files cannot state that: it silently omits tests that +// exercise a kernel indirectly (`src/__tests__/daemon-error.test.ts` reaches +// `normalizeError` through `src/daemon.ts`), and nothing fails when a new test +// is added. So ownership is computed from the static import graph instead: a test +// file owns every kernel module whose mutated sources it can reach. +// +// The derivation is deliberately a superset — reaching a kernel is cheaper to +// prove than killing its mutants, so an unrelated diff can select a module and +// pay for a report. False positives cost runner minutes; a false negative would +// let a weakened test slip past the ratchet, which is the thing the lane exists +// to catch. +// +// Non-test source changes outside the registry are NOT owned: they can only move +// a score through the tests that reach the kernel, and the weekly full sweep is +// what re-measures the whole surface. The PR lane's claim is narrower on purpose +// — kernel sources plus the tests that exercise them. + +import fs from 'node:fs'; +import path from 'node:path'; +import { walkFiles } from '../lib/walk-files.ts'; +import { + affectedModules, + KERNEL_MODULES, + normalizePath, + type ModuleId, + type KernelModule, +} from './modules.ts'; +import { expandMutateFiles } from './test-scope.ts'; + +/** Test files the mutation lane can attribute to a kernel at all. */ +export function isTestFile(filePath: string): boolean { + const normalized = normalizePath(filePath); + return normalized.startsWith('src/') && normalized.endsWith('.test.ts'); +} + +/** Repository-relative modules a file imports, following relative specifiers only. */ +function importsOf(file: string, repoRoot: string, cache: Map): string[] { + const cached = cache.get(file); + if (cached) return cached; + const absolute = path.join(repoRoot, file); + const text = fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf8') : ''; + const specifiers = [...text.matchAll(/(?:from|import)\s*\(?\s*'(?\.[^']+)'/g)].map( + (match) => match.groups!.spec, + ); + const resolved = [ + ...new Set( + specifiers.flatMap((specifier) => { + const base = path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)); + return [base, `${base}.ts`, `${base}/index.ts`].filter((candidate) => + fs.existsSync(path.join(repoRoot, candidate)), + ); + }), + ), + ].filter((candidate) => candidate.endsWith('.ts')); + cache.set(file, resolved); + return resolved; +} + +/** Every repository-relative module `file` reaches through the import graph. */ +export function reachableFrom( + file: string, + repoRoot: string, + cache: Map = new Map(), +): Set { + const seen = new Set(); + const queue = [normalizePath(file)]; + while (queue.length > 0) { + const current = queue.shift()!; + if (seen.has(current)) continue; + seen.add(current); + queue.push(...importsOf(current, repoRoot, cache)); + } + return seen; +} + +/** The concrete sources Stryker mutates for a module. */ +export function mutatedSources(module: KernelModule, repoRoot: string): string[] { + return expandMutateFiles(module.mutate, repoRoot); +} + +type Deriver = { + /** Kernel modules a single test file exercises, in registry order. */ + ownersOf: (testFile: string) => ModuleId[]; +}; + +/** A deriver with caches shared across files — one graph walk per module, not per query. */ +export function ownershipDeriver(repoRoot: string): Deriver { + const importCache = new Map(); + const sources = KERNEL_MODULES.map((module) => ({ + id: module.id, + sources: new Set(mutatedSources(module, repoRoot)), + })); + return { + ownersOf(testFile) { + const reachable = reachableFrom(testFile, repoRoot, importCache); + return sources + .filter((entry) => [...entry.sources].some((source) => reachable.has(source))) + .map((entry) => entry.id); + }, + }; +} + +/** + * Kernel modules a diff affects: registry-owned paths plus every module the + * changed tests reach. Registry order, deduplicated. + */ +export function derivedAffectedModules( + changedFiles: readonly string[], + repoRoot: string, +): ModuleId[] { + const ids = new Set(affectedModules(changedFiles)); + const tests = changedFiles.filter(isTestFile).map(normalizePath); + if (tests.length > 0) { + const deriver = ownershipDeriver(repoRoot); + for (const testFile of tests) { + for (const id of deriver.ownersOf(testFile)) ids.add(id); + } + } + return KERNEL_MODULES.filter((module) => ids.has(module.id)).map((module) => module.id); +} + +/** Every test file in the repository, per module that owns it — one graph walk. */ +export function ownedTestFiles(repoRoot: string): Map { + const deriver = ownershipDeriver(repoRoot); + const owned = new Map(KERNEL_MODULES.map((module) => [module.id, []])); + for (const file of walkFiles(path.join(repoRoot, 'src'), (file) => file.endsWith('.test.ts'))) { + const relative = normalizePath(path.relative(repoRoot, file)); + for (const id of deriver.ownersOf(relative)) owned.get(id)!.push(relative); + } + for (const files of owned.values()) files.sort(); + return owned; +} diff --git a/scripts/mutation/run.ts b/scripts/mutation/run.ts index d9b483c1f..c151d2f63 100644 --- a/scripts/mutation/run.ts +++ b/scripts/mutation/run.ts @@ -18,13 +18,8 @@ import { pathToFileURL } from 'node:url'; import { runCmdStreaming, runCmdSync } from '../../src/utils/exec.ts'; import { parseScriptArgs } from '../lib/cli-args.ts'; import { laneEnvelope } from '../lib/lane-envelope.ts'; -import { - affectedModules, - ALL_MODULE_IDS, - isModuleId, - mutateGlobs, - type ModuleId, -} from './modules.ts'; +import { ALL_MODULE_IDS, isModuleId, mutateGlobs, type ModuleId } from './modules.ts'; +import { derivedAffectedModules } from './ownership.ts'; import { renderReport } from './report.ts'; import { applyRun, @@ -61,6 +56,7 @@ const USAGE = `Usage: pnpm mutation:run [options] --update Record the run into the baseline (ratchet + graduation) --summary Also write the markdown report to --no-run Alias for --report with the default report path + --list-affected Print the affected module ids as JSON and exit (shard matrix) `; type Args = { @@ -71,6 +67,7 @@ type Args = { reportDir: string | undefined; update: boolean; summary: string | undefined; + listAffected: boolean; }; function parseModules(value: string | undefined): readonly ModuleId[] { @@ -98,6 +95,7 @@ function parseMutationArgs(argv: readonly string[]): Args { update: { type: 'boolean', default: false }, summary: { type: 'string' }, 'no-run': { type: 'boolean', default: false }, + 'list-affected': { type: 'boolean', default: false }, }); return { modules: parseModules(values.modules), @@ -107,6 +105,7 @@ function parseMutationArgs(argv: readonly string[]): Args { reportDir: values['report-dir'], update: Boolean(values.update), summary: values.summary, + listAffected: Boolean(values['list-affected']), }; } @@ -191,48 +190,67 @@ function emit(markdown: string, summaryPath: string | undefined): void { } } +/** + * How far the lane got. A run that dies in `stryker` or `report` is exactly the + * failure the freshness monitor must see, so the stage rides in the envelope + * rather than only in the job log. + */ +type Stage = 'select' | 'stryker' | 'report' | 'ratchet' | 'complete'; + +type LaneState = { + stage: Stage; + provenance: Provenance; + baseline: Baseline; + modules: readonly ModuleId[]; + affected: boolean; + scores: readonly ModuleScore[]; + result: RatchetResult | undefined; + error: string | undefined; +}; + /** * Scheduled-lane artifact envelope (#1430). Without it a downloaded report cannot * say which commit or Stryker/config version produced it, how long the sweep took, * or whether it passed — freshness and tool-drift monitoring would have to parse * logs. + * + * Written on every exit path, including a crashed setup or a Stryker run that + * produced no report: a lane that fails before it can measure anything is the + * dark-lane case, and an absent envelope is indistinguishable from a lane that + * never ran. */ -function writeEnvelope( - provenance: Provenance, - scores: readonly ModuleScore[], - baseline: Baseline, - result: RatchetResult, - options: { startedAtMs: number; affected: boolean; modules: readonly ModuleId[] }, -): void { +function writeEnvelope(state: LaneState, startedAtMs: number): void { const envelope = laneEnvelope({ lane: LANE_ID, commit: runCmdSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot }).stdout.trim(), - tool: { stryker: provenance.strykerVersion }, - configHash: provenance.configHash, - startedAtMs: options.startedAtMs, - result: result.failed ? 'fail' : 'pass', + tool: { stryker: state.provenance.strykerVersion }, + configHash: state.provenance.configHash, + startedAtMs, + result: state.stage === 'complete' && !state.result?.failed ? 'pass' : 'fail', data: { - scope: options.affected ? 'affected' : 'full-sweep', - modules: options.modules.map((id) => { - const score = scores.find((entry) => entry.module === id); + scope: state.affected ? 'affected' : 'full-sweep', + stage: state.stage, + error: state.error ?? null, + modules: state.modules.map((id) => { + const score = state.scores.find((entry) => entry.module === id); return { id, score: score?.score ?? null, killed: score?.killed ?? null, total: score?.total ?? null, - status: result.verdicts.find((verdict) => verdict.module === id)?.status ?? null, + status: state.result?.verdicts.find((verdict) => verdict.module === id)?.status ?? null, }; }), - gating: baseline.gating, - stableRuns: baseline.stableRuns, - requiredStableRuns: baseline.requiredStableRuns, + gating: state.baseline.gating, + stableRuns: state.baseline.stableRuns, + requiredStableRuns: state.baseline.requiredStableRuns, }, }); const file = path.join(repoRoot, ENVELOPE_PATH); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(envelope, null, 2)}\n`); process.stdout.write( - `\nLane envelope (${ENVELOPE_PATH}): ${envelope.result} in ` + + `\nLane envelope (${ENVELOPE_PATH}): ${envelope.result} at stage ${state.stage} in ` + `${Math.round(envelope.durationMs / 1000)}s at ${envelope.commit.slice(0, 12)}.\n`, ); } @@ -262,48 +280,85 @@ async function produceReport( return readReport(reportPath ?? DEFAULT_REPORT_PATH); } -async function main(argv = process.argv.slice(2)): Promise { - const startedAtMs = Date.now(); - const args = parseMutationArgs(argv); - const modules = args.affected ? affectedModules(changedFiles(args.base)) : args.modules; - if (modules.length === 0) { +function recordRun(args: Args, state: LaneState, result: RatchetResult): void { + const next = applyRun(state.baseline, state.scores, result, { + provenance: state.provenance, + now: new Date().toISOString(), + // Only the full sweep proves stability; an affected subset says nothing + // about the modules it skipped. + countsTowardGraduation: !args.affected && state.modules.length === ALL_MODULE_IDS.length, + }); + writeBaseline(next); + process.stdout.write( + `\nBaseline updated (${BASELINE_PATH}): ${next.stableRuns}/${next.requiredStableRuns} ` + + `stable runs, gating ${next.gating ? 'on' : 'off'}.\n`, + ); +} + +async function sweep(args: Args, state: LaneState): Promise { + // Test attribution is derived from the import graph, not a listed set of test + // files: see scripts/mutation/ownership.ts. + state.modules = args.affected + ? derivedAffectedModules(changedFiles(args.base), repoRoot) + : args.modules; + if (state.modules.length === 0) { process.stdout.write('mutation: no decision-kernel modules affected — nothing to mutate.\n'); + state.stage = 'complete'; return 0; } + state.stage = args.reportDir || args.report ? 'report' : 'stryker'; const report = args.reportDir ? readShardedReports(args.reportDir) - : await produceReport(modules, args.report); - const provenance = readProvenance(); - const baseline = readBaseline(); - const scores = summarizeReport(report, modules); - const result = evaluateRatchet(scores, baseline, provenance); + : await produceReport(state.modules, args.report); + + state.stage = 'ratchet'; + state.scores = summarizeReport(report, state.modules); + const result = evaluateRatchet(state.scores, state.baseline, state.provenance); + state.result = result; const title = args.affected ? 'Mutation score — affected decision kernels' : 'Mutation score — decision kernels'; - emit(renderReport(result, baseline, provenance, { title }), args.summary); - writeEnvelope(provenance, scores, baseline, result, { - startedAtMs, - affected: args.affected, - modules, - }); + emit(renderReport(result, state.baseline, state.provenance, { title }), args.summary); - if (args.update) { - const next = applyRun(baseline, scores, result, { - provenance, - now: new Date().toISOString(), - // Only the full sweep proves stability; an affected subset says nothing - // about the modules it skipped. - countsTowardGraduation: !args.affected && modules.length === ALL_MODULE_IDS.length, - }); - writeBaseline(next); + if (args.update) recordRun(args, state, result); + state.stage = 'complete'; + return result.failed ? 1 : 0; +} + +async function main(argv = process.argv.slice(2)): Promise { + const startedAtMs = Date.now(); + const args = parseMutationArgs(argv); + if (args.listAffected) { + // Selection only — no lane run, so no envelope. The shard jobs that consume + // this matrix each write their own. process.stdout.write( - `\nBaseline updated (${BASELINE_PATH}): ${next.stableRuns}/${next.requiredStableRuns} ` + - `stable runs, gating ${next.gating ? 'on' : 'off'}.\n`, + `${JSON.stringify(derivedAffectedModules(changedFiles(args.base), repoRoot))}\n`, ); + return 0; + } + // Provenance and baseline are read before any work so a crashed sweep still + // reports which tool and config it crashed with. + const state: LaneState = { + stage: 'select', + provenance: readProvenance(), + baseline: readBaseline(), + modules: args.modules, + affected: args.affected, + scores: [], + result: undefined, + error: undefined, + }; + try { + return await sweep(args, state); + } catch (error: unknown) { + state.error = error instanceof Error ? error.message : String(error); + process.stderr.write(`mutation: ${state.error}\n`); + return 1; + } finally { + writeEnvelope(state, startedAtMs); } - return result.failed ? 1 : 0; } if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { diff --git a/scripts/mutation/workflow.test.ts b/scripts/mutation/workflow.test.ts index c56da046e..774fdebf1 100644 --- a/scripts/mutation/workflow.test.ts +++ b/scripts/mutation/workflow.test.ts @@ -32,7 +32,7 @@ test('every kernel path a PR can touch selects the affected mutation job', () => (match) => match.groups!.glob, ); for (const module of KERNEL_MODULES) { - for (const owned of [...module.owns, ...module.tests]) { + for (const owned of module.owns) { const selected = paths.some( (glob) => glob === owned || @@ -42,6 +42,14 @@ test('every kernel path a PR can touch selects the affected mutation job', () => assert.ok(selected, `no path filter selects ${owned} (module ${module.id})`); } } + // Ownership is derived, so any test in src/ can own a kernel; the filter must + // let all of them through and leave the decision to the `select` job. A + // narrower filter is exactly the omission the derivation exists to prevent. + assert.ok( + paths.includes('src/**/*.test.ts'), + 'the PR lane must trigger on every src test, since test ownership is derived', + ); + assert.match(workflow('mutation-affected.yml'), /mutation:affected --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/**']) { From da4bf16f8b9597b11a5bcebe8936194472679c4d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:41:51 +0000 Subject: [PATCH 05/11] fix: merge only Stryker reports from a shard directory The shard artifacts now carry the lane envelope beside mutation.json, and the merge globbed every .json under the download path, so the ratchet job fed the envelope to the report parser and died after the mutants had already run. --- scripts/mutation/envelope.test.ts | 50 +++++++++++++++++++++++++++++++ scripts/mutation/run.ts | 5 ++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/scripts/mutation/envelope.test.ts b/scripts/mutation/envelope.test.ts index 8dcdb7019..8aa1a9dd3 100644 --- a/scripts/mutation/envelope.test.ts +++ b/scripts/mutation/envelope.test.ts @@ -90,6 +90,56 @@ test('a crashed run still writes an envelope naming the stage it died in', () => assert.match(envelope.configHash, /^sha256:/); }); +// Shard artifacts carry the envelope next to the report, so merging "every JSON +// under the shard directory" fed the envelope to the report parser and crashed +// the ratchet job after the mutants had already run. +test('merging shard reports ignores the envelope sitting beside them', () => { + const shards = path.join(repoRoot, '.tmp/mutation/envelope-test-shards/shard-a'); + fs.mkdirSync(shards, { recursive: true }); + fs.writeFileSync( + path.join(shards, 'mutation.json'), + JSON.stringify({ + files: { + 'src/kernel/errors.ts': { mutants: [{ status: 'Killed' }, { status: 'Survived' }] }, + }, + }), + ); + fs.writeFileSync( + path.join(shards, 'lane-envelope.json'), + JSON.stringify( + laneEnvelope({ + lane: 'mutation-decision-kernels', + commit: 'c'.repeat(40), + tool: { stryker: '9.6.1' }, + configHash: 'sha256:abcdef123456', + startedAtMs: 0, + now: 0, + result: 'pass', + data: {}, + }), + ), + ); + const result = runCmdSync( + 'node', + [ + '--experimental-strip-types', + 'scripts/mutation/run.ts', + '--report-dir', + '.tmp/mutation/envelope-test-shards', + '--modules', + 'kernel-errors', + ], + { cwd: repoRoot, allowFailure: true }, + ); + assert.match(result.stdout, /merging 1 shard report\(s\)/); + assert.match(result.stdout, /kernel-errors/); + assert.doesNotMatch(result.stderr, /Cannot convert undefined or null to object/); + fs.rmSync(path.join(repoRoot, '.tmp/mutation/envelope-test-shards'), { + recursive: true, + force: true, + }); +}); + test('both mutation lanes publish the envelope', () => { for (const name of ['mutation-weekly.yml', 'mutation-affected.yml']) { assert.match( diff --git a/scripts/mutation/run.ts b/scripts/mutation/run.ts index c151d2f63..129d65c5b 100644 --- a/scripts/mutation/run.ts +++ b/scripts/mutation/run.ts @@ -262,9 +262,10 @@ function readReport(file: string): StrykerReport { function readShardedReports(dir: string): StrykerReport { const root = path.isAbsolute(dir) ? dir : path.join(repoRoot, dir); + // Shard artifacts also carry the lane envelope and the derived test scope, so + // the report is selected by name rather than by "every .json here". const files = fs - .globSync('**/*.json', { cwd: root }) - .filter((file) => !file.endsWith('proposed-baseline.json') && !file.endsWith('test-scope.json')) + .globSync(`**/${path.basename(DEFAULT_REPORT_PATH)}`, { cwd: root }) .map((file) => path.join(root, file)) .sort(); if (files.length === 0) throw new Error(`No Stryker JSON reports found under ${dir}`); From 0218d2174fa87edf8ba6ca0eb9db87e06b114218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 27 Jul 2026 17:23:58 +0000 Subject: [PATCH 06/11] fix(mutation): fail on incomplete shard sets and envelope pre-run failures Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/mutation-affected.yml | 28 ++++++ .github/workflows/mutation-weekly.yml | 22 +++++ scripts/mutation/envelope.test.ts | 94 ++++++++++++++++++++ scripts/mutation/run.ts | 111 ++++++++++++++++++------ 4 files changed, 230 insertions(+), 25 deletions(-) diff --git a/.github/workflows/mutation-affected.yml b/.github/workflows/mutation-affected.yml index a7fe8f99f..adcfa97c9 100644 --- a/.github/workflows/mutation-affected.yml +++ b/.github/workflows/mutation-affected.yml @@ -66,6 +66,22 @@ jobs: echo "modules=$modules" >> "$GITHUB_OUTPUT" echo "Affected decision kernels: $modules" >> "$GITHUB_STEP_SUMMARY" + # 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 \ + "affected selection failed before any mutant ran (run ${{ github.run_id }})" || true + + - name: Upload selection envelope + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: mutation-affected-select + path: .tmp/mutation/lane-envelope.json + if-no-files-found: warn + mutants: name: Mutants (${{ matrix.module }}) needs: select @@ -86,6 +102,12 @@ jobs: - name: Run mutants for ${{ matrix.module }} run: pnpm mutation:run --modules ${{ matrix.module }} + - name: Record a failed shard envelope + if: failure() + run: | + pnpm mutation:run --affected --modules ${{ matrix.module }} --fail-envelope \ + "shard ${{ matrix.module }} failed before producing a report (run ${{ github.run_id }})" || true + - name: Upload shard report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -130,6 +152,12 @@ jobs: pnpm 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 \ + "affected ratchet failed before producing a verdict (run ${{ github.run_id }})" || true + - name: Upload mutation report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.github/workflows/mutation-weekly.yml b/.github/workflows/mutation-weekly.yml index 1ab73c4ea..0bd9efa7b 100644 --- a/.github/workflows/mutation-weekly.yml +++ b/.github/workflows/mutation-weekly.yml @@ -57,6 +57,15 @@ jobs: - name: Run mutants for ${{ matrix.module }} run: pnpm mutation:run --modules ${{ matrix.module }} + # A shard can die before Stryker writes anything (install, config, crash); + # without this the artifact is absent and "shard failed" is indistinguishable + # from "lane never ran". --fail-envelope leaves a real verdict untouched. + - name: Record a failed shard envelope + if: failure() + run: | + pnpm mutation:run --modules ${{ matrix.module }} --fail-envelope \ + "shard ${{ matrix.module }} failed before producing a report (run ${{ github.run_id }})" || true + - name: Upload shard report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -97,12 +106,25 @@ jobs: cp mutation-baselines/decision-kernels.json .tmp/mutation/proposed-baseline.json git checkout -- mutation-baselines/decision-kernels.json + # The self-test and the artifact download both run before the ratchet, so a + # failure there would otherwise leave the aggregate lane with no envelope. + - name: Record a failed lane envelope + if: failure() + run: | + pnpm mutation:run --fail-envelope \ + "weekly ratchet job failed before producing a verdict (run ${{ github.run_id }})" || true + # Freshness/drift telemetry (#1430): the envelope states commit, Stryker # version, config hash, duration and result, so a lane going dark or a tool # bump is visible without reading these logs. - name: Lane envelope if: always() run: | + if [ ! -f .tmp/mutation/lane-envelope.json ]; then + echo 'No lane envelope was written — the job died before it could run node.' \ + >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi { echo '
Lane envelope (schema #1430)' echo diff --git a/scripts/mutation/envelope.test.ts b/scripts/mutation/envelope.test.ts index 8aa1a9dd3..423dc40dd 100644 --- a/scripts/mutation/envelope.test.ts +++ b/scripts/mutation/envelope.test.ts @@ -140,6 +140,100 @@ test('merging shard reports ignores the envelope sitting beside them', () => { }); }); +function runMutation(args: readonly string[]): { + exitCode: number; + stdout: string; + stderr: string; +} { + const result = runCmdSync( + 'node', + ['--experimental-strip-types', 'scripts/mutation/run.ts', ...args], + { + cwd: repoRoot, + allowFailure: true, + }, + ); + return { exitCode: result.exitCode ?? 1, stdout: result.stdout, stderr: result.stderr }; +} + +type Envelope = { + result: string; + data: { stage: string; error: string | null; modules: readonly { id: string }[] }; +}; + +function readEnvelope(): Envelope { + return JSON.parse( + fs.readFileSync(path.join(repoRoot, '.tmp/mutation/lane-envelope.json'), 'utf8'), + ) as Envelope; +} + +// A merged shard set is only a sweep if every requested module actually reported. +// summarizeReport scores an absent module as 0, and while the lane is non-gating a +// 0 only *reports* a regression — so a dead matrix shard would otherwise be +// aggregated into a passing "complete" envelope claiming the sweep happened. +test('an incomplete shard set fails instead of scoring the missing module as zero', () => { + const shards = path.join(repoRoot, '.tmp/mutation/partial-shards/shard-kernel-errors'); + fs.mkdirSync(shards, { recursive: true }); + fs.writeFileSync( + path.join(shards, 'mutation.json'), + JSON.stringify({ + files: { 'src/kernel/errors.ts': { mutants: [{ status: 'Killed' }] } }, + }), + ); + const result = runMutation([ + '--report-dir', + '.tmp/mutation/partial-shards', + '--modules', + 'kernel-errors,daemon-ref-frame', + ]); + assert.notEqual(result.exitCode, 0, 'a missing shard must fail the aggregate'); + assert.match(result.stderr, /Incomplete shard set/); + assert.match(result.stderr, /daemon-ref-frame/); + const envelope = readEnvelope(); + assert.equal(envelope.result, 'fail'); + assert.equal(envelope.data.stage, 'ratchet'); + fs.rmSync(path.join(repoRoot, '.tmp/mutation/partial-shards'), { recursive: true, force: true }); +}); + +// Argument parsing and the provenance/baseline reads used to sit outside the +// envelope boundary, so the lane could exit without declaring itself at all. +test('a malformed invocation still writes an envelope', () => { + fs.rmSync(path.join(repoRoot, '.tmp/mutation/lane-envelope.json'), { force: true }); + const result = runMutation(['--modules', 'not-a-kernel']); + assert.notEqual(result.exitCode, 0); + const envelope = readEnvelope(); + assert.equal(envelope.result, 'fail'); + assert.equal(envelope.data.stage, 'setup'); + assert.match(envelope.data.error ?? '', /Unknown mutation module/); +}); + +// The weekly self-test and the affected-selection job run before any mutant, so +// their failure has to be declared by the lane rather than only by the job log. +test('--fail-envelope declares a step that failed before the sweep', () => { + fs.rmSync(path.join(repoRoot, '.tmp/mutation/lane-envelope.json'), { force: true }); + const result = runMutation(['--fail-envelope', 'self-test failed']); + assert.notEqual(result.exitCode, 0, 'a pre-run failure must not report success'); + const envelope = readEnvelope(); + assert.equal(envelope.result, 'fail'); + assert.equal(envelope.data.stage, 'setup'); + assert.equal(envelope.data.error, 'self-test failed'); +}); + +// The workflows run it from `if: failure()`, which also fires when the ratchet +// itself failed — a generic reason must never overwrite the real verdict. +test('--fail-envelope leaves an envelope this run already wrote alone', () => { + fs.rmSync(path.join(repoRoot, '.tmp/mutation/lane-envelope.json'), { force: true }); + runMutation(['--fail-envelope', 'the real failure']); + runMutation(['--fail-envelope', 'a later generic failure']); + assert.equal(readEnvelope().data.error, 'the real failure'); +}); + +test('both mutation lanes record an envelope for failures before the sweep', () => { + for (const name of ['mutation-weekly.yml', 'mutation-affected.yml']) { + assert.match(workflow(name), /--fail-envelope/, `${name} can fail without an envelope`); + } +}); + test('both mutation lanes publish the envelope', () => { for (const name of ['mutation-weekly.yml', 'mutation-affected.yml']) { assert.match( diff --git a/scripts/mutation/run.ts b/scripts/mutation/run.ts index 129d65c5b..4e04d05c6 100644 --- a/scripts/mutation/run.ts +++ b/scripts/mutation/run.ts @@ -57,6 +57,9 @@ const USAGE = `Usage: pnpm mutation:run [options] --summary Also write the markdown report to --no-run Alias for --report with the default report path --list-affected Print the affected module ids as JSON and exit (shard matrix) + --fail-envelope + Write a failed lane envelope for a step that ran before (or + instead of) the sweep, e.g. a self-test failure `; type Args = { @@ -68,6 +71,7 @@ type Args = { update: boolean; summary: string | undefined; listAffected: boolean; + failEnvelope: string | undefined; }; function parseModules(value: string | undefined): readonly ModuleId[] { @@ -96,6 +100,7 @@ function parseMutationArgs(argv: readonly string[]): Args { summary: { type: 'string' }, 'no-run': { type: 'boolean', default: false }, 'list-affected': { type: 'boolean', default: false }, + 'fail-envelope': { type: 'string' }, }); return { modules: parseModules(values.modules), @@ -106,6 +111,7 @@ function parseMutationArgs(argv: readonly string[]): Args { update: Boolean(values.update), summary: values.summary, listAffected: Boolean(values['list-affected']), + failEnvelope: values['fail-envelope'], }; } @@ -195,7 +201,13 @@ function emit(markdown: string, summaryPath: string | undefined): void { * failure the freshness monitor must see, so the stage rides in the envelope * rather than only in the job log. */ -type Stage = 'select' | 'stryker' | 'report' | 'ratchet' | 'complete'; +type Stage = 'setup' | 'select' | 'stryker' | 'report' | 'ratchet' | 'complete'; + +/** + * Provenance for a lane that died before it could read any: `unknown` is a + * reported fact, whereas skipping the envelope would be silence. + */ +const UNKNOWN_PROVENANCE: Provenance = { strykerVersion: 'unknown', configHash: 'unknown' }; type LaneState = { stage: Stage; @@ -206,6 +218,8 @@ type LaneState = { scores: readonly ModuleScore[]; result: RatchetResult | undefined; error: string | undefined; + /** `--fail-envelope` never overwrites a real verdict this run already wrote. */ + onlyIfAbsent: boolean; }; /** @@ -220,9 +234,17 @@ type LaneState = { * never ran. */ function writeEnvelope(state: LaneState, startedAtMs: number): void { + const target = path.join(repoRoot, ENVELOPE_PATH); + if (state.onlyIfAbsent && fs.existsSync(target)) { + process.stdout.write(`mutation: ${ENVELOPE_PATH} already describes this run; left as is.\n`); + return; + } const envelope = laneEnvelope({ lane: LANE_ID, - commit: runCmdSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot }).stdout.trim(), + commit: runCmdSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + allowFailure: true, + }).stdout.trim(), tool: { stryker: state.provenance.strykerVersion }, configHash: state.provenance.configHash, startedAtMs, @@ -246,9 +268,8 @@ function writeEnvelope(state: LaneState, startedAtMs: number): void { requiredStableRuns: state.baseline.requiredStableRuns, }, }); - const file = path.join(repoRoot, ENVELOPE_PATH); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, `${JSON.stringify(envelope, null, 2)}\n`); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, `${JSON.stringify(envelope, null, 2)}\n`); process.stdout.write( `\nLane envelope (${ENVELOPE_PATH}): ${envelope.result} at stage ${state.stage} in ` + `${Math.round(envelope.durationMs / 1000)}s at ${envelope.commit.slice(0, 12)}.\n`, @@ -296,25 +317,45 @@ function recordRun(args: Args, state: LaneState, result: RatchetResult): void { ); } +/** + * A merged shard set must cover every requested module. `summarizeReport` scores + * a module with no mutants as 0, and while the lane is non-gating a 0 is only + * *reported* as a regression — so a matrix shard that died would otherwise be + * aggregated into a `complete`/`pass` envelope claiming the sweep happened. + */ +function assertShardsCoverModules(state: LaneState, dir: string): void { + const missing = state.scores.filter((score) => score.total === 0).map((score) => score.module); + if (missing.length === 0) return; + throw new Error( + `Incomplete shard set from ${dir}: no mutants for ${missing.join(', ')}. ` + + 'A shard job failed or its artifact is absent; the aggregate is not a sweep.', + ); +} + +/** Reads shard reports, an existing report, or runs Stryker — and scores them. */ +async function scoreModules(args: Args, state: LaneState): Promise { + state.stage = args.reportDir || args.report ? 'report' : 'stryker'; + const report = args.reportDir + ? readShardedReports(args.reportDir) + : await produceReport(state.modules, args.report); + + state.stage = 'ratchet'; + state.scores = summarizeReport(report, state.modules); + if (args.reportDir) assertShardsCoverModules(state, args.reportDir); +} + async function sweep(args: Args, state: LaneState): Promise { + state.stage = 'select'; // Test attribution is derived from the import graph, not a listed set of test // files: see scripts/mutation/ownership.ts. - state.modules = args.affected - ? derivedAffectedModules(changedFiles(args.base), repoRoot) - : args.modules; + if (args.affected) state.modules = derivedAffectedModules(changedFiles(args.base), repoRoot); if (state.modules.length === 0) { process.stdout.write('mutation: no decision-kernel modules affected — nothing to mutate.\n'); state.stage = 'complete'; return 0; } - state.stage = args.reportDir || args.report ? 'report' : 'stryker'; - const report = args.reportDir - ? readShardedReports(args.reportDir) - : await produceReport(state.modules, args.report); - - state.stage = 'ratchet'; - state.scores = summarizeReport(report, state.modules); + await scoreModules(args, state); const result = evaluateRatchet(state.scores, state.baseline, state.provenance); state.result = result; @@ -328,31 +369,51 @@ async function sweep(args: Args, state: LaneState): Promise { return result.failed ? 1 : 0; } +/** + * Everything after `--help` runs inside the envelope boundary, argument parsing + * and provenance reads included: a lane that cannot even read its own config is + * the failure freshness monitoring most needs to see. + */ +async function run(argv: readonly string[], state: LaneState): Promise { + const args = parseMutationArgs(argv); + state.affected = args.affected; + state.provenance = readProvenance(); + state.baseline = readBaseline(); + state.modules = args.modules; + if (args.failEnvelope) { + // A step that ran before the sweep failed (the weekly self-test, setup): the + // lane produced no measurement, and that is what the envelope must say. + state.error = args.failEnvelope; + state.onlyIfAbsent = true; + return 1; + } + return await sweep(args, state); +} + async function main(argv = process.argv.slice(2)): Promise { const startedAtMs = Date.now(); - const args = parseMutationArgs(argv); - if (args.listAffected) { + if (argv.includes('--list-affected')) { // Selection only — no lane run, so no envelope. The shard jobs that consume // this matrix each write their own. + const args = parseMutationArgs(argv); process.stdout.write( `${JSON.stringify(derivedAffectedModules(changedFiles(args.base), repoRoot))}\n`, ); return 0; } - // Provenance and baseline are read before any work so a crashed sweep still - // reports which tool and config it crashed with. const state: LaneState = { - stage: 'select', - provenance: readProvenance(), - baseline: readBaseline(), - modules: args.modules, - affected: args.affected, + stage: 'setup', + provenance: UNKNOWN_PROVENANCE, + baseline: emptyBaseline(), + modules: [], + affected: false, scores: [], result: undefined, error: undefined, + onlyIfAbsent: false, }; try { - return await sweep(args, state); + return await run(argv, state); } catch (error: unknown) { state.error = error instanceof Error ? error.message : String(error); process.stderr.write(`mutation: ${state.error}\n`); From f6fbba1c7063ec73b52f14294f722f403b40199f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 27 Jul 2026 18:17:52 +0000 Subject: [PATCH 07/11] fix(mutation): downgrade a passing envelope when a later lane step fails Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/mutation/envelope.test.ts | 34 +++++++++++++++++++++++++++++-- scripts/mutation/run.ts | 27 ++++++++++++++++++------ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/scripts/mutation/envelope.test.ts b/scripts/mutation/envelope.test.ts index 423dc40dd..ef5e61568 100644 --- a/scripts/mutation/envelope.test.ts +++ b/scripts/mutation/envelope.test.ts @@ -220,14 +220,44 @@ test('--fail-envelope declares a step that failed before the sweep', () => { }); // The workflows run it from `if: failure()`, which also fires when the ratchet -// itself failed — a generic reason must never overwrite the real verdict. -test('--fail-envelope leaves an envelope this run already wrote alone', () => { +// itself failed — a generic reason must never displace the specific one. +test('--fail-envelope keeps a failure the run already reported', () => { fs.rmSync(path.join(repoRoot, '.tmp/mutation/lane-envelope.json'), { force: true }); runMutation(['--fail-envelope', 'the real failure']); runMutation(['--fail-envelope', 'a later generic failure']); assert.equal(readEnvelope().data.error, 'the real failure'); }); +// A pass is not a verdict worth preserving: the weekly job copies the proposed +// baseline and restores the committed one *after* the ratchet passed, so a failure +// there would otherwise publish the failed scheduled job as a passing lane. +test('--fail-envelope downgrades a passing envelope when a later step fails', () => { + const shards = path.join(repoRoot, '.tmp/mutation/pass-then-fail/shard-kernel-errors'); + fs.mkdirSync(shards, { recursive: true }); + // A perfect shard so the ratchet passes: the score can only rise from the + // committed kernel-errors baseline. + fs.writeFileSync( + path.join(shards, 'mutation.json'), + JSON.stringify({ + files: { 'src/kernel/errors.ts': { mutants: [{ status: 'Killed' }, { status: 'Killed' }] } }, + }), + ); + const passing = runMutation([ + '--report-dir', + '.tmp/mutation/pass-then-fail', + '--modules', + 'kernel-errors', + ]); + assert.equal(passing.exitCode, 0, passing.stderr); + assert.equal(readEnvelope().result, 'pass'); + + runMutation(['--fail-envelope', 'the baseline copy step failed']); + const envelope = readEnvelope(); + assert.equal(envelope.result, 'fail', 'a failed job must not publish a passing envelope'); + assert.equal(envelope.data.error, 'the baseline copy step failed'); + fs.rmSync(path.join(repoRoot, '.tmp/mutation/pass-then-fail'), { recursive: true, force: true }); +}); + test('both mutation lanes record an envelope for failures before the sweep', () => { for (const name of ['mutation-weekly.yml', 'mutation-affected.yml']) { assert.match(workflow(name), /--fail-envelope/, `${name} can fail without an envelope`); diff --git a/scripts/mutation/run.ts b/scripts/mutation/run.ts index 4e04d05c6..7485cd0d6 100644 --- a/scripts/mutation/run.ts +++ b/scripts/mutation/run.ts @@ -218,10 +218,25 @@ type LaneState = { scores: readonly ModuleScore[]; result: RatchetResult | undefined; error: string | undefined; - /** `--fail-envelope` never overwrites a real verdict this run already wrote. */ - onlyIfAbsent: boolean; + /** + * `--fail-envelope` keeps an existing *failure* (its reason is the specific + * one) but replaces an existing pass: a post-verdict step can fail after the + * ratchet passed, and publishing that job as passing is the bug the envelope + * exists to prevent. + */ + recoveryOnly: boolean; }; +/** The result of an envelope already on disk, if there is a readable one. */ +function existingResult(file: string): string | undefined { + if (!fs.existsSync(file)) return undefined; + try { + return (JSON.parse(fs.readFileSync(file, 'utf8')) as { result?: string }).result; + } catch { + return undefined; + } +} + /** * Scheduled-lane artifact envelope (#1430). Without it a downloaded report cannot * say which commit or Stryker/config version produced it, how long the sweep took, @@ -235,8 +250,8 @@ type LaneState = { */ function writeEnvelope(state: LaneState, startedAtMs: number): void { const target = path.join(repoRoot, ENVELOPE_PATH); - if (state.onlyIfAbsent && fs.existsSync(target)) { - process.stdout.write(`mutation: ${ENVELOPE_PATH} already describes this run; left as is.\n`); + if (state.recoveryOnly && existingResult(target) === 'fail') { + process.stdout.write(`mutation: ${ENVELOPE_PATH} already reports a failure; left as is.\n`); return; } const envelope = laneEnvelope({ @@ -384,7 +399,7 @@ async function run(argv: readonly string[], state: LaneState): Promise { // A step that ran before the sweep failed (the weekly self-test, setup): the // lane produced no measurement, and that is what the envelope must say. state.error = args.failEnvelope; - state.onlyIfAbsent = true; + state.recoveryOnly = true; return 1; } return await sweep(args, state); @@ -410,7 +425,7 @@ async function main(argv = process.argv.slice(2)): Promise { scores: [], result: undefined, error: undefined, - onlyIfAbsent: false, + recoveryOnly: false, }; try { return await run(argv, state); From 390cc39c2a1460d520f35b35705ef3b508a1ae28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 28 Jul 2026 06:12:50 +0000 Subject: [PATCH 08/11] refactor(mutation): shard by registry, defer the PR lane, drop the bundled watcher Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/lane-health.yml | 46 ------ .github/workflows/mutation-affected.yml | 27 ++-- .github/workflows/mutation-weekly.yml | 39 +++--- docs/agents/testing.md | 15 -- package.json | 6 +- scripts/lane-health/cadence.test.ts | 138 ------------------ scripts/lane-health/cadence.ts | 109 --------------- scripts/lane-health/run.ts | 179 ------------------------ scripts/mutation/modules.test.ts | 23 +++ scripts/mutation/modules.ts | 29 ++++ scripts/mutation/run.ts | 124 ++++++++++++++-- scripts/mutation/workflow.test.ts | 36 ++++- stryker.config.json | 2 +- 13 files changed, 238 insertions(+), 535 deletions(-) delete mode 100644 .github/workflows/lane-health.yml delete mode 100644 scripts/lane-health/cadence.test.ts delete mode 100644 scripts/lane-health/cadence.ts delete mode 100644 scripts/lane-health/run.ts diff --git a/.github/workflows/lane-health.yml b/.github/workflows/lane-health.yml deleted file mode 100644 index b91500692..000000000 --- a/.github/workflows/lane-health.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Lane Health - -# Watches the watchers (issue #1430): every `schedule:`-triggered workflow is -# checked for freshness and for two consecutive failures. Nightly and weekly lanes -# can go dark for weeks — a disabled schedule, a 60-day inactivity suspension, a -# rename — while PR CI stays green, so no other job can notice. -# -# The lane list is derived from .github/workflows/, not hand-maintained, so a lane -# added in the same PR as its first run is already watched. - -on: - schedule: - # Daily 06:00 UTC — after the nightlies and the weekly sweep have reported. - - cron: "0 6 * * *" - workflow_dispatch: - -permissions: - contents: read - actions: read - issues: write - -concurrency: - group: ci-${{ github.workflow }} - cancel-in-progress: true - -jobs: - lane-health: - name: Scheduled lane freshness - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Setup toolchain - uses: ./.github/actions/setup-node-pnpm - - - name: Self-test - run: pnpm lane-health:test - - # Reports to the job summary and pings one tracking issue; it never fails - # the build, because a second red lane nobody watches is not an alert. - - name: Check scheduled lane freshness - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: pnpm lane-health --alert diff --git a/.github/workflows/mutation-affected.yml b/.github/workflows/mutation-affected.yml index adcfa97c9..c12a3e28e 100644 --- a/.github/workflows/mutation-affected.yml +++ b/.github/workflows/mutation-affected.yml @@ -8,6 +8,11 @@ name: Mutation Affected # sweeps (mutation-weekly.yml). Until then it is a report with the same numbers, # so the gate's first failing day is not also its first running day. # +# Cost control: before graduation an affected run is a report nobody acts on, so +# `select` returns an empty matrix and no mutants run — except when the diff +# touches the lane's own tooling, the one case where a pre-graduation run buys +# something (the gate has to be proven before it can bite). +# # Scope is the AFFECTED modules only, and affectedness is DERIVED from the import # graph (scripts/mutation/ownership.ts): a kernel source, or any test that reaches # one. Reaching a kernel is a superset of killing its mutants, so the `select` job @@ -59,12 +64,12 @@ jobs: run: pnpm mutation:test - id: select - name: Derive affected modules + 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 decision kernels: $modules" >> "$GITHUB_STEP_SUMMARY" + echo "Affected mutation shards: $modules" >> "$GITHUB_STEP_SUMMARY" # The self-test and the derivation run before any mutant, so a failure here # would leave the lane with no envelope at all (#1430). @@ -83,15 +88,15 @@ jobs: if-no-files-found: warn mutants: - name: Mutants (${{ matrix.module }}) + name: Mutants (${{ matrix.name }}) needs: select if: needs.select.outputs.modules != '[]' runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 30 strategy: fail-fast: false matrix: - module: ${{ fromJSON(needs.select.outputs.modules) }} + include: ${{ fromJSON(needs.select.outputs.modules) }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -99,20 +104,22 @@ jobs: - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm - - name: Run mutants for ${{ matrix.module }} - run: pnpm mutation:run --modules ${{ matrix.module }} + - name: Run mutants for ${{ matrix.name }} + run: | + pnpm mutation:run --modules ${{ matrix.module }} \ + ${{ matrix.shard && format('--shard {0}', matrix.shard) || '' }} - name: Record a failed shard envelope if: failure() run: | pnpm mutation:run --affected --modules ${{ matrix.module }} --fail-envelope \ - "shard ${{ matrix.module }} failed before producing a report (run ${{ github.run_id }})" || true + "shard ${{ matrix.name }} failed before producing a report (run ${{ github.run_id }})" || true - name: Upload shard report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: mutation-affected-shard-${{ matrix.module }} + name: mutation-affected-shard-${{ matrix.name }} path: | .tmp/mutation/mutation.json .tmp/mutation/mutation.html @@ -146,7 +153,9 @@ jobs: - name: Ratchet the affected modules run: | if [ -d .tmp/mutation/shards ]; then + expected=$(echo '${{ needs.select.outputs.modules }}' | jq length) pnpm 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 }}" diff --git a/.github/workflows/mutation-weekly.yml b/.github/workflows/mutation-weekly.yml index 0bd9efa7b..99df7f329 100644 --- a/.github/workflows/mutation-weekly.yml +++ b/.github/workflows/mutation-weekly.yml @@ -31,22 +31,27 @@ concurrency: jobs: shard: - name: Mutants (${{ matrix.module }}) + name: Mutants (${{ matrix.name }}) runs-on: ubuntu-latest - # Budget is 30 minutes per shard; the headroom covers checkout/install so a - # slow runner still reports instead of being killed mid-sweep. - timeout-minutes: 45 + # The acceptance budget is 30 minutes of wall clock for the lane, and shards + # run in parallel, so the budget is per shard. The observed rate on a 2-core + # runner is ~3s/mutant, which is why selectors (~1,280 mutants) is sliced. + timeout-minutes: 30 strategy: fail-fast: false matrix: # Kept in step with KERNEL_MODULES by the shard-coverage assertion in - # scripts/mutation/workflow.test.ts. - module: - - kernel-errors - - daemon-ref-frame - - interaction-settle - - scroll-edge-state - - selectors + # scripts/mutation/workflow.test.ts, which also pins --expect-shards to + # the number of entries here. + include: + - { name: kernel-errors, module: kernel-errors } + - { name: daemon-ref-frame, module: daemon-ref-frame } + - { name: interaction-settle, module: interaction-settle } + - { name: scroll-edge-state, module: scroll-edge-state } + - { name: selectors-1, module: selectors, shard: 1/4 } + - { name: selectors-2, module: selectors, shard: 2/4 } + - { name: selectors-3, module: selectors, shard: 3/4 } + - { name: selectors-4, module: selectors, shard: 4/4 } steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -54,8 +59,10 @@ jobs: - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm - - name: Run mutants for ${{ matrix.module }} - run: pnpm mutation:run --modules ${{ matrix.module }} + - name: Run mutants for ${{ matrix.name }} + run: | + pnpm mutation:run --modules ${{ matrix.module }} \ + ${{ matrix.shard && format('--shard {0}', matrix.shard) || '' }} # A shard can die before Stryker writes anything (install, config, crash); # without this the artifact is absent and "shard failed" is indistinguishable @@ -64,13 +71,13 @@ jobs: if: failure() run: | pnpm mutation:run --modules ${{ matrix.module }} --fail-envelope \ - "shard ${{ matrix.module }} failed before producing a report (run ${{ github.run_id }})" || true + "shard ${{ matrix.name }} failed before producing a report (run ${{ github.run_id }})" || true - name: Upload shard report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: mutation-shard-${{ matrix.module }} + name: mutation-shard-${{ matrix.name }} path: | .tmp/mutation/mutation.json .tmp/mutation/lane-envelope.json @@ -102,7 +109,7 @@ jobs: # runner exports it, so the summary and the artifact carry the same numbers. - name: Ratchet the merged sweep and propose the next baseline run: | - pnpm mutation:check --report-dir .tmp/mutation/shards --update + pnpm mutation:check --report-dir .tmp/mutation/shards --expect-shards 8 --update cp mutation-baselines/decision-kernels.json .tmp/mutation/proposed-baseline.json git checkout -- mutation-baselines/decision-kernels.json diff --git a/docs/agents/testing.md b/docs/agents/testing.md index ad6af0753..15929ed90 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -169,21 +169,6 @@ pnpm mutation:baseline # full sweep, then record it (reviewed c written on every exit path, including a crash before any mutant runs: an absent envelope would be indistinguishable from a lane that never ran. -## Scheduled lane health (#1430) - -Nightly and weekly lanes can stop running — a disabled schedule, a rename, GitHub's 60-day inactivity -suspension — while PR CI stays green, so nothing fails and nobody notices. `pnpm lane-health` -(`.github/workflows/lane-health.yml`, daily) derives the lane list from `.github/workflows/` rather -than a hand-maintained list, reads each lane's scheduled runs through `gh`, and reports: - -- `dark` — two consecutive cadences missed (the cadence comes from the lane's own `cron`); -- `failing` — two consecutive scheduled runs concluded non-success (cancellations excluded); -- `never-run` — declared longer than two cadences ago and still never fired. - -One miss is a runner hiccup; two is the lane not doing its job. Unhealthy lanes ping a single tracking -issue (`--alert`) instead of opening one per run, and the job never fails — a second red lane nobody -watches is not an alert. `pnpm lane-health:test` covers the model. - ## Live web smoke The live web platform smoke runs the public built CLI against a local fixture page through the managed web backend: diff --git a/package.json b/package.json index e752e7e36..a59c8eb7f 100644 --- a/package.json +++ b/package.json @@ -113,11 +113,9 @@ "mutation:check": "node --experimental-strip-types scripts/mutation/run.ts --no-run", "mutation:affected": "node --experimental-strip-types scripts/mutation/run.ts --affected", "mutation:test": "node --experimental-strip-types --test scripts/mutation/*.test.ts", - "lane-health": "node --experimental-strip-types scripts/lane-health/run.ts", - "lane-health:test": "node --experimental-strip-types --test scripts/lane-health/*.test.ts", "lint": "oxlint . --deny-warnings", - "format": "node ./node_modules/oxfmt/bin/oxfmt --write src test skills scripts/mutation scripts/lane-health scripts/lib scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", - "format:check": "node ./node_modules/oxfmt/bin/oxfmt --check src test skills scripts/mutation scripts/lane-health scripts/lib scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", + "format": "node ./node_modules/oxfmt/bin/oxfmt --write src test skills scripts/mutation scripts/lib scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", + "format:check": "node ./node_modules/oxfmt/bin/oxfmt --check src test skills scripts/mutation scripts/lib scripts/help-conformance-bench.mjs scripts/help-conformance-case-checks.mjs scripts/help-conformance-command-validator.ts scripts/help-conformance-expectations.mjs scripts/help-conformance-plan-validator.mjs scripts/help-conformance-runner-output.mjs scripts/help-conformance-summary.mjs scripts/help-conformance-cases.mjs scripts/help-conformance-sample-outputs.mjs scripts/__tests__/help-conformance-bench.test.ts scripts/__tests__/help-conformance-sample-outputs.test.ts scripts/__tests__/help-conformance-topic-coverage.test.ts package.json tsconfig.json tsconfig.lib.json tsdown.config.ts vitest.config.ts vitest.mutation.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'", "fallow": "fallow audit --base origin/main", "fallow:all": "fallow --summary", "fallow:baseline": "(fallow dead-code --save-baseline fallow-baselines/dead-code.json --summary || true) && (fallow health --save-baseline fallow-baselines/health.json --summary || true)", diff --git a/scripts/lane-health/cadence.test.ts b/scripts/lane-health/cadence.test.ts deleted file mode 100644 index bb8f86c42..000000000 --- a/scripts/lane-health/cadence.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import assert from 'node:assert/strict'; -import path from 'node:path'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { - cadenceHours, - evaluateLane, - laneCadenceHours, - renderLaneHealth, - unhealthy, -} from './cadence.ts'; -import { observe, scheduledLanes } from './run.ts'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const HOUR = 3600_000; -const now = Date.parse('2026-02-01T00:00:00Z'); - -test('cadence comes from the coarsest set cron field', () => { - assert.equal(cadenceHours('0 5 * * 0'), 24 * 7); - assert.equal(cadenceHours('0 3 * * *'), 24); - assert.equal(cadenceHours('*/15 * * * *'), 1); - assert.equal(cadenceHours('0 0 1 * *'), 24 * 30); - assert.equal(laneCadenceHours(['0 5 * * 0', '0 3 * * *']), 24); -}); - -test('the scheduled lane list is derived from the workflow directory', () => { - const lanes = scheduledLanes(path.join(repoRoot, '.github/workflows')); - // The lane this PR adds must be watched by the monitor without being listed. - assert.deepEqual(lanes.get('mutation-weekly.yml'), ['0 5 * * 0']); - assert.ok(lanes.size >= 3, `expected several scheduled lanes, found ${lanes.size}`); - assert.ok(!lanes.has('ci.yml'), 'PR CI is not a scheduled lane'); -}); - -test('a lane that missed two cadences is dark', () => { - const weekly = { lane: 'mutation-weekly.yml', cadenceHours: 24 * 7 }; - const fresh = evaluateLane( - { - ...weekly, - lastRunAt: new Date(now - 24 * HOUR).toISOString(), - lastSuccessAt: undefined, - recentConclusions: ['success'], - declaredAt: undefined, - }, - now, - ); - assert.equal(fresh.status, 'fresh'); - const dark = evaluateLane( - { - ...weekly, - lastRunAt: new Date(now - 15 * 24 * HOUR).toISOString(), - lastSuccessAt: undefined, - recentConclusions: ['success'], - declaredAt: undefined, - }, - now, - ); - assert.equal(dark.status, 'dark'); - assert.match(dark.detail, /15\.0d ago/); - // One missed cadence is a hiccup, not an alert. - assert.equal( - evaluateLane( - { - ...weekly, - lastRunAt: new Date(now - 9 * 24 * HOUR).toISOString(), - lastSuccessAt: undefined, - recentConclusions: ['success'], - declaredAt: undefined, - }, - now, - ).status, - 'fresh', - ); -}); - -test('two consecutive non-success conclusions are failing; cancellations do not count', () => { - const base = { - lane: 'perf-nightly.yml', - cadenceHours: 24, - lastRunAt: new Date(now - HOUR).toISOString(), - lastSuccessAt: undefined, - declaredAt: undefined, - }; - assert.equal( - evaluateLane({ ...base, recentConclusions: ['failure', 'success'] }, now).status, - 'fresh', - ); - assert.equal( - evaluateLane({ ...base, recentConclusions: ['failure', 'cancelled', 'timed_out'] }, now).status, - 'failing', - ); - assert.equal(evaluateLane({ ...base, recentConclusions: [] }, now).status, 'fresh'); -}); - -test('a lane that never ran is reported once it is overdue, not while it is new', () => { - const never = { - lane: 'mutation-weekly.yml', - cadenceHours: 24 * 7, - lastRunAt: undefined, - lastSuccessAt: undefined, - recentConclusions: [], - }; - // Merged an hour ago: it has legitimately not fired yet. - assert.equal( - evaluateLane({ ...never, declaredAt: new Date(now - HOUR).toISOString() }, now).status, - 'fresh', - ); - const overdue = evaluateLane( - { ...never, declaredAt: new Date(now - 30 * 24 * HOUR).toISOString() }, - now, - ); - assert.equal(overdue.status, 'never-run'); - assert.equal(unhealthy([overdue]).length, 1); -}); - -test('observations map API runs onto the model, ignoring in-flight runs', () => { - const observation = observe( - 'mutation-weekly.yml', - ['0 5 * * 0'], - [ - { conclusion: null, createdAt: '2026-02-01T05:00:00Z', status: 'in_progress' }, - { conclusion: 'failure', createdAt: '2026-01-25T05:00:00Z', status: 'completed' }, - { conclusion: 'success', createdAt: '2026-01-18T05:00:00Z', status: 'completed' }, - ], - ); - assert.equal(observation.lastRunAt, '2026-02-01T05:00:00Z'); - assert.equal(observation.lastSuccessAt, '2026-01-18T05:00:00Z'); - assert.deepEqual(observation.recentConclusions, ['failure', 'success']); - assert.equal(observation.cadenceHours, 24 * 7); -}); - -test('the rendered report names the unhealthy lanes', () => { - const markdown = renderLaneHealth([ - { lane: 'mutation-weekly.yml', status: 'dark', detail: 'last scheduled run 20.0d ago' }, - { lane: 'perf-nightly.yml', status: 'fresh', detail: 'last scheduled run now' }, - ]); - assert.match(markdown, /1 of 2 scheduled lane\(s\) need attention: mutation-weekly\.yml/); - assert.match(markdown, /\| `mutation-weekly\.yml` \| dark \|/); -}); diff --git a/scripts/lane-health/cadence.ts b/scripts/lane-health/cadence.ts deleted file mode 100644 index ee4668fba..000000000 --- a/scripts/lane-health/cadence.ts +++ /dev/null @@ -1,109 +0,0 @@ -// Cadence + staleness model for scheduled lanes (issue #1430). -// -// A scheduled lane can stop running — a disabled schedule, a workflow renamed -// out of the trigger, a repo-wide 60-day schedule suspension — while PR CI stays -// green, so nothing fails and nobody notices for weeks. The observatory watches -// the watchers: derive the expected cadence from each workflow's own `cron`, then -// compare it against the last observed run. -// -// Pure model, no IO: `run.ts` supplies the observations from the GitHub API. - -/** Hours between fires, derived from a cron expression's coarsest set field. */ -export function cadenceHours(cron: string): number { - const [minute, hour, dayOfMonth, month, dayOfWeek] = cron.trim().split(/\s+/); - if (!minute || !hour) throw new Error(`Unparseable cron: ${cron}`); - const set = (field: string | undefined): boolean => Boolean(field) && field !== '*'; - if (set(month)) return 24 * 365; - if (set(dayOfMonth)) return 24 * 30; - if (set(dayOfWeek)) return 24 * 7; - if (set(hour)) return 24; - return 1; -} - -/** The cadence of a lane that declares several crons: its most frequent one. */ -export function laneCadenceHours(crons: readonly string[]): number { - const hours = crons.map(cadenceHours); - if (hours.length === 0) throw new Error('A scheduled lane must declare at least one cron'); - return Math.min(...hours); -} - -export type LaneObservation = { - /** Workflow file name, e.g. `mutation-weekly.yml`. */ - readonly lane: string; - readonly cadenceHours: number; - /** ISO timestamp of the most recent scheduled run, or undefined if never. */ - readonly lastRunAt: string | undefined; - /** ISO timestamp of the most recent successful scheduled run. */ - readonly lastSuccessAt: string | undefined; - /** Conclusions of recent scheduled runs, newest first. */ - readonly recentConclusions: readonly string[]; - /** - * When the workflow file was last committed. A lane merged an hour ago has - * legitimately never run; only one that has been declared for two cadences - * without firing is dark. - */ - readonly declaredAt: string | undefined; -}; - -export type LaneVerdict = { - readonly lane: string; - /** `fresh` | `dark` (missed two cadences) | `failing` (two consecutive) | `never-run`. */ - readonly status: 'fresh' | 'dark' | 'failing' | 'never-run'; - readonly detail: string; -}; - -/** - * A lane is unhealthy when it misses two consecutive cadences (dark) or fails - * two consecutive cadences (failing) — one miss is a runner hiccup, two is the - * lane not doing its job. Both are the alert condition #1430 specifies. - */ -export function evaluateLane(observation: LaneObservation, now: number): LaneVerdict { - const { lane, cadenceHours: cadence, lastRunAt, recentConclusions, declaredAt } = observation; - const window = cadence * 2 * 3600_000; - if (!lastRunAt) { - const declaredMs = declaredAt ? now - Date.parse(declaredAt) : Number.POSITIVE_INFINITY; - if (declaredMs <= window) { - return { lane, status: 'fresh', detail: 'declared recently, awaiting its first run' }; - } - return { lane, status: 'never-run', detail: 'no scheduled run recorded since it was declared' }; - } - const ageMs = now - Date.parse(lastRunAt); - if (ageMs > window) { - const days = (ageMs / 86_400_000).toFixed(1); - return { - lane, - status: 'dark', - detail: `last scheduled run ${days}d ago, cadence ${cadence}h (two cadences missed)`, - }; - } - const failures = recentConclusions.filter((conclusion) => conclusion !== 'cancelled'); - if ( - failures.length >= 2 && - failures.slice(0, 2).every((conclusion) => conclusion !== 'success') - ) { - return { - lane, - status: 'failing', - detail: `two consecutive scheduled runs concluded ${failures.slice(0, 2).join(', ')}`, - }; - } - return { lane, status: 'fresh', detail: `last scheduled run ${lastRunAt}` }; -} - -export function unhealthy(verdicts: readonly LaneVerdict[]): LaneVerdict[] { - return verdicts.filter((verdict) => verdict.status !== 'fresh'); -} - -export function renderLaneHealth(verdicts: readonly LaneVerdict[]): string { - const rows = [...verdicts] - .sort((a, b) => a.lane.localeCompare(b.lane)) - .map((verdict) => `| \`${verdict.lane}\` | ${verdict.status} | ${verdict.detail} |`) - .join('\n'); - const bad = unhealthy(verdicts); - const headline = - bad.length === 0 - ? `All ${verdicts.length} scheduled lane(s) fresh.` - : `${bad.length} of ${verdicts.length} scheduled lane(s) need attention: ` + - bad.map((verdict) => verdict.lane).join(', '); - return `## Scheduled lane health\n\n${headline}\n\n| Lane | Status | Detail |\n| --- | --- | --- |\n${rows}\n`; -} diff --git a/scripts/lane-health/run.ts b/scripts/lane-health/run.ts deleted file mode 100644 index c36a3e020..000000000 --- a/scripts/lane-health/run.ts +++ /dev/null @@ -1,179 +0,0 @@ -// Scheduled-lane health check (issue #1430): does every `schedule:`-triggered -// workflow still run, and still pass? -// -// pnpm lane-health report to stdout + job summary -// pnpm lane-health --alert also open/ping the tracking issue -// -// The lane list is DERIVED from .github/workflows/ — a hand-maintained list would -// omit exactly the new lane nobody is watching yet. Run observations come from -// the GitHub API through `gh`, so this needs no elevated scope beyond the token -// the workflow already has. - -import fs from 'node:fs'; -import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { runCmdSync } from '../../src/utils/exec.ts'; -import { parseScriptArgs } from '../lib/cli-args.ts'; -import { - evaluateLane, - laneCadenceHours, - renderLaneHealth, - unhealthy, - type LaneObservation, - type LaneVerdict, -} from './cadence.ts'; - -const repoRoot = runCmdSync('git', ['rev-parse', '--show-toplevel']).stdout.trim(); -const WORKFLOW_DIR = '.github/workflows'; -const ALERT_TITLE = 'Scheduled lane health: a lane is dark or failing'; -const ALERT_LABEL = 'ci'; - -const USAGE = `Usage: pnpm lane-health [options] - - --alert Open or ping the tracking issue when a lane is unhealthy - --repo Repository to query (default: the checkout's origin) -`; - -/** - * The cron expressions each workflow declares, keyed by file name. Parsed by - * shape rather than with a YAML dependency: `schedule:` blocks are a flat list of - * `- cron: "..."` lines, and `scheduledLanes` is asserted against the real - * workflow directory in the tests. - */ -export function scheduledLanes(dir: string): Map { - const lanes = new Map(); - for (const file of fs.readdirSync(dir).sort()) { - if (!file.endsWith('.yml') && !file.endsWith('.yaml')) continue; - const yaml = fs.readFileSync(path.join(dir, file), 'utf8'); - const crons = [...yaml.matchAll(/^\s*-\s*cron:\s*["']?(?[^"'\n]+?)["']?\s*$/gm)].map( - (match) => match.groups!.cron, - ); - if (crons.length > 0) lanes.set(file, crons); - } - return lanes; -} - -type WorkflowRun = { conclusion: string | null; createdAt: string; status: string }; - -function gh(args: readonly string[]): string { - return runCmdSync('gh', [...args], { cwd: repoRoot, allowFailure: true }).stdout; -} - -/** Recent scheduled runs of one workflow, newest first. */ -function recentRuns(lane: string, repo: string): WorkflowRun[] { - const raw = gh([ - 'run', - 'list', - '--workflow', - lane, - '--event', - 'schedule', - '--limit', - '10', - '--json', - 'conclusion,createdAt,status', - '--repo', - repo, - ]); - if (!raw.trim()) return []; - return JSON.parse(raw) as WorkflowRun[]; -} - -export function observe( - lane: string, - crons: readonly string[], - runs: readonly WorkflowRun[], - declaredAt?: string, -): LaneObservation { - const finished = runs.filter((run) => run.status === 'completed'); - return { - lane, - cadenceHours: laneCadenceHours(crons), - lastRunAt: runs[0]?.createdAt, - lastSuccessAt: finished.find((run) => run.conclusion === 'success')?.createdAt, - recentConclusions: finished.map((run) => run.conclusion ?? 'unknown'), - declaredAt, - }; -} - -/** When the workflow file last changed — the earliest its schedule could fire. */ -function declaredAt(lane: string): string | undefined { - const iso = runCmdSync('git', ['log', '-1', '--format=%cI', '--', `${WORKFLOW_DIR}/${lane}`], { - cwd: repoRoot, - allowFailure: true, - }).stdout.trim(); - return iso || undefined; -} - -function appendSummary(markdown: string): void { - const summary = process.env.GITHUB_STEP_SUMMARY; - if (summary) fs.appendFileSync(summary, markdown); - process.stdout.write(markdown); -} - -/** One tracking issue, pinged on every unhealthy sweep — never a new issue per run. */ -function alert(verdicts: readonly LaneVerdict[], repo: string): void { - const body = renderLaneHealth(verdicts); - const existing = gh([ - 'issue', - 'list', - '--search', - `${ALERT_TITLE} in:title`, - '--state', - 'open', - '--json', - 'number', - '--repo', - repo, - ]); - const open = (existing.trim() ? (JSON.parse(existing) as { number: number }[]) : [])[0]; - if (open) { - gh(['issue', 'comment', String(open.number), '--body', body, '--repo', repo]); - process.stdout.write(`lane-health: pinged issue #${open.number}\n`); - return; - } - gh([ - 'issue', - 'create', - '--title', - ALERT_TITLE, - '--body', - body, - '--label', - ALERT_LABEL, - '--repo', - repo, - ]); - process.stdout.write('lane-health: opened the tracking issue\n'); -} - -function originRepo(): string { - const url = runCmdSync('git', ['remote', 'get-url', 'origin'], { - cwd: repoRoot, - allowFailure: true, - }).stdout.trim(); - return url.replace(/^.*[:/]([^/]+\/[^/]+?)(?:\.git)?$/, '$1'); -} - -function main(argv = process.argv.slice(2)): number { - const values = parseScriptArgs(argv, USAGE, { - alert: { type: 'boolean', default: false }, - repo: { type: 'string' }, - }); - const repo = values.repo ?? process.env.GITHUB_REPOSITORY ?? originRepo(); - const lanes = scheduledLanes(path.join(repoRoot, WORKFLOW_DIR)); - const now = Date.now(); - const verdicts = [...lanes].map(([lane, crons]) => - evaluateLane(observe(lane, crons, recentRuns(lane, repo), declaredAt(lane)), now), - ); - appendSummary(renderLaneHealth(verdicts)); - const bad = unhealthy(verdicts); - if (bad.length > 0 && values.alert) alert(bad, repo); - // Reporting, not gating: a dark lane is tracked in the issue, and failing this - // job would only add a second red lane nobody is watching. - return 0; -} - -if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { - process.exitCode = main(); -} diff --git a/scripts/mutation/modules.test.ts b/scripts/mutation/modules.test.ts index 05e6062a6..046c04d2e 100644 --- a/scripts/mutation/modules.test.ts +++ b/scripts/mutation/modules.test.ts @@ -9,6 +9,7 @@ import { KERNEL_MODULES, moduleForFile, mutateGlobs, + shardMatrix, } from './modules.ts'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -52,3 +53,25 @@ test('mutate globs default to every module and narrow on request', () => { assert.deepEqual(mutateGlobs(), mutateGlobs(ALL_MODULE_IDS)); assert.deepEqual(mutateGlobs(['kernel-errors']), ['src/kernel/errors.ts']); }); + +// One job per module is only affordable while a module fits the lane's budget; +// the shard count is registry data so the workflows and the runner agree on it. +test('the shard matrix slices only the modules that declare shards', () => { + assert.deepEqual(shardMatrix(['kernel-errors']), [ + { name: 'kernel-errors', module: 'kernel-errors' }, + ]); + assert.deepEqual(shardMatrix(['selectors']), [ + { name: 'selectors-1', module: 'selectors', shard: '1/4' }, + { name: 'selectors-2', module: 'selectors', shard: '2/4' }, + { name: 'selectors-3', module: 'selectors', shard: '3/4' }, + { name: 'selectors-4', module: 'selectors', shard: '4/4' }, + ]); + assert.equal(shardMatrix().length, ALL_MODULE_IDS.length + 3); + // Every registry module reaches the matrix: an unsharded sweep is not a sweep. + for (const id of ALL_MODULE_IDS) { + assert.ok( + shardMatrix().some((spec) => spec.module === id), + `${id} has no shard`, + ); + } +}); diff --git a/scripts/mutation/modules.ts b/scripts/mutation/modules.ts index fa127eba8..bd213a089 100644 --- a/scripts/mutation/modules.ts +++ b/scripts/mutation/modules.ts @@ -29,6 +29,12 @@ export type KernelModule = { * import graph in `ownership.ts`, never listed here. */ readonly owns: readonly string[]; + /** + * How many parallel jobs the module's mutants are sliced across. One job per + * module is the default; a module big enough to outrun the lane's 30-minute + * budget declares more (`--shard i/n` picks the slice). + */ + readonly shards?: number; }; export const KERNEL_MODULES: readonly KernelModule[] = [ @@ -62,6 +68,9 @@ export const KERNEL_MODULES: readonly KernelModule[] = [ mutate: ['src/selectors/**/*.ts', '!src/selectors/**/*.test.ts', '!src/selectors/__tests__/**'], // Selector tests live under the owned directory, so the prefix covers them. owns: ['src/selectors/'], + // ~1,280 mutants at the observed ~3s/mutant on a 2-core runner is ~64 + // minutes in one job — past the acceptance budget and its own timeout. + shards: 4, }, ]; @@ -84,6 +93,26 @@ export function mutateGlobs(ids: readonly ModuleId[] = ALL_MODULE_IDS): string[] ]); } +/** One mutation job: a module, optionally one slice of it. */ +export type ShardSpec = { name: string; module: ModuleId; shard?: string }; + +/** + * The jobs a module set expands into. Both workflows' matrices are this list, so + * a registry module (or a change to its shard count) can never leave the sweep + * without the workflow assertions noticing. + */ +export function shardMatrix(ids: readonly ModuleId[] = ALL_MODULE_IDS): ShardSpec[] { + return KERNEL_MODULES.filter((module) => ids.includes(module.id)).flatMap((module) => { + const count = module.shards ?? 1; + if (count === 1) return [{ name: module.id, module: module.id }]; + return Array.from({ length: count }, (_unused, index) => ({ + name: `${module.id}-${index + 1}`, + module: module.id, + shard: `${index + 1}/${count}`, + })); + }); +} + export function normalizePath(filePath: string): string { return filePath.replaceAll('\\', '/').replace(/^\.\//, ''); } diff --git a/scripts/mutation/run.ts b/scripts/mutation/run.ts index 7485cd0d6..e0865cd45 100644 --- a/scripts/mutation/run.ts +++ b/scripts/mutation/run.ts @@ -18,7 +18,15 @@ import { pathToFileURL } from 'node:url'; import { runCmdStreaming, runCmdSync } from '../../src/utils/exec.ts'; import { parseScriptArgs } from '../lib/cli-args.ts'; import { laneEnvelope } from '../lib/lane-envelope.ts'; -import { ALL_MODULE_IDS, isModuleId, mutateGlobs, type ModuleId } from './modules.ts'; +import { + ALL_MODULE_IDS, + isModuleId, + mutateGlobs, + normalizePath, + shardMatrix, + type ModuleId, + type ShardSpec, +} from './modules.ts'; import { derivedAffectedModules } from './ownership.ts'; import { renderReport } from './report.ts'; import { @@ -53,10 +61,16 @@ const USAGE = `Usage: pnpm mutation:run [options] --base Base ref for --affected (default: origin/main) --report Read an existing Stryker JSON report instead of running Stryker --report-dir Merge every *.json Stryker report under (weekly shards) + --expect-shards + Fail unless --report-dir holds exactly n shard reports + --shard Mutate only the i-th of n balanced slices of the module's + sources (the big modules exceed one job's budget) --update Record the run into the baseline (ratchet + graduation) --summary Also write the markdown report to --no-run Alias for --report with the default report path - --list-affected Print the affected module ids as JSON and exit (shard matrix) + --list-affected Print the PR lane's shard matrix as JSON and exit (empty + until the baseline graduates, unless the diff touches the + lane's own tooling) --fail-envelope Write a failed lane envelope for a step that ran before (or instead of) the sweep, e.g. a self-test failure @@ -72,8 +86,24 @@ type Args = { summary: string | undefined; listAffected: boolean; failEnvelope: string | undefined; + shard: Shard | undefined; + expectShards: number | undefined; }; +/** One-based slice of a module's mutated sources: `--shard 2/4`. */ +type Shard = { index: number; count: number }; + +function parseShard(value: string | undefined): Shard | undefined { + if (!value) return undefined; + const match = /^(\d+)\/(\d+)$/.exec(value.trim()); + const index = Number(match?.[1]); + const count = Number(match?.[2]); + if (!match || index < 1 || index > count) { + throw new Error(`--shard expects i/n with 1 <= i <= n, got "${value}"`); + } + return { index, count }; +} + function parseModules(value: string | undefined): readonly ModuleId[] { if (!value) return ALL_MODULE_IDS; const ids = value @@ -101,6 +131,8 @@ function parseMutationArgs(argv: readonly string[]): Args { 'no-run': { type: 'boolean', default: false }, 'list-affected': { type: 'boolean', default: false }, 'fail-envelope': { type: 'string' }, + shard: { type: 'string' }, + 'expect-shards': { type: 'string' }, }); return { modules: parseModules(values.modules), @@ -112,6 +144,8 @@ function parseMutationArgs(argv: readonly string[]): Args { summary: values.summary, listAffected: Boolean(values['list-affected']), failEnvelope: values['fail-envelope'], + shard: parseShard(values.shard), + expectShards: values['expect-shards'] ? Number(values['expect-shards']) : undefined, }; } @@ -153,22 +187,53 @@ function changedFiles(base: string): string[] { // The JSON report path comes from the config (Stryker's CLI takes no nested // reporter options), so a run always writes DEFAULT_REPORT_PATH. -async function runStryker(modules: readonly ModuleId[], reportPath: string): Promise { +/** + * Balances a module's sources across `count` jobs. Mutant count tracks file size + * closely enough that greedy longest-first packing keeps the slowest slice near + * the mean — the alternative is one 1,277-mutant selectors job that outruns both + * the 30-minute acceptance budget and its own timeout. + */ +function shardFiles(files: readonly string[], shard: Shard, root: string): string[] { + const bins: { size: number; files: string[] }[] = Array.from({ length: shard.count }, () => ({ + size: 0, + files: [], + })); + const weighed = files + .map((file) => ({ file, size: fs.statSync(path.join(root, file)).size })) + .sort((a, b) => b.size - a.size || a.file.localeCompare(b.file)); + for (const { file, size } of weighed) { + const bin = bins.reduce((smallest, next) => (next.size < smallest.size ? next : smallest)); + bin.files.push(file); + bin.size += size; + } + return bins[shard.index - 1]!.files.sort(); +} + +async function runStryker( + modules: readonly ModuleId[], + reportPath: string, + shard: Shard | undefined, +): Promise { const absolute = path.isAbsolute(reportPath) ? reportPath : path.join(repoRoot, reportPath); fs.mkdirSync(path.dirname(absolute), { recursive: true }); fs.rmSync(absolute, { force: true }); // The suite Stryker replays per mutant is derived from Vitest's module graph // over the mutated files, not hand-listed; see scripts/mutation/test-scope.ts. const globs = mutateGlobs(modules); + const all = expandMutateFiles(globs, repoRoot); + // A sharded job mutates concrete files, so the slice is exact rather than a + // glob the next contributor has to keep in step with the registry. + const mutate = shard ? shardFiles(all, shard, repoRoot) : globs; const scopePath = path.join(repoRoot, TEST_SCOPE_PATH); - const testFiles = relatedTestFiles(expandMutateFiles(globs, repoRoot), repoRoot); + const testFiles = relatedTestFiles(shard ? mutate : all, repoRoot); writeTestScope(testFiles, scopePath); process.stdout.write( - `mutation: ${modules.join(', ')} -> ${testFiles.length} related test file(s)\n`, + `mutation: ${modules.join(', ')}${shard ? ` shard ${shard.index}/${shard.count}` : ''} -> ` + + `${shard ? mutate.length : all.length} source(s), ${testFiles.length} related test file(s)\n`, ); // Stryker's `--mutate` takes one comma-separated value, not repeated args. - const args = ['exec', 'stryker', 'run', CONFIG_PATH, '--mutate', globs.join(',')]; + const args = ['exec', 'stryker', 'run', CONFIG_PATH, '--mutate', mutate.join(',')]; const result = await runCmdStreaming('pnpm', args, { cwd: repoRoot, allowFailure: true, @@ -296,7 +361,7 @@ function readReport(file: string): StrykerReport { return JSON.parse(fs.readFileSync(absolute, 'utf8')) as StrykerReport; } -function readShardedReports(dir: string): StrykerReport { +function readShardedReports(dir: string, expected: number | undefined): StrykerReport { const root = path.isAbsolute(dir) ? dir : path.join(repoRoot, dir); // Shard artifacts also carry the lane envelope and the derived test scope, so // the report is selected by name rather than by "every .json here". @@ -305,6 +370,15 @@ function readShardedReports(dir: string): StrykerReport { .map((file) => path.join(root, file)) .sort(); if (files.length === 0) throw new Error(`No Stryker JSON reports found under ${dir}`); + // Sub-sharded modules make "every module has mutants" too weak on its own: the + // surviving slices would still cover the module, so the expected shard count is + // asserted as well. + if (expected !== undefined && files.length !== expected) { + throw new Error( + `Incomplete shard set from ${dir}: ${files.length} report(s), expected ${expected}. ` + + 'A shard job failed or its artifact is absent; the aggregate is not a sweep.', + ); + } process.stdout.write(`mutation: merging ${files.length} shard report(s) from ${dir}\n`); return mergeReports(files.map(readReport)); } @@ -312,8 +386,9 @@ function readShardedReports(dir: string): StrykerReport { async function produceReport( modules: readonly ModuleId[], reportPath: string | undefined, + shard: Shard | undefined, ): Promise { - if (!reportPath) await runStryker(modules, DEFAULT_REPORT_PATH); + if (!reportPath) await runStryker(modules, DEFAULT_REPORT_PATH, shard); return readReport(reportPath ?? DEFAULT_REPORT_PATH); } @@ -351,8 +426,8 @@ function assertShardsCoverModules(state: LaneState, dir: string): void { async function scoreModules(args: Args, state: LaneState): Promise { state.stage = args.reportDir || args.report ? 'report' : 'stryker'; const report = args.reportDir - ? readShardedReports(args.reportDir) - : await produceReport(state.modules, args.report); + ? readShardedReports(args.reportDir, args.expectShards) + : await produceReport(state.modules, args.report, args.shard); state.stage = 'ratchet'; state.scores = summarizeReport(report, state.modules); @@ -363,7 +438,11 @@ async function sweep(args: Args, state: LaneState): Promise { state.stage = 'select'; // Test attribution is derived from the import graph, not a listed set of test // files: see scripts/mutation/ownership.ts. - if (args.affected) state.modules = derivedAffectedModules(changedFiles(args.base), repoRoot); + // Same selection the PR matrix uses, graduation rule included, so the ratchet + // job can never run mutants the `select` job decided not to spend. + if (args.affected) { + state.modules = [...new Set(affectedMatrix(args.base).map((entry) => entry.module))]; + } if (state.modules.length === 0) { process.stdout.write('mutation: no decision-kernel modules affected — nothing to mutate.\n'); state.stage = 'complete'; @@ -384,6 +463,25 @@ async function sweep(args: Args, state: LaneState): Promise { return result.failed ? 1 : 0; } +/** Sources of the lane itself: a change here must prove itself on real mutants. */ +const LANE_TOOLING = ['scripts/mutation/', 'scripts/lib/', 'stryker.config.json', 'mutation-']; + +/** + * The PR lane's matrix. Before graduation the affected run is a report nobody + * acts on, so it costs runner minutes for no verdict: it stays empty until the + * baseline reaches `gating: true`. The exception is a diff that changes the lane + * itself — that is the one case where the pre-graduation run buys something, + * because the gate has to be proven before it can bite. + */ +function affectedMatrix(base: string): ShardSpec[] { + const changed = changedFiles(base); + const touchesLane = changed.some((file) => + LANE_TOOLING.some((prefix) => normalizePath(file).startsWith(prefix)), + ); + if (!readBaseline().gating && !touchesLane) return []; + return shardMatrix(derivedAffectedModules(changed, repoRoot)); +} + /** * Everything after `--help` runs inside the envelope boundary, argument parsing * and provenance reads included: a lane that cannot even read its own config is @@ -411,9 +509,7 @@ async function main(argv = process.argv.slice(2)): Promise { // Selection only — no lane run, so no envelope. The shard jobs that consume // this matrix each write their own. const args = parseMutationArgs(argv); - process.stdout.write( - `${JSON.stringify(derivedAffectedModules(changedFiles(args.base), repoRoot))}\n`, - ); + process.stdout.write(`${JSON.stringify(affectedMatrix(args.base))}\n`); return 0; } const state: LaneState = { diff --git a/scripts/mutation/workflow.test.ts b/scripts/mutation/workflow.test.ts index 774fdebf1..2cbeb2788 100644 --- a/scripts/mutation/workflow.test.ts +++ b/scripts/mutation/workflow.test.ts @@ -7,7 +7,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; -import { ALL_MODULE_IDS, KERNEL_MODULES } from './modules.ts'; +import { KERNEL_MODULES, shardMatrix } from './modules.ts'; const repoRoot = path.resolve(import.meta.dirname, '../..'); @@ -15,16 +15,44 @@ function workflow(name: string): string { return fs.readFileSync(path.join(repoRoot, '.github/workflows', name), 'utf8'); } -test('the weekly sweep shards exactly the registry modules', () => { +test('the weekly sweep shards exactly the registry matrix', () => { const yaml = workflow('mutation-weekly.yml'); - const shards = [...yaml.matchAll(/^ {10}- (?[a-z-]+)$/gm)].map((match) => match.groups!.id); - assert.deepEqual(shards, [...ALL_MODULE_IDS]); + const jobs = [...yaml.matchAll(/^ {10}- \{ (?[^}]+) \}$/gm)].map((match) => + Object.fromEntries( + match + .groups!.entry.split(', ') + .map((pair) => pair.split(': ') as [string, string]) + .map(([key, value]) => [key, value]), + ), + ); + assert.deepEqual( + jobs, + shardMatrix().map((spec) => + spec.shard ? { ...spec } : { name: spec.name, module: spec.module }, + ), + ); }); 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, /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`), + 'the weekly ratchet does not require the full shard set', + ); +}); + +// A shard that outruns the job timeout reports nothing, so the per-shard budget +// is the acceptance criterion made mechanical. +test('no mutation shard is allowed to exceed the 30-minute budget', () => { + for (const name of ['mutation-weekly.yml', 'mutation-affected.yml']) { + for (const [, minutes] of workflow(name).matchAll(/timeout-minutes: (\d+)/g)) { + assert.ok(Number(minutes) <= 30, `${name} declares a ${minutes}-minute job`); + } + } }); test('every kernel path a PR can touch selects the affected mutation job', () => { diff --git a/stryker.config.json b/stryker.config.json index b12a35340..8768e845e 100644 --- a/stryker.config.json +++ b/stryker.config.json @@ -2,7 +2,7 @@ "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", "_comment": "Decision-kernel mutation lane (issue #1415). The mutate list below MUST mirror KERNEL_MODULES in scripts/mutation/modules.ts — scripts/mutation/config.test.ts asserts it. Ratchet verdicts come from scripts/mutation/run.ts, never from Stryker's own thresholds. Any edit here changes the config content hash, which invalidates every recorded baseline as non-comparable until re-recorded with `pnpm mutation:baseline`.", "packageManager": "pnpm", - "tsconfigFile_comment": "Points at a path that does not exist on purpose. Stryker's sandbox rewrites tsconfig `extends`/`references` through the classic TypeScript API, which the repo's TypeScript 7 native package no longer exposes (`ts.parseConfigFileTextToJson is not a function`). Our tsconfig has neither extends nor references, so there is nothing to rewrite — skipping the preprocessor is exact, not a workaround for a real dependency.", + "tsconfigFile_comment": "Points at a path that does not exist on purpose. Stryker's sandbox rewrites tsconfig `extends`/`references` through the classic TypeScript API, which the repo's TypeScript 7 native package no longer exposes (`ts.parseConfigFileTextToJson is not a function`). Our tsconfig has neither extends nor references, so there is nothing to rewrite — skipping the preprocessor is exact, not a workaround for a real dependency. The behaviour relied on is that the preprocessor no-ops when the file is absent: see packages/core/src/sandbox/ts-config-preprocessor.ts (https://github.com/stryker-mutator/stryker-js/blob/master/packages/core/src/sandbox/ts-config-preprocessor.ts) and https://stryker-mutator.io/docs/stryker-js/configuration/#tsconfigfile-string. Re-check it on every Stryker upgrade: if a future version errors on a missing tsconfig instead, point this at a real, extends-free tsconfig.", "tsconfigFile": "tsconfig.stryker-absent.json", "plugins_comment": "Named explicitly: the default `@stryker-mutator/*` glob does not resolve under pnpm's non-flat node_modules, so plugin discovery finds no test runner.", "plugins": ["@stryker-mutator/vitest-runner"], From c60890abced95b197f2377c7d7b2cda2cda562f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 28 Jul 2026 06:13:14 +0000 Subject: [PATCH 09/11] docs: describe registry sharding and the deferred PR mutation lane Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/agents/testing.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 15929ed90..a9d27bc56 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -136,21 +136,26 @@ pnpm mutation:check # ratchet an existing .tmp/mutation/muta pnpm mutation:baseline # full sweep, then record it (reviewed commit) ``` -- **Weekly full sweep** (`.github/workflows/mutation-weekly.yml`) shards one job per module so no job - approaches the 30-minute budget, then merges the shard reports (`--report-dir`) for one verdict, +- **Weekly full sweep** (`.github/workflows/mutation-weekly.yml`) runs `shardMatrix()` from the + registry: one job per module, except modules that declare a `shards` count and are sliced with + `--shard i/n` (selectors is ~1,280 mutants, well past the 30-minute budget in one job). The ratchet + merges the shard reports (`--report-dir`) for one verdict and requires the full set + (`--expect-shards`), so a dead shard fails the lane instead of scoring its module as 0. Results are reported as a job summary plus an artifact. It never commits: the proposed baseline rides in the artifact, and applying it is a reviewed `pnpm mutation:baseline` commit, so a score cannot lower itself. -- **PR lane** (`.github/workflows/mutation-affected.yml`) derives the affected modules - (`--list-affected`), shards one job per module, and merges them into one verdict. +- **PR lane** (`.github/workflows/mutation-affected.yml`) derives the affected shard matrix + (`--list-affected`) and merges the shards into one verdict. Before graduation the matrix is empty — + a report nobody acts on is not worth the runner minutes — unless the diff touches the lane's own + tooling, the one pre-graduation run that buys something: the gate has to be proven before it bites. - **Ratchet**: scores may only rise. `mutation-baselines/decision-kernels.json` records the high-water score per module plus the Stryker version and config content hash that produced it, so a score change caused by a tool/config change is reported as provenance drift, never as a test-strength regression. - **Graduation, not a flag day**: gating is off until two consecutive comparable weekly sweeps pass - (`stableRuns`/`requiredStableRuns` in the baseline); the PR job runs the whole time and only starts - failing once the committed baseline says `gating: true`. A regression or provenance drift resets the - counter. + (`stableRuns`/`requiredStableRuns` in the baseline); the PR job starts selecting modules — and + failing on them — once the committed baseline says `gating: true`. A regression or provenance drift + resets the counter. - **Test scope** is derived from Vitest's module graph (`vitest related` over the mutated files), the same delegation `pnpm check:affected` uses; see `scripts/mutation/test-scope.ts` for the three groups it drops and why dropping them cannot hide a surviving mutant. From 0ce6b338f790d8926503a036b633da784c60db5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 28 Jul 2026 06:33:48 +0000 Subject: [PATCH 10/11] fix(mutation): make the pre-graduation tooling exception select real mutants Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/agents/testing.md | 4 ++ scripts/mutation/modules.ts | 8 ++++ scripts/mutation/run.ts | 21 +++++++-- scripts/mutation/selection.test.ts | 76 ++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 scripts/mutation/selection.test.ts diff --git a/docs/agents/testing.md b/docs/agents/testing.md index a9d27bc56..dd8780417 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -148,6 +148,10 @@ pnpm mutation:baseline # full sweep, then record it (reviewed c (`--list-affected`) and merges the shards into one verdict. Before graduation the matrix is empty — a report nobody acts on is not worth the runner minutes — unless the diff touches the lane's own tooling, the one pre-graduation run that buys something: the gate has to be proven before it bites. + Lane sources own no kernel, so that exception adds `LANE_CANARY` (`kernel-errors`, the registry's + cheapest real sweep) to whatever the diff derives; otherwise it would select zero mutants and prove + nothing. `scripts/mutation/selection.test.ts` drives both halves of the rule through the real CLI + against a throwaway worktree commit. - **Ratchet**: scores may only rise. `mutation-baselines/decision-kernels.json` records the high-water score per module plus the Stryker version and config content hash that produced it, so a score change caused by a tool/config change is reported as provenance drift, never as a diff --git a/scripts/mutation/modules.ts b/scripts/mutation/modules.ts index bd213a089..df398b658 100644 --- a/scripts/mutation/modules.ts +++ b/scripts/mutation/modules.ts @@ -93,6 +93,14 @@ export function mutateGlobs(ids: readonly ModuleId[] = ALL_MODULE_IDS): string[] ]); } +/** + * The module a lane-tooling change proves itself against before graduation. + * `kernel-errors` is the cheapest real sweep in the registry (one file, ~183 + * mutants), so a change to the ratchet, the config, or the baseline runs actual + * mutants end to end without paying for the full sweep. + */ +export const LANE_CANARY: ModuleId = 'kernel-errors'; + /** One mutation job: a module, optionally one slice of it. */ export type ShardSpec = { name: string; module: ModuleId; shard?: string }; diff --git a/scripts/mutation/run.ts b/scripts/mutation/run.ts index e0865cd45..db608ae0e 100644 --- a/scripts/mutation/run.ts +++ b/scripts/mutation/run.ts @@ -21,6 +21,7 @@ import { laneEnvelope } from '../lib/lane-envelope.ts'; import { ALL_MODULE_IDS, isModuleId, + LANE_CANARY, mutateGlobs, normalizePath, shardMatrix, @@ -473,13 +474,25 @@ const LANE_TOOLING = ['scripts/mutation/', 'scripts/lib/', 'stryker.config.json' * itself — that is the one case where the pre-graduation run buys something, * because the gate has to be proven before it can bite. */ -function affectedMatrix(base: string): ShardSpec[] { - const changed = changedFiles(base); +export function affectedMatrixFor( + changed: readonly string[], + gating: boolean, + root: string = repoRoot, +): ShardSpec[] { const touchesLane = changed.some((file) => LANE_TOOLING.some((prefix) => normalizePath(file).startsWith(prefix)), ); - if (!readBaseline().gating && !touchesLane) return []; - return shardMatrix(derivedAffectedModules(changed, repoRoot)); + if (!gating && !touchesLane) return []; + const modules = new Set(derivedAffectedModules(changed, root)); + // Lane sources own no kernel, so a tooling-only diff derives nothing: without + // the canary the "prove the gate" exception would select zero mutants and + // prove nothing. + if (touchesLane) modules.add(LANE_CANARY); + return shardMatrix(ALL_MODULE_IDS.filter((id) => modules.has(id))); +} + +function affectedMatrix(base: string): ShardSpec[] { + return affectedMatrixFor(changedFiles(base), readBaseline().gating); } /** diff --git a/scripts/mutation/selection.test.ts b/scripts/mutation/selection.test.ts new file mode 100644 index 000000000..c4a8eeeda --- /dev/null +++ b/scripts/mutation/selection.test.ts @@ -0,0 +1,76 @@ +// Selection is what the PR lane spends money on, so both halves of the rule are +// asserted end to end through the real CLI: nothing runs before graduation, and +// the "prove the gate" exception for a lane-tooling diff selects real mutants +// rather than an empty matrix that proves nothing. + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { after, test } from 'node:test'; +import { runCmdSync } from '../../src/utils/exec.ts'; +import { LANE_CANARY, shardMatrix, type ShardSpec } from './modules.ts'; +import { affectedMatrixFor } from './run.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const worktrees: string[] = []; + +after(() => { + for (const dir of worktrees) { + runCmdSync('git', ['worktree', 'remove', '--force', dir], { cwd: repoRoot }); + } +}); + +/** + * A throwaway worktree holding one commit that touches only the given files, so + * `--list-affected` runs against a real `git diff` rather than a stubbed list. + */ +function worktreeWithCommit(name: string, files: readonly string[]): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `mutation-${name}-`)); + fs.rmSync(dir, { recursive: true }); + runCmdSync('git', ['worktree', 'add', '--detach', '--quiet', dir, 'HEAD'], { cwd: repoRoot }); + worktrees.push(dir); + for (const file of files) { + fs.appendFileSync(path.join(dir, file), '\n'); + } + runCmdSync('git', ['add', ...files], { cwd: dir }); + runCmdSync('git', ['commit', '--quiet', '--no-verify', '-m', `touch ${name}`], { cwd: dir }); + return dir; +} + +function listAffected(cwd: string): ShardSpec[] { + const result = runCmdSync( + 'node', + [ + '--experimental-strip-types', + path.join(repoRoot, 'scripts/mutation/run.ts'), + '--list-affected', + '--base', + 'HEAD~1', + ], + { cwd }, + ); + assert.equal(result.exitCode, 0, result.stderr); + return JSON.parse(result.stdout.trim().split('\n').at(-1)!) as ShardSpec[]; +} + +test('a lane-tooling diff selects real mutants even before graduation', () => { + const dir = worktreeWithCommit('tooling', ['scripts/mutation/ratchet.ts']); + // The lane's own sources own no kernel, so derivation alone yields nothing: + // without the canary this exception would run zero mutants. + assert.deepEqual(listAffected(dir), shardMatrix([LANE_CANARY])); +}); + +test('a kernel diff selects nothing until the baseline graduates', () => { + const dir = worktreeWithCommit('kernel', ['src/utils/scroll-edge-state.ts']); + assert.deepEqual(listAffected(dir), []); + // …and the same diff selects that module once gating is on. + assert.deepEqual( + affectedMatrixFor(['src/utils/scroll-edge-state.ts'], true), + shardMatrix(['scroll-edge-state']), + ); +}); + +test('a docs-only diff selects nothing even once gating is on', () => { + assert.deepEqual(affectedMatrixFor(['docs/agents/testing.md'], true), []); +}); From 7a8ea6de08988803998f6a912a84c7e63112ad0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 28 Jul 2026 06:34:59 +0000 Subject: [PATCH 11/11] test(mutation): give the worktree fixture commits their own identity Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/mutation/selection.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/mutation/selection.test.ts b/scripts/mutation/selection.test.ts index c4a8eeeda..d6546f008 100644 --- a/scripts/mutation/selection.test.ts +++ b/scripts/mutation/selection.test.ts @@ -34,7 +34,23 @@ function worktreeWithCommit(name: string, files: readonly string[]): string { fs.appendFileSync(path.join(dir, file), '\n'); } runCmdSync('git', ['add', ...files], { cwd: dir }); - runCmdSync('git', ['commit', '--quiet', '--no-verify', '-m', `touch ${name}`], { cwd: dir }); + // CI runners have no committer identity configured, and this commit is a + // fixture, so it carries its own rather than depending on the environment. + runCmdSync( + 'git', + [ + '-c', + 'user.name=mutation-selection-test', + '-c', + 'user.email=mutation-selection-test@invalid', + 'commit', + '--quiet', + '--no-verify', + '-m', + `touch ${name}`, + ], + { cwd: dir }, + ); return dir; }