diff --git a/.fallowrc.json b/.fallowrc.json index 80ce2306c..8c9d51fdd 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" ] @@ -81,6 +81,20 @@ "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": "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/mutation-affected.yml b/.github/workflows/mutation-affected.yml new file mode 100644 index 000000000..c12a3e28e --- /dev/null +++ b/.github/workflows/mutation-affected.yml @@ -0,0 +1,178 @@ +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. +# +# 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 +# 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, 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/daemon/ref-frame.ts" + - "src/commands/interaction/runtime/settle.ts" + - "src/utils/scroll-edge-state.ts" + - "src/selectors/**" + - "src/**/*.test.ts" + - "scripts/mutation/**" + - "scripts/lib/**" + - "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: + select: + name: Select affected kernels + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + modules: ${{ steps.select.outputs.modules }} + 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 + + - id: select + name: Derive the affected shard matrix + run: | + modules=$(pnpm --silent mutation:affected --list-affected \ + --base "origin/${{ github.event.pull_request.base.ref }}" | tail -n1) + echo "modules=$modules" >> "$GITHUB_OUTPUT" + echo "Affected mutation shards: $modules" >> "$GITHUB_STEP_SUMMARY" + + # 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.name }}) + needs: select + if: needs.select.outputs.modules != '[]' + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: ${{ 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.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.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.name }} + 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 + 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 }}" + 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 + with: + name: mutation-affected + path: | + .tmp/mutation/shards + .tmp/mutation/lane-envelope.json + 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..99df7f329 --- /dev/null +++ b/.github/workflows/mutation-weekly.yml @@ -0,0 +1,153 @@ +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.name }}) + runs-on: ubuntu-latest + # 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, 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 + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + + - name: Run mutants for ${{ matrix.name }} + run: | + pnpm mutation:run --modules ${{ matrix.module }} \ + ${{ matrix.shard && format('--shard {0}', matrix.shard) || '' }} + + # 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.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.name }} + path: | + .tmp/mutation/mutation.json + .tmp/mutation/lane-envelope.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 --expect-shards 8 --update + 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 + 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 + with: + 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 6e778d1b2..dd8780417 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,64 @@ 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`) 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 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. + 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 + 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 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. +- **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, 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. + ## 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..a59c8eb7f 100644 --- a/package.json +++ b/package.json @@ -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/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)", @@ -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/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/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/envelope.test.ts b/scripts/mutation/envelope.test.ts new file mode 100644 index 000000000..ef5e61568 --- /dev/null +++ b/scripts/mutation/envelope.test.ts @@ -0,0 +1,276 @@ +// 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 { runCmdSync } from '../../src/utils/exec.ts'; +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); +}); + +// 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:/); +}); + +// 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, + }); +}); + +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 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`); + } +}); + +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 new file mode 100644 index 000000000..046c04d2e --- /dev/null +++ b/scripts/mutation/modules.test.ts @@ -0,0 +1,77 @@ +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, + shardMatrix, +} 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 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'); + // 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/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']); +}); + +// 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 new file mode 100644 index 000000000..df398b658 --- /dev/null +++ b/scripts/mutation/modules.ts @@ -0,0 +1,153 @@ +// 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. Sources + * only — the tests whose strength the score measures are derived from the + * 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[] = [ + { + 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__/**'], + // 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, + }, +]; + +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, + ]); +} + +/** + * 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 }; + +/** + * 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(/^\.\//, ''); +} + +/** + * Which kernel module owns a repository-relative *source* path, if any. + * + * 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) { + const match = owned.endsWith('/') ? normalized.startsWith(owned) : normalized === owned; + if (match) return module.id; + } + } + return undefined; +} + +/** 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) { + 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/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/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..db608ae0e --- /dev/null +++ b/scripts/mutation/run.ts @@ -0,0 +1,559 @@ +// 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 { laneEnvelope } from '../lib/lane-envelope.ts'; +import { + ALL_MODULE_IDS, + isModuleId, + LANE_CANARY, + mutateGlobs, + normalizePath, + shardMatrix, + type ModuleId, + type ShardSpec, +} from './modules.ts'; +import { derivedAffectedModules } from './ownership.ts'; +import { renderReport } from './report.ts'; +import { + applyRun, + emptyBaseline, + evaluateRatchet, + type Baseline, + type Provenance, + type RatchetResult, +} from './ratchet.ts'; +import { mergeReports, summarizeReport, type ModuleScore, 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 ENVELOPE_PATH = '.tmp/mutation/lane-envelope.json'; +const LANE_ID = 'mutation-decision-kernels'; + +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) + --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 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 +`; + +type Args = { + modules: readonly ModuleId[]; + affected: boolean; + base: string; + report: string | undefined; + reportDir: string | undefined; + update: boolean; + 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 + .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 }, + 'list-affected': { type: 'boolean', default: false }, + 'fail-envelope': { type: 'string' }, + shard: { type: 'string' }, + 'expect-shards': { type: 'string' }, + }); + 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, + listAffected: Boolean(values['list-affected']), + failEnvelope: values['fail-envelope'], + shard: parseShard(values.shard), + expectShards: values['expect-shards'] ? Number(values['expect-shards']) : undefined, + }; +} + +/** 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. +/** + * 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(shard ? mutate : all, repoRoot); + writeTestScope(testFiles, scopePath); + process.stdout.write( + `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', mutate.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); + } +} + +/** + * 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 = '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; + provenance: Provenance; + baseline: Baseline; + modules: readonly ModuleId[]; + affected: boolean; + scores: readonly ModuleScore[]; + result: RatchetResult | undefined; + error: string | undefined; + /** + * `--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, + * 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(state: LaneState, startedAtMs: number): void { + const target = path.join(repoRoot, ENVELOPE_PATH); + if (state.recoveryOnly && existingResult(target) === 'fail') { + process.stdout.write(`mutation: ${ENVELOPE_PATH} already reports a failure; left as is.\n`); + return; + } + const envelope = laneEnvelope({ + lane: LANE_ID, + commit: runCmdSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + allowFailure: true, + }).stdout.trim(), + tool: { stryker: state.provenance.strykerVersion }, + configHash: state.provenance.configHash, + startedAtMs, + result: state.stage === 'complete' && !state.result?.failed ? 'pass' : 'fail', + data: { + 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: state.result?.verdicts.find((verdict) => verdict.module === id)?.status ?? null, + }; + }), + gating: state.baseline.gating, + stableRuns: state.baseline.stableRuns, + requiredStableRuns: state.baseline.requiredStableRuns, + }, + }); + 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`, + ); +} + +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, 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". + const files = fs + .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}`); + // 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)); +} + +async function produceReport( + modules: readonly ModuleId[], + reportPath: string | undefined, + shard: Shard | undefined, +): Promise { + if (!reportPath) await runStryker(modules, DEFAULT_REPORT_PATH, shard); + return readReport(reportPath ?? DEFAULT_REPORT_PATH); +} + +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`, + ); +} + +/** + * 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, args.expectShards) + : await produceReport(state.modules, args.report, args.shard); + + 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. + // 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'; + return 0; + } + + await scoreModules(args, state); + 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, state.baseline, state.provenance, { title }), args.summary); + + if (args.update) recordRun(args, state, result); + state.stage = 'complete'; + 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. + */ +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 (!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); +} + +/** + * 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.recoveryOnly = true; + return 1; + } + return await sweep(args, state); +} + +async function main(argv = process.argv.slice(2)): Promise { + const startedAtMs = Date.now(); + 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(affectedMatrix(args.base))}\n`); + return 0; + } + const state: LaneState = { + stage: 'setup', + provenance: UNKNOWN_PROVENANCE, + baseline: emptyBaseline(), + modules: [], + affected: false, + scores: [], + result: undefined, + error: undefined, + recoveryOnly: false, + }; + try { + return await run(argv, 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); + } +} + +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/selection.test.ts b/scripts/mutation/selection.test.ts new file mode 100644 index 000000000..d6546f008 --- /dev/null +++ b/scripts/mutation/selection.test.ts @@ -0,0 +1,92 @@ +// 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 }); + // 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; +} + +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), []); +}); 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..2cbeb2788 --- /dev/null +++ b/scripts/mutation/workflow.test.ts @@ -0,0 +1,86 @@ +// 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 { KERNEL_MODULES, shardMatrix } 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 matrix', () => { + const yaml = workflow('mutation-weekly.yml'); + 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', () => { + 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}**` || + (glob.endsWith('/**') && owned.startsWith(glob.slice(0, -2))), + ); + 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/**']) { + assert.ok(paths.includes(own), `missing path filter ${own}`); + } +}); 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', { diff --git a/stryker.config.json b/stryker.config.json new file mode 100644 index 000000000..8768e845e --- /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. 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"], + "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'], + }, +});