diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml
index 41a71c8..5992b1e 100644
--- a/.github/workflows/release-gate.yml
+++ b/.github/workflows/release-gate.yml
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
- packages: ${{ steps.shards.outputs.packages }}
+ shards: ${{ steps.shards.outputs.shards }}
steps:
- uses: actions/checkout@v7
with:
@@ -34,16 +34,18 @@ jobs:
cat .cache/mutation-shards.out >> "$GITHUB_OUTPUT"
mutation:
- name: mutation · ${{ matrix.package }}
+ name: mutation · ${{ matrix.shard.package }}
needs: [plan]
runs-on: ubuntu-latest
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
- package: ${{ fromJSON(needs.plan.outputs.packages) }}
+ shard: ${{ fromJSON(needs.plan.outputs.shards) }}
env:
CI: "true"
+ PACKAGE: ${{ matrix.shard.package }}
+ MUTATE: ${{ join(matrix.shard.mutate, ',') }}
steps:
- uses: actions/checkout@v7
with:
@@ -55,10 +57,16 @@ jobs:
path: |
apps/*/reports/stryker-incremental.json
packages/*/reports/stryker-incremental.json
- key: stryker-${{ matrix.package }}-${{ github.sha }}
- restore-keys: stryker-${{ matrix.package }}-
+ key: stryker-${{ matrix.shard.package }}-${{ github.sha }}
+ restore-keys: stryker-${{ matrix.shard.package }}-
+ - name: Mutation dependencies
+ shell: bash
+ run: |
+ deps="$(nix develop --command pnpm exec turbo run mutation --filter="$PACKAGE" --dry=json | jq -r --arg self "$PACKAGE#mutation" '[.tasks[].taskId | select(. != $self)] | join(" ")')"
+ read -ra tasks <<< "$deps"
+ if [ "${#tasks[@]}" -gt 0 ]; then nix develop --command pnpm exec sandbox -- turbo run "${tasks[@]}"; fi
- name: Mutation at break 100
- run: nix develop --command pnpm exec sandbox --pass-env GITHUB_ACTIONS -- turbo run mutation --filter="${{ matrix.package }}"
+ run: nix develop --command pnpm exec sandbox --pass-env GITHUB_ACTIONS -- turbo run mutation --filter="$PACKAGE" --only -- --mutate "$MUTATE"
- if: always()
uses: actions/upload-artifact@v6
with:
diff --git a/README.md b/README.md
index b311150..1d1152c 100644
--- a/README.md
+++ b/README.md
@@ -198,7 +198,7 @@ Every configuration toggle provides a route for AI agents to downgrade verificat
How does mutation testing work in this template?
-Stryker introduces deliberate syntax and logic mutations into your code and runs your test suite against each mutant. If your tests still pass when code behavior changes, the mutant survives and the gate fails. Domain decisions require a 100% kill score. Mutation runs only in the release gate on pushes to `main`, never locally or on pull requests.
+Stryker introduces deliberate syntax and logic mutations into your code and runs your test suite against each mutant. If your tests still pass when code behavior changes, the mutant survives and the gate fails. The release gate mutates only `*.workflow.ts` decision files. Its planner (`scripts/mutation-shards.ts`) lists each package's workflow files and hands exactly that list to Stryker, and it refuses anything that would change that set before Stryker starts. Those files require a 100% kill score. Mutation runs only in the release gate on pushes to `main`, never locally or on pull requests.
diff --git a/apps/site/package.json b/apps/site/package.json
index 4997338..45292a4 100644
--- a/apps/site/package.json
+++ b/apps/site/package.json
@@ -49,11 +49,6 @@
"test": "vitest run",
"typecheck": "tsr generate && tsc --noEmit --incremental && tsc -p tsconfig.node.json --noEmit"
},
- "stryker": {
- "mutate": [
- "src/**/*.workflow.ts"
- ]
- },
"type": "module",
"version": "0.0.0"
}
diff --git a/apps/site/stryker.config.ts b/apps/site/stryker.config.ts
index 846125c..047e96f 100644
--- a/apps/site/stryker.config.ts
+++ b/apps/site/stryker.config.ts
@@ -1,4 +1,3 @@
import { packageStrykerConfig } from '../../stryker.shared.ts'
-import manifest from './package.json' with { type: 'json' }
-export default packageStrykerConfig(manifest.stryker.mutate)
+export default packageStrykerConfig
diff --git a/docs/plans/2026-10-09-1040-build-mutate-only-workflows-plan.md b/docs/plans/2026-10-09-1040-build-mutate-only-workflows-plan.md
new file mode 100644
index 0000000..1de6111
--- /dev/null
+++ b/docs/plans/2026-10-09-1040-build-mutate-only-workflows-plan.md
@@ -0,0 +1,106 @@
+---
+title: Mutate Only Workflow Files - Plan
+type: build
+date: 2026-10-09
+supersedes: docs/plans/2026-10-09-1002-build-mutate-only-workflows-plan.md
+artifact_contract: ce-unified-plan/v1
+product_contract_source: ce-brainstorm
+execution: code
+---
+
+# Mutate Only Workflow Files - Plan
+
+## Goal Capsule
+
+- **Objective:** The release gate's 100% kill score always covers each package's `*.workflow.ts` decision files, and nothing else. No package can add other files to it or quietly take files out.
+- **Means:** the planner lists each package's workflow files from one glob in `stryker.shared.ts`, and the gate passes that list to Stryker as `--mutate` (KTD1, KTD2).
+- **Authority:** Conductor rulings A-8, A-8b and A-8c (2026-10-09). On behavior, R-IDs win. On mechanism, KTDs win.
+- **Stop conditions:** Stop if the fork's CLI `--mutate` turns out not to replace the config file's `mutate`. Never run Stryker locally: no mutation runs and no dry runs.
+- **Execution profile:** one PR off `main`, containing this plan and the code. Plain push.
+
+## Product Contract
+
+### Summary
+
+The planner computes the exact files to mutate, the gate passes exactly those files to Stryker, and every other way of setting the scope is refused before Stryker starts.
+
+### Problem Frame
+
+At present a package chooses its own mutated set: `apps/site/package.json` declares `stryker.mutate`, and `apps/site/stryker.config.ts` passes that value through. The planner refuses only a set that matches no files (`scripts/mutation-shards.ts:33-39`). As a result, a set like `src/**/*.ts` is planned and then mutated. So is a narrower set that leaves out a decision file. Workflow files in a package with no `mutation` script are never mutated, and nothing is refused as long as some other package mutates.
+
+### Requirements
+
+- R1. For every workspace package, the release gate's Stryker run mutates exactly that package's `*.workflow.ts` files. Test files and other source files are never mutated.
+- R2. The planner refuses a package that sets `stryker.mutate` in its `package.json`, with its own named refusal. There is no flag, environment variable, comment or per-package override that gets around it.
+- R3. The planner refuses a `mutation` script that is not exactly `stryker run`, with its own named refusal. That is how a package script is prevented from passing its own `--mutate`/`-m`.
+- R4. The planner refuses, with its own named refusal, a package that has a `mutation` script but no workflow files.
+- R5. The planner refuses, with its own named refusal, a package that has workflow files but no package name or no `mutation` script.
+- R6. A workspace with no `*.workflow.ts` file is still refused as an empty set. The earlier refusal for decisions that no package mutates becomes R5's refusal, which names the package. `apps/site` is still planned.
+- R7. Tests run real temporary workspaces through the real planner. Every new test fails when its enforcement is removed.
+- R8. The README states the rule in one place: only `*.workflow.ts` decision files are mutated, and the planner refuses anything that would change that set.
+- R9. The planner does not list a workflow file in a directory Stryker ignores: the shared config's `ignorePatterns`, plus `node_modules` and `.stryker-tmp`.
+- R10. The planner refuses, by name, any workflow path containing a comma or a glob metacharacter.
+
+### Acceptance Examples
+
+- AE1. **Covers R1.** A package contains `src/order.workflow.ts`, `src/order.test.ts`, `src/__tests__/order.workflow.property.test.ts`, `src/page.tsx`, `node_modules/dep/x.workflow.ts` and `.stryker-tmp/…/order.workflow.ts`. Its shard mutates only `src/order.workflow.ts` and its sibling workflow files.
+- AE2. **Covers R2.** A package with `stryker.mutate: ["src/**/*.ts"]`, and one with `["src/**/*.test.ts"]`, each get an own-mutate refusal.
+- AE3. **Covers R3.** `"mutation": "stryker run -m src/**/*.ts"` gets a script refusal.
+- AE4. **Covers R5, R6.** Workflow files in a package with no `mutation` script are refused by that package, whether or not another package mutates.
+- AE5. **Covers R9.** `dist/order.workflow.ts` beside `src/order.workflow.ts` is not planned.
+- AE6. **Covers R10.** `src/a,b.workflow.ts` and `src/page[1].workflow.ts` are each refused as `UnsafeWorkflowPath`, and the package is not planned.
+
+## Planning Contract
+
+### Key Technical Decisions
+
+- KTD1. **The gate passes the list on the command line.** The fork's `stryker run` merges the CLI record over the file config (`mergeConfig(file, cli)`, vendored stryker-js 17.0.2 `dist/main.mjs:84525`). A child array replaces the inherited one, and `--mutate` is split on commas (`:114070`). The gate first runs the dependency tasks that turbo's dry-run graph lists for `mutation`, and then runs `turbo run mutation --filter="$PACKAGE" --only -- --mutate "$MUTATE"`. turbo 2.11.7 forwards `--` arguments to dependency tasks as well (a dry run gave `@endgame/site#generate` the same `--mutate x`). `--only` drops those dependencies from the run that carries the arguments. So the list decides the set, whatever a package's `stryker.config.ts` contains. No environment variable can set `mutate`. Governs R1, R2.
+- KTD2. **One glob, owned by `stryker.shared.ts`.** `WORKFLOW_FILES = '**/*.workflow.ts'` is exported from there. The shared config uses it, and so does the planner. `defineConfig` is an identity function, so the file imports `StrykerConfig` as a type only. That lets the plan job, which has no `node_modules`, import the file through Deno. Governs R1.
+- KTD3. **The plan output carries files, not just names.** The planner emits `shards=[{"package":…,"mutate":[…]}]`, with paths relative to the package directory. The mutation matrix runs over `shard`. `PACKAGE` and `MUTATE` (`join(matrix.shard.mutate, ',')`) go through `env`, so neither gets spliced into the shell. The walk skips the shared config's `ignorePatterns` plus `node_modules` and `.stryker-tmp`, so a copy under `dist/` is never planned. Governs R1.
+- KTD4. **Refusals are a tagged union keyed by package directory.** The variants are `OwnMutate`, `MutationScriptNotStrykerRun`, `NoWorkflowFiles`, `WorkflowFilesNotMutated`, `UnsafeWorkflowPath` and `NoWorkflowFilesInWorkspace`. Tests compare them against hand-written values. The CLI prints one line per refusal and exits 1. Governs R2-R6.
+
+### Risks
+
+- A workflow path containing a comma would be split by `--mutate`. A path containing a glob metacharacter (`*?[]{}()!\`) would be read as a pattern: `page[1].workflow.ts` matches `page1.workflow.ts` instead of itself, which mutates the wrong file rather than failing. The planner therefore refuses such a path by name (`UnsafeWorkflowPath`) before Stryker starts.
+- KTD1 and KTD2 rest on three assumptions, each probed before U2 lands:
+ - The fork's `mergeConfig` replaces `mutate` from the CLI record.
+ - turbo passes `--` arguments only to the run that names `mutation` with `--only`. A dry run showed that without `--only`, turbo 2.11.7 also passes them to `generate`.
+ - `deno run` loads `stryker.shared.ts` without `node_modules`.
+
+### Test admission
+
+Admitted: planner tests that call `planMutationShards` on temporary workspaces and compare against hand-written plans (OP12, observable contract). Refused: any test that reads `release-gate.yml`, `package.json` or the glob string back and asserts on its text.
+
+### Judgment surfaces (CONST-W3)
+
+This PR edits instruments that grade work: `scripts/mutation-shards.ts`, `.github/workflows/release-gate.yml`, `stryker.shared.ts`, `apps/site/stryker.config.ts`, the `stryker` field of `apps/site/package.json`, and the `//#test:scripts` inputs in `turbo.json`. The owner directed these changes in rulings A-8, A-8b and A-8c. The `turbo.json` change adds `stryker.shared.ts` to the inputs, because the planner's tests now import it.
+
+## Implementation Units
+
+- U1. **Planner, shared glob, tests.**
+ - **Requirements:** R1-R7, R9, R10. **Decisions:** KTD2-KTD4.
+ - **Files:** `stryker.shared.ts`, `scripts/mutation-shards.ts`, `scripts/mutation-shards.test.ts`, `turbo.json`.
+ - **Test scenarios:**
+ - AE1 to AE6.
+ - R4's package with a `mutation` script and no workflow files.
+ - The workspace with no `*.workflow.ts` file, from R6.
+ - **Verification:** `pnpm test:scripts` passes. Then delete the four new refusals and widen the glob to `**/*.ts` with no exclusions. Every planner test must go red. Revert afterwards.
+- U2. **Gate and site config.**
+ - **Requirements:** R1, R6. **Decisions:** KTD1, KTD3.
+ - **Ruling A-8c:** the package name goes through `env`. A dependency step runs the tasks that `turbo run mutation --dry=json` lists other than `mutation` itself. The mutation step then adds `--only`. The verification quotes a turbo dry run showing which tasks receive `--mutate`.
+ - **Files:** `.github/workflows/release-gate.yml`, `apps/site/stryker.config.ts`, `apps/site/package.json`.
+ - **Approach:** `apps/site` drops its `stryker` field, and its config becomes `export default packageStrykerConfig`.
+ - **Verification:** Run the planner on the real tree from a checkout without `node_modules`; it prints `apps/site`'s three workflow files. A turbo dry run with `--only` shows `--mutate` reaching only `@endgame/site#mutation`. `pnpm check:ci` passes.
+- U3. **README.**
+ - **Requirements:** R8. **Files:** `README.md` (the mutation FAQ).
+ - **Test expectation:** none, because this is documentation.
+
+## Verification Contract
+
+- `pnpm test:scripts`, the planner run in a checkout without `node_modules`, and `pnpm check:ci`. CI must be green on the PR head.
+- Stryker never runs locally. The release gate runs it on `main`.
+
+## Definition of Done
+
+- R1-R10 hold, shown by the tests and the runs named above. The sabotage run is red and recorded in the PR.
+- No sabotage edit, probe file or scratch output is left in the diff.
diff --git a/scripts/mutation-shards.test.ts b/scripts/mutation-shards.test.ts
index 3770ecf..8a9167f 100644
--- a/scripts/mutation-shards.test.ts
+++ b/scripts/mutation-shards.test.ts
@@ -1,11 +1,11 @@
import { assertEquals } from '@std/assert'
-import { join } from '@std/path'
+import { dirname, join } from '@std/path'
import { planMutationShards } from './mutation-shards.ts'
type PackageFixture = {
readonly name: string
- readonly mutation?: false
+ readonly mutation?: string | false
readonly mutate?: string[]
readonly files: string[]
}
@@ -15,78 +15,141 @@ const workspaceOf = async (packages: PackageFixture[]): Promise => {
await Deno.writeTextFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n')
for (const fixture of packages) {
const dir = join(root, 'packages', fixture.name)
- await Deno.mkdir(join(dir, 'src'), { recursive: true })
+ await Deno.mkdir(dir, { recursive: true })
const stryker = fixture.mutate === undefined ? {} : { stryker: { mutate: fixture.mutate } }
- const scripts = fixture.mutation === false ? {} : { scripts: { mutation: 'stryker run' } }
+ const scripts = fixture.mutation === false ? {} : { scripts: { mutation: fixture.mutation ?? 'stryker run' } }
await Deno.writeTextFile(
join(dir, 'package.json'),
JSON.stringify({ name: `@fixture/${fixture.name}`, ...scripts, ...stryker }),
)
- for (const file of fixture.files) await Deno.writeTextFile(join(dir, file), 'export {}\n')
+ for (const file of fixture.files) {
+ await Deno.mkdir(dirname(join(dir, file)), { recursive: true })
+ await Deno.writeTextFile(join(dir, file), 'export {}\n')
+ }
}
return root
}
-Deno.test('a package whose mutate globs match files becomes a shard', async () => {
- const root = await workspaceOf([{ name: 'core', mutate: ['src/**/*.workflow.ts'], files: ['src/order.workflow.ts'] }])
- assertEquals(await planMutationShards(root), { packages: ['@fixture/core'], refusals: [], decisions: 1 })
+Deno.test('a shard mutates exactly the package workflow files, never tests, other source or dependencies', async () => {
+ const root = await workspaceOf([{
+ name: 'core',
+ files: [
+ 'src/order.workflow.ts',
+ 'src/billing/invoice.workflow.ts',
+ 'src/order.test.ts',
+ 'src/__tests__/order.workflow.property.test.ts',
+ 'src/page.tsx',
+ 'src/order.schema.ts',
+ 'node_modules/dep/x.workflow.ts',
+ '.stryker-tmp/sandbox-1/src/order.workflow.ts',
+ ],
+ }])
+ assertEquals(await planMutationShards(root), {
+ shards: [{ package: '@fixture/core', mutate: ['src/billing/invoice.workflow.ts', 'src/order.workflow.ts'] }],
+ refusals: [],
+ })
})
-Deno.test('a package whose mutate globs match no file is refused by name and globs', async () => {
+Deno.test('a package that widens its own mutated set is refused', async () => {
+ const root = await workspaceOf([{ name: 'core', mutate: ['src/**/*.ts'], files: ['src/order.workflow.ts'] }])
+ assertEquals((await planMutationShards(root)).refusals, [
+ { _tag: 'OwnMutate', dir: 'packages/core', mutate: ['src/**/*.ts'] },
+ ])
+})
+
+Deno.test('a package that points its mutated set at test files is refused', async () => {
+ const root = await workspaceOf([{
+ name: 'core',
+ mutate: ['src/**/*.test.ts'],
+ files: ['src/order.workflow.ts', 'src/order.test.ts'],
+ }])
+ assertEquals((await planMutationShards(root)).refusals, [
+ { _tag: 'OwnMutate', dir: 'packages/core', mutate: ['src/**/*.test.ts'] },
+ ])
+})
+
+Deno.test('a package that sets its own mutated set without a mutation script is still refused', async () => {
const root = await workspaceOf([
- { name: 'core', mutate: ['src/**/*.workflow.ts'], files: ['src/order.workflow.ts'] },
- { name: 'site', mutate: ['src/**/*.workflow.ts'], files: ['src/page.tsx'] },
+ { name: 'core', files: ['src/order.workflow.ts'] },
+ { name: 'tools', mutation: false, mutate: ['src/**/*.ts'], files: ['src/cli.ts'] },
+ ])
+ assertEquals((await planMutationShards(root)).refusals, [
+ { _tag: 'OwnMutate', dir: 'packages/tools', mutate: ['src/**/*.ts'] },
])
- assertEquals(await planMutationShards(root), {
- packages: ['@fixture/core'],
- refusals: ['@fixture/site (packages/site): stryker.mutate ["src/**/*.workflow.ts"] matches no files'],
- decisions: 1,
- })
})
-Deno.test('negated globs that remove every match leave the package refused', async () => {
+Deno.test('a mutation script that passes its own files to stryker is refused', async () => {
const root = await workspaceOf([{
name: 'core',
- mutate: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.workflow.ts'],
- files: ['src/a.test.ts', 'src/order.workflow.ts'],
+ mutation: 'stryker run -m src/**/*.ts',
+ files: ['src/order.workflow.ts'],
}])
assertEquals(await planMutationShards(root), {
- packages: [],
- refusals: [
- '@fixture/core (packages/core): stryker.mutate ["src/**/*.ts","!src/**/*.test.ts","!src/**/*.workflow.ts"] matches no files',
- ],
- decisions: 1,
+ shards: [],
+ refusals: [{
+ _tag: 'MutationScriptNotStrykerRun',
+ dir: 'packages/core',
+ script: 'stryker run -m src/**/*.ts',
+ }],
})
})
-Deno.test('a mutation script without declared mutate globs is refused', async () => {
- const root = await workspaceOf([{ name: 'core', files: ['src/order.workflow.ts'] }])
+Deno.test('a mutating package without a workflow file is refused by name', async () => {
+ const root = await workspaceOf([
+ { name: 'core', files: ['src/order.workflow.ts'] },
+ { name: 'site', files: ['src/page.tsx'] },
+ ])
assertEquals(await planMutationShards(root), {
- packages: [],
- refusals: ['@fixture/core (packages/core): stryker.mutate [] matches no files'],
- decisions: 1,
+ shards: [{ package: '@fixture/core', mutate: ['src/order.workflow.ts'] }],
+ refusals: [{ _tag: 'NoWorkflowFiles', dir: 'packages/site' }],
})
})
Deno.test('a workspace without a single *.workflow.ts file is refused as an empty set', async () => {
const root = await workspaceOf([
- { name: 'site', mutate: ['src/**/*.workflow.ts'], files: ['src/page.tsx'] },
+ { name: 'site', files: ['src/page.tsx'] },
{ name: 'tools', mutation: false, files: ['src/cli.ts'] },
])
+ assertEquals(await planMutationShards(root), { shards: [], refusals: [{ _tag: 'NoWorkflowFilesInWorkspace' }] })
+})
+
+Deno.test('workflow files in a package without a mutation script are refused by package', async () => {
+ const root = await workspaceOf([
+ { name: 'core', files: ['src/order.workflow.ts'] },
+ { name: 'billing', mutation: false, files: ['src/invoice.workflow.ts', 'src/refund.workflow.ts'] },
+ ])
assertEquals(await planMutationShards(root), {
- packages: [],
- refusals: ['no workspace package has a *.workflow.ts file; the release gate refuses an empty set'],
- decisions: 0,
+ shards: [{ package: '@fixture/core', mutate: ['src/order.workflow.ts'] }],
+ refusals: [{ _tag: 'WorkflowFilesNotMutated', dir: 'packages/billing', workflows: 2 }],
})
})
-Deno.test('decisions that no package mutates are refused as an empty set', async () => {
+Deno.test('decisions that no package mutates are refused', async () => {
const root = await workspaceOf([{ name: 'core', mutation: false, files: ['src/order.workflow.ts'] }])
assertEquals(await planMutationShards(root), {
- packages: [],
+ shards: [],
+ refusals: [{ _tag: 'WorkflowFilesNotMutated', dir: 'packages/core', workflows: 1 }],
+ })
+})
+
+Deno.test('a workflow copy in a directory Stryker ignores is not planned', async () => {
+ const root = await workspaceOf([{ name: 'core', files: ['src/order.workflow.ts', 'dist/order.workflow.ts'] }])
+ assertEquals(await planMutationShards(root), {
+ shards: [{ package: '@fixture/core', mutate: ['src/order.workflow.ts'] }],
+ refusals: [],
+ })
+})
+
+Deno.test('a workflow path the comma-joined --mutate list would split or expand is refused by name', async () => {
+ const root = await workspaceOf([{
+ name: 'core',
+ files: ['src/order.workflow.ts', 'src/a,b.workflow.ts', 'src/page[1].workflow.ts'],
+ }])
+ assertEquals(await planMutationShards(root), {
+ shards: [],
refusals: [
- '1 *.workflow.ts file(s) but no workspace package declares a `mutation` script; the release gate refuses an empty set',
+ { _tag: 'UnsafeWorkflowPath', dir: 'packages/core', path: 'src/a,b.workflow.ts' },
+ { _tag: 'UnsafeWorkflowPath', dir: 'packages/core', path: 'src/page[1].workflow.ts' },
],
- decisions: 1,
})
})
diff --git a/scripts/mutation-shards.ts b/scripts/mutation-shards.ts
index a3531ac..c14daf9 100755
--- a/scripts/mutation-shards.ts
+++ b/scripts/mutation-shards.ts
@@ -5,66 +5,105 @@ import { expandGlob } from '@std/fs/expand-glob'
import { dirname, join, relative } from '@std/path'
import { parse } from '@std/yaml'
-type Manifest = { name?: string; scripts?: Record; stryker?: { mutate?: string[] } }
+import { packageStrykerConfig, WORKFLOW_FILES } from '../stryker.shared.ts'
-export type ShardPlan = { readonly packages: string[]; readonly refusals: string[]; readonly decisions: number }
+type Manifest = { name?: string; scripts?: Record; stryker?: { mutate?: unknown } }
-const DECISIONS = ['**/*.workflow.ts', '!**/.stryker-tmp/**']
+export type Shard = { readonly package: string; readonly mutate: string[] }
-const matchedFileCount = async (dir: string, mutate: string[]): Promise => {
- const exclude = ['**/node_modules/**', ...mutate.filter((glob) => glob.startsWith('!')).map((glob) => glob.slice(1))]
- let count = 0
- for (const glob of mutate.filter((glob) => !glob.startsWith('!'))) {
- for await (const _ of expandGlob(glob, { root: dir, exclude, includeDirs: false })) count++
+export type Refusal =
+ | { readonly _tag: 'OwnMutate'; readonly dir: string; readonly mutate: unknown }
+ | { readonly _tag: 'MutationScriptNotStrykerRun'; readonly dir: string; readonly script: string }
+ | { readonly _tag: 'NoWorkflowFiles'; readonly dir: string }
+ | { readonly _tag: 'WorkflowFilesNotMutated'; readonly dir: string; readonly workflows: number }
+ | { readonly _tag: 'UnsafeWorkflowPath'; readonly dir: string; readonly path: string }
+ | { readonly _tag: 'NoWorkflowFilesInWorkspace' }
+
+export type ShardPlan = { readonly shards: Shard[]; readonly refusals: Refusal[] }
+
+const STRYKER_RUN = 'stryker run'
+
+const UNSAFE_PATH = /[,*?[\]{}()!\\]/
+
+const IGNORED_DIRS = [...(packageStrykerConfig.ignorePatterns ?? []), 'node_modules', '.stryker-tmp']
+
+export const describeRefusal = (refusal: Refusal): string => {
+ switch (refusal._tag) {
+ case 'OwnMutate':
+ return `${refusal.dir}: sets its own stryker.mutate ${
+ JSON.stringify(refusal.mutate)
+ }; the release gate mutates exactly the package's ${WORKFLOW_FILES} files`
+ case 'MutationScriptNotStrykerRun':
+ return `${refusal.dir}: the mutation script is ${
+ JSON.stringify(refusal.script)
+ }, not exactly "${STRYKER_RUN}"; the release gate passes the mutated files itself`
+ case 'NoWorkflowFiles':
+ return `${refusal.dir}: declares a mutation script but has no ${WORKFLOW_FILES} file`
+ case 'WorkflowFilesNotMutated':
+ return `${refusal.dir}: has ${refusal.workflows} ${WORKFLOW_FILES} file(s) but no package name or \`mutation\` script to mutate them`
+ case 'UnsafeWorkflowPath':
+ return `${refusal.dir}: ${
+ JSON.stringify(refusal.path)
+ } holds a comma or glob metacharacter, so the --mutate list would split or expand it; rename the file`
+ case 'NoWorkflowFilesInWorkspace':
+ return `no workspace package has a ${WORKFLOW_FILES} file; the release gate refuses an empty set`
}
- return count
+}
+
+const workflowFilesOf = async (dir: string): Promise => {
+ const files: string[] = []
+ const walk = expandGlob(WORKFLOW_FILES, {
+ root: dir,
+ exclude: IGNORED_DIRS.map((ignored) => `**/${ignored}/**`),
+ includeDirs: false,
+ })
+ for await (const entry of walk) files.push(relative(dir, entry.path))
+ return files.sort()
}
export const planMutationShards = async (root: string): Promise => {
const workspace = parse(await Deno.readTextFile(join(root, 'pnpm-workspace.yaml'))) as { packages?: string[] }
- const packages: string[] = []
- const refusals: string[] = []
- let decisions = 0
+ const shards: Shard[] = []
+ const refusals: Refusal[] = []
+ let workflows = 0
for (const glob of workspace.packages ?? []) {
for await (const entry of expandGlob(join(glob, 'package.json'), { root, exclude: ['**/node_modules/**'] })) {
- decisions += await matchedFileCount(dirname(entry.path), DECISIONS)
+ const packageDir = dirname(entry.path)
+ const dir = relative(root, packageDir)
+ const files = await workflowFilesOf(packageDir)
+ workflows += files.length
const manifest = JSON.parse(await Deno.readTextFile(entry.path)) as Manifest
- if (manifest.name === undefined || manifest.scripts?.mutation === undefined) continue
- const mutate = manifest.stryker?.mutate ?? []
- if (await matchedFileCount(dirname(entry.path), mutate) === 0) {
- refusals.push(
- `${manifest.name} (${relative(root, dirname(entry.path))}): stryker.mutate ${
- JSON.stringify(mutate)
- } matches no files`,
- )
+ if (manifest.stryker?.mutate !== undefined) {
+ refusals.push({ _tag: 'OwnMutate', dir, mutate: manifest.stryker.mutate })
+ }
+ const script = manifest.scripts?.mutation
+ if (manifest.name === undefined || script === undefined) {
+ if (files.length > 0) refusals.push({ _tag: 'WorkflowFilesNotMutated', dir, workflows: files.length })
+ } else if (script !== STRYKER_RUN) {
+ refusals.push({ _tag: 'MutationScriptNotStrykerRun', dir, script })
+ } else if (files.length === 0) {
+ refusals.push({ _tag: 'NoWorkflowFiles', dir })
+ } else if (files.some((path) => UNSAFE_PATH.test(path))) {
+ for (const path of files.filter((path) => UNSAFE_PATH.test(path))) {
+ refusals.push({ _tag: 'UnsafeWorkflowPath', dir, path })
+ }
} else {
- packages.push(manifest.name)
+ shards.push({ package: manifest.name, mutate: files })
}
}
}
- if (decisions === 0) {
- return {
- packages: [],
- refusals: ['no workspace package has a *.workflow.ts file; the release gate refuses an empty set'],
- decisions,
- }
- }
- if (packages.length === 0 && refusals.length === 0) {
- refusals.push(
- `${decisions} *.workflow.ts file(s) but no workspace package declares a \`mutation\` script; the release gate refuses an empty set`,
- )
- }
- return { packages: packages.sort(), refusals, decisions }
+ if (workflows === 0) return { shards: [], refusals: [{ _tag: 'NoWorkflowFilesInWorkspace' }] }
+ return { shards: shards.sort((a, b) => a.package.localeCompare(b.package)), refusals }
}
if (import.meta.main) {
const { output, root = '.' } = parseArgs(Deno.args, { string: ['output', 'root'] })
const plan = await planMutationShards(root)
if (plan.refusals.length > 0) {
- for (const refusal of plan.refusals) console.error(`mutation-shards: ${refusal}`)
+ for (const refusal of plan.refusals) console.error(`mutation-shards: ${describeRefusal(refusal)}`)
Deno.exit(1)
}
- const line = `packages=${JSON.stringify(plan.packages)}`
+ const line = `shards=${JSON.stringify(plan.shards)}`
console.error(`mutation-shards: ${line}`)
if (output) await Deno.writeTextFile(output, `${line}\n`, { append: true })
else console.log(line)
diff --git a/stryker.shared.ts b/stryker.shared.ts
index c956d53..88ab198 100644
--- a/stryker.shared.ts
+++ b/stryker.shared.ts
@@ -1,22 +1,23 @@
-import { defineConfig, type StrykerConfig } from '@systemfsoftware/stryker-js/config'
+import type { StrykerConfig } from '@systemfsoftware/stryker-js/config'
-export const packageStrykerConfig = (mutate: ReadonlyArray): StrykerConfig =>
- defineConfig({
- checkers: [{ plugin: '@systemfsoftware/stryker-js-typescript-checker' }],
- coverageAnalysis: 'perTest',
- disableBail: true,
- htmlReporter: { fileName: 'reports/mutation-report.html' },
- ignorePatterns: ['reports', 'coverage', 'dist'],
- incremental: true,
- incrementalFile: 'reports/stryker-incremental.json',
- ignorers: ['@systemfsoftware/stryker-ignorer-effect-schema-declarations'],
- jsonReporter: { fileName: 'reports/mutation-report.json' },
- mutate: [...mutate],
- packageManager: 'pnpm',
- reporters: ['progress', 'html', 'json', 'progress-stream'],
- testRunner: {
- plugin: '@systemfsoftware/stryker-js-vitest-runner',
- options: { configFile: 'vitest.config.ts', dir: '.', related: true },
- },
- thresholds: { break: 100, high: 100, low: 100 },
- })
+export const WORKFLOW_FILES = '**/*.workflow.ts'
+
+export const packageStrykerConfig: StrykerConfig = {
+ checkers: [{ plugin: '@systemfsoftware/stryker-js-typescript-checker' }],
+ coverageAnalysis: 'perTest',
+ disableBail: true,
+ htmlReporter: { fileName: 'reports/mutation-report.html' },
+ ignorePatterns: ['reports', 'coverage', 'dist'],
+ incremental: true,
+ incrementalFile: 'reports/stryker-incremental.json',
+ ignorers: ['@systemfsoftware/stryker-ignorer-effect-schema-declarations'],
+ jsonReporter: { fileName: 'reports/mutation-report.json' },
+ mutate: [WORKFLOW_FILES],
+ packageManager: 'pnpm',
+ reporters: ['progress', 'html', 'json', 'progress-stream'],
+ testRunner: {
+ plugin: '@systemfsoftware/stryker-js-vitest-runner',
+ options: { configFile: 'vitest.config.ts', dir: '.', related: true },
+ },
+ thresholds: { break: 100, high: 100, low: 100 },
+}
diff --git a/turbo.json b/turbo.json
index 4071c35..4b86ee0 100644
--- a/turbo.json
+++ b/turbo.json
@@ -71,6 +71,7 @@
"outputLogs": "errors-only",
"inputs": [
"scripts/**",
+ "stryker.shared.ts",
"flake.nix",
"flake.lock"
],