From 642e81c798ef3667bb227ecb5f9d3209435ef39e Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Mon, 5 Oct 2026 19:34:15 +0000 Subject: [PATCH 1/7] ci(ci): move mutation to a release gate on main check:ci no longer runs stryker. The release gate on push to main plans one shard per workspace package that declares a mutation script, refuses an empty set, and runs each shard at break 100 on the fleet with its incremental report cached. The checker drops prioritizePerformanceOverAccuracy, which stryker-js 15 removes. Operator approval: Kiro, 2026-10-05 (GATE1) --- .github/workflows/release-gate.yml | 63 ++++++++++++++++++++++++++++++ package.json | 2 +- packages/starter/stryker.config.ts | 7 +--- scripts/mutation-shards.ts | 34 ++++++++++++++++ 4 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/release-gate.yml create mode 100755 scripts/mutation-shards.ts diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 00000000..ed983115 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,63 @@ +name: Release gate + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-gate-${{ github.ref }} + cancel-in-progress: false + +jobs: + plan: + name: plan · mutation shards + runs-on: [self-hosted, systemfsoftware-runner, small] + timeout-minutes: 5 + outputs: + packages: ${{ steps.shards.outputs.packages }} + steps: + - uses: actions/checkout@v7 + - uses: denoland/setup-deno@v2 + - id: shards + run: ./scripts/mutation-shards.ts --output "$GITHUB_OUTPUT" + + mutation: + name: mutation · ${{ matrix.package }} + needs: [plan] + runs-on: [self-hosted, systemfsoftware-runner, large] + timeout-minutes: 75 + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.plan.outputs.packages) }} + env: + CI: "true" + steps: + - uses: actions/checkout@v7 + - uses: pnpm/setup@v2 + with: + runtime: node@24 + cache: true + - run: pnpm install --frozen-lockfile + - name: Stryker incremental cache + uses: actions/cache@v6 + with: + path: | + apps/*/reports/stryker-incremental.json + packages/*/reports/stryker-incremental.json + key: stryker-${{ matrix.package }}-${{ github.sha }} + restore-keys: stryker-${{ matrix.package }}- + - name: Mutation at break 100 + run: pnpm exec turbo run mutation --filter="${{ matrix.package }}" + - if: always() + uses: actions/upload-artifact@v6 + with: + name: mutation-report-${{ strategy.job-index }} + path: | + apps/*/reports/mutation-report.* + packages/*/reports/mutation-report.* + if-no-files-found: ignore diff --git a/package.json b/package.json index 5a60a0ec..bbe19ef2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "scripts": { "attw": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} attw", "build": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} build", - "check:ci": "s=0; pnpm format:check || s=1; TURBO_CONCURRENCY=${TURBO_CONCURRENCY:-100%} pnpm gate:tasks || s=1; pnpm gate:dist || s=1; pnpm mutation || s=1; exit $s", + "check:ci": "s=0; pnpm format:check || s=1; TURBO_CONCURRENCY=${TURBO_CONCURRENCY:-100%} pnpm gate:tasks || s=1; pnpm gate:dist || s=1; exit $s", "clean": "turbo clean", "format": "./bin/dprint fmt", "format:check": "./bin/dprint check", diff --git a/packages/starter/stryker.config.ts b/packages/starter/stryker.config.ts index a7998014..5024485f 100644 --- a/packages/starter/stryker.config.ts +++ b/packages/starter/stryker.config.ts @@ -1,12 +1,7 @@ import { defineConfig, type StrykerConfig } from '@systemfsoftware/stryker-js/config' const config: StrykerConfig = defineConfig({ - checkers: [ - { - plugin: '@systemfsoftware/stryker-js-typescript-checker', - options: { prioritizePerformanceOverAccuracy: true }, - }, - ], + checkers: [{ plugin: '@systemfsoftware/stryker-js-typescript-checker' }], coverageAnalysis: 'perTest', disableBail: true, htmlReporter: { fileName: 'reports/mutation-report.html' }, diff --git a/scripts/mutation-shards.ts b/scripts/mutation-shards.ts new file mode 100755 index 00000000..760d764a --- /dev/null +++ b/scripts/mutation-shards.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env -S deno run --config=scripts/deno.json --allow-read --allow-write --allow-import=jsr.io + +import { parseArgs } from '@std/cli/parse-args' +import { expandGlob } from '@std/fs/expand-glob' +import { join } from '@std/path' +import { parse } from '@std/yaml' + +type Manifest = { name?: string; scripts?: Record } + +const { output, root = '.' } = parseArgs(Deno.args, { string: ['output', 'root'] }) + +const workspace = parse(await Deno.readTextFile(join(root, 'pnpm-workspace.yaml'))) as { + packages?: string[] +} + +const shards: string[] = [] +for (const glob of workspace.packages ?? []) { + for await (const entry of expandGlob(join(glob, 'package.json'), { root, exclude: ['**/node_modules/**'] })) { + const manifest = JSON.parse(await Deno.readTextFile(entry.path)) as Manifest + if (manifest.name !== undefined && manifest.scripts?.mutation !== undefined) shards.push(manifest.name) + } +} + +if (shards.length === 0) { + console.error( + 'mutation-shards: no workspace package declares a `mutation` script; the release gate refuses an empty set', + ) + Deno.exit(1) +} + +const line = `packages=${JSON.stringify(shards.sort())}` +console.error(`mutation-shards: ${line}`) +if (output) await Deno.writeTextFile(output, `${line}\n`, { append: true }) +else console.log(line) From 4924650735fd692cf5c035d41e7ca5f9daec7f74 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 6 Oct 2026 01:32:15 +0000 Subject: [PATCH 2/7] ci(ci): run the release gate on github-hosted runners The plan and per-package mutation jobs move from the self-hosted fleet, which admits only private repositories, to ubuntu-latest. Mutation stays one parallel job per package. Operator approval: Kiro, 2026-10-05 (GATE1) --- .github/workflows/release-gate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index ed983115..421534d5 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -15,7 +15,7 @@ concurrency: jobs: plan: name: plan · mutation shards - runs-on: [self-hosted, systemfsoftware-runner, small] + runs-on: ubuntu-latest timeout-minutes: 5 outputs: packages: ${{ steps.shards.outputs.packages }} @@ -28,7 +28,7 @@ jobs: mutation: name: mutation · ${{ matrix.package }} needs: [plan] - runs-on: [self-hosted, systemfsoftware-runner, large] + runs-on: ubuntu-latest timeout-minutes: 75 strategy: fail-fast: false From 2948df38fe684f8cc1dfc9e4a13f8f929512f140 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 6 Oct 2026 07:53:48 +0000 Subject: [PATCH 3/7] fix(ci): refuse a mutation shard whose mutate globs match no files A package with a mutation script whose mutate globs match nothing reports zero mutants and passes a break-100 threshold vacuously (review finding #9). Each package now declares its globs once, as stryker.mutate in package.json; its Stryker config reads them and the release gate's shard planner expands them. The planner fails red at plan time naming the package, its directory and its globs when they match no file, including a package with no declared globs. Its Deno tests are ordinary tests: a turbo root task, test:scripts, runs them through the dev shell's deno, and both pnpm test and check:ci run it, so a local check:ci covers the planner exactly as CI does --- package.json | 7 ++-- packages/starter/package.json | 32 ++++++++++++--- packages/starter/stryker.config.ts | 4 +- scripts/deno.json | 13 ++++++ scripts/deno.lock | 52 ++++++++++++++++++++++++ scripts/mutation-shards.test.ts | 58 +++++++++++++++++++++++++++ scripts/mutation-shards.ts | 64 ++++++++++++++++++++---------- turbo.json | 10 +++++ 8 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 scripts/deno.json create mode 100644 scripts/deno.lock create mode 100644 scripts/mutation-shards.test.ts diff --git a/package.json b/package.json index bbe19ef2..ef5ed6f2 100644 --- a/package.json +++ b/package.json @@ -25,14 +25,15 @@ "format": "./bin/dprint fmt", "format:check": "./bin/dprint check", "gate:dist": "turbo --concurrency=${TURBO_CONCURRENCY:-100%} build", - "gate:tasks": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} --continue lint typecheck typecheck:node test", + "gate:tasks": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} --continue lint typecheck typecheck:node test test:scripts", "lint": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} lint", "mutation": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} mutation", "precommit": "pnpm exec lint-staged --no-revert --config .lintstagedrc.js", "prepare": "husky && effect-tsgo patch --oxlint --no-typescript --skip-missing", - "test": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} test", + "test": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} test test:scripts", "typecheck": "turbo --concurrency=${TURBO_CONCURRENCY:-50%} --continue typecheck typecheck:node", - "typecheck:node": "tsc -p tsconfig.node.json --noEmit" + "typecheck:node": "tsc -p tsconfig.node.json --noEmit", + "test:scripts": "nix develop --command deno test --config=scripts/deno.json --allow-read --allow-write --allow-import=jsr.io scripts/" }, "type": "module" } diff --git a/packages/starter/package.json b/packages/starter/package.json index 5a1785e3..c900e558 100644 --- a/packages/starter/package.json +++ b/packages/starter/package.json @@ -19,15 +19,30 @@ "vitest": "catalog:" }, "exports": { - ".": { "@systemfsoftware/source": "./src/index.ts", "default": "./dist/index.js" }, + ".": { + "@systemfsoftware/source": "./src/index.ts", + "default": "./dist/index.js" + }, "./package.json": "./package.json" }, - "files": ["dist"], + "files": [ + "dist" + ], "license": "Apache-2.0", "name": "@todo/starter", - "omp": { "extensions": ["./dist/index.js"] }, + "omp": { + "extensions": [ + "./dist/index.js" + ] + }, "private": true, - "publishConfig": { "exports": { ".": "./dist/index.js", "./package.json": "./package.json" }, "provenance": true }, + "publishConfig": { + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + }, + "provenance": true + }, "scripts": { "build": "rimraf dist && tsdown", "clean": "rimraf dist", @@ -38,5 +53,12 @@ "typecheck": "tsc --noEmit --incremental && tsc -p tsconfig.node.json --noEmit" }, "type": "module", - "version": "0.0.0" + "version": "0.0.0", + "stryker": { + "mutate": [ + "src/**/*.ts", + "!src/**/*.test.ts", + "!src/**/*.d.ts" + ] + } } diff --git a/packages/starter/stryker.config.ts b/packages/starter/stryker.config.ts index 5024485f..2cb40c83 100644 --- a/packages/starter/stryker.config.ts +++ b/packages/starter/stryker.config.ts @@ -1,5 +1,7 @@ import { defineConfig, type StrykerConfig } from '@systemfsoftware/stryker-js/config' +import manifest from './package.json' with { type: 'json' } + const config: StrykerConfig = defineConfig({ checkers: [{ plugin: '@systemfsoftware/stryker-js-typescript-checker' }], coverageAnalysis: 'perTest', @@ -10,7 +12,7 @@ const config: StrykerConfig = defineConfig({ incrementalFile: 'reports/stryker-incremental.json', ignorers: ['@systemfsoftware/stryker-ignorer-effect-schema-declarations'], jsonReporter: { fileName: 'reports/mutation-report.json' }, - mutate: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts'], + mutate: [...manifest.stryker.mutate], packageManager: 'pnpm', reporters: ['progress', 'html', 'json', 'progress-stream'], testRunner: { diff --git a/scripts/deno.json b/scripts/deno.json new file mode 100644 index 00000000..8e7df685 --- /dev/null +++ b/scripts/deno.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "noImplicitOverride": true + }, + "imports": { + "@std/assert": "jsr:@std/assert@1", + "@std/cli/parse-args": "jsr:@std/cli@1/parse-args", + "@std/fs/expand-glob": "jsr:@std/fs@1/expand-glob", + "@std/path": "jsr:@std/path@1", + "@std/yaml": "jsr:@std/yaml@1" + } +} diff --git a/scripts/deno.lock b/scripts/deno.lock new file mode 100644 index 00000000..91104ecf --- /dev/null +++ b/scripts/deno.lock @@ -0,0 +1,52 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.19", + "jsr:@std/cli@1": "1.0.32", + "jsr:@std/fs@1": "1.0.24", + "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@1": "1.1.6", + "jsr:@std/path@^1.1.5": "1.1.6", + "jsr:@std/yaml@1": "1.3.0" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/cli@1.0.32": { + "integrity": "188b3a100d6202d64e3f5bd3d799c7fa4f6d77f92cc65eb7f641c1fa0aa92a66" + }, + "@std/fs@1.0.24": { + "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", + "dependencies": [ + "jsr:@std/internal@^1.0.14", + "jsr:@std/path@^1.1.5" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] + }, + "@std/yaml@1.3.0": { + "integrity": "b250103854378b8b36cbff0fe95aec017d3d9ce86b489c67605180c602fb4922" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1", + "jsr:@std/cli@1", + "jsr:@std/fs@1", + "jsr:@std/path@1", + "jsr:@std/yaml@1" + ] + } +} diff --git a/scripts/mutation-shards.test.ts b/scripts/mutation-shards.test.ts new file mode 100644 index 00000000..da85b044 --- /dev/null +++ b/scripts/mutation-shards.test.ts @@ -0,0 +1,58 @@ +import { assertEquals } from '@std/assert' +import { join } from '@std/path' + +import { planMutationShards } from './mutation-shards.ts' + +type PackageFixture = { readonly name: string; readonly mutate?: string[]; readonly files: string[] } + +const workspaceOf = async (packages: PackageFixture[]): Promise => { + const root = await Deno.makeTempDir({ prefix: 'mutation-shards-' }) + 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 }) + const stryker = fixture.mutate === undefined ? {} : { stryker: { mutate: fixture.mutate } } + await Deno.writeTextFile( + join(dir, 'package.json'), + JSON.stringify({ name: `@fixture/${fixture.name}`, scripts: { mutation: 'stryker run' }, ...stryker }), + ) + for (const file of fixture.files) 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: [] }) +}) + +Deno.test('a package whose mutate globs match no file is refused by name and globs', 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'] }, + ]) + assertEquals(await planMutationShards(root), { + packages: ['@fixture/core'], + refusals: ['@fixture/site (packages/site): stryker.mutate ["src/**/*.workflow.ts"] matches no files'], + }) +}) + +Deno.test('negated globs that remove every match leave the package refused', async () => { + const root = await workspaceOf([{ + name: 'core', + mutate: ['src/**/*.ts', '!src/**/*.test.ts'], + files: ['src/a.test.ts'], + }]) + assertEquals(await planMutationShards(root), { + packages: [], + refusals: ['@fixture/core (packages/core): stryker.mutate ["src/**/*.ts","!src/**/*.test.ts"] matches no files'], + }) +}) + +Deno.test('a mutation script without declared mutate globs is refused', async () => { + const root = await workspaceOf([{ name: 'core', files: ['src/order.workflow.ts'] }]) + assertEquals(await planMutationShards(root), { + packages: [], + refusals: ['@fixture/core (packages/core): stryker.mutate [] matches no files'], + }) +}) diff --git a/scripts/mutation-shards.ts b/scripts/mutation-shards.ts index 760d764a..1bb18e99 100755 --- a/scripts/mutation-shards.ts +++ b/scripts/mutation-shards.ts @@ -2,33 +2,57 @@ import { parseArgs } from '@std/cli/parse-args' import { expandGlob } from '@std/fs/expand-glob' -import { join } from '@std/path' +import { dirname, join, relative } from '@std/path' import { parse } from '@std/yaml' -type Manifest = { name?: string; scripts?: Record } +type Manifest = { name?: string; scripts?: Record; stryker?: { mutate?: string[] } } -const { output, root = '.' } = parseArgs(Deno.args, { string: ['output', 'root'] }) +export type ShardPlan = { readonly packages: string[]; readonly refusals: string[] } -const workspace = parse(await Deno.readTextFile(join(root, 'pnpm-workspace.yaml'))) as { - packages?: 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++ + } + return count } -const shards: string[] = [] -for (const glob of workspace.packages ?? []) { - for await (const entry of expandGlob(join(glob, 'package.json'), { root, exclude: ['**/node_modules/**'] })) { - const manifest = JSON.parse(await Deno.readTextFile(entry.path)) as Manifest - if (manifest.name !== undefined && manifest.scripts?.mutation !== undefined) shards.push(manifest.name) +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[] = [] + for (const glob of workspace.packages ?? []) { + for await (const entry of expandGlob(join(glob, 'package.json'), { root, exclude: ['**/node_modules/**'] })) { + 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`, + ) + } else { + packages.push(manifest.name) + } + } + } + if (packages.length === 0 && refusals.length === 0) { + refusals.push('no workspace package declares a `mutation` script; the release gate refuses an empty set') } + return { packages: packages.sort(), refusals } } -if (shards.length === 0) { - console.error( - 'mutation-shards: no workspace package declares a `mutation` script; the release gate refuses an empty set', - ) - Deno.exit(1) +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}`) + Deno.exit(1) + } + const line = `packages=${JSON.stringify(plan.packages)}` + console.error(`mutation-shards: ${line}`) + if (output) await Deno.writeTextFile(output, `${line}\n`, { append: true }) + else console.log(line) } - -const line = `packages=${JSON.stringify(shards.sort())}` -console.error(`mutation-shards: ${line}`) -if (output) await Deno.writeTextFile(output, `${line}\n`, { append: true }) -else console.log(line) diff --git a/turbo.json b/turbo.json index 31d67522..5dc14c03 100644 --- a/turbo.json +++ b/turbo.json @@ -44,6 +44,16 @@ "outputs": [], "cache": true }, + "//#test:scripts": { + "outputLogs": "errors-only", + "inputs": [ + "scripts/**", + "flake.nix", + "flake.lock" + ], + "outputs": [], + "cache": true + }, "test": { "inputs": [ "$TURBO_DEFAULT$", From 78d23d0776fa42c68d503c333ad2530e0b593716 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Tue, 6 Oct 2026 23:42:06 +0000 Subject: [PATCH 4/7] ci(ci): pass the release gate with a notice when there are no decisions to mutate A decision is a *.workflow.ts file. When no workspace package has one, the planner emits an empty shard list and a 'No decisions to mutate' notice, and the mutation job is skipped. When decisions exist and no package declares a mutation script, it still refuses the empty set, naming how many decisions went unmutated. Kiro ruling, 2026-10-06 --- .github/workflows/release-gate.yml | 1 + scripts/mutation-shards.test.ts | 42 +++++++++++++++++++++++++----- scripts/mutation-shards.ts | 16 +++++++++--- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 421534d5..5f9e5e72 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -28,6 +28,7 @@ jobs: mutation: name: mutation · ${{ matrix.package }} needs: [plan] + if: ${{ needs.plan.outputs.packages != '[]' }} runs-on: ubuntu-latest timeout-minutes: 75 strategy: diff --git a/scripts/mutation-shards.test.ts b/scripts/mutation-shards.test.ts index da85b044..bf970516 100644 --- a/scripts/mutation-shards.test.ts +++ b/scripts/mutation-shards.test.ts @@ -3,7 +3,12 @@ import { join } from '@std/path' import { planMutationShards } from './mutation-shards.ts' -type PackageFixture = { readonly name: string; readonly mutate?: string[]; readonly files: string[] } +type PackageFixture = { + readonly name: string + readonly mutation?: false + readonly mutate?: string[] + readonly files: string[] +} const workspaceOf = async (packages: PackageFixture[]): Promise => { const root = await Deno.makeTempDir({ prefix: 'mutation-shards-' }) @@ -12,9 +17,10 @@ const workspaceOf = async (packages: PackageFixture[]): Promise => { const dir = join(root, 'packages', fixture.name) await Deno.mkdir(join(dir, 'src'), { recursive: true }) const stryker = fixture.mutate === undefined ? {} : { stryker: { mutate: fixture.mutate } } + const scripts = fixture.mutation === false ? {} : { scripts: { mutation: 'stryker run' } } await Deno.writeTextFile( join(dir, 'package.json'), - JSON.stringify({ name: `@fixture/${fixture.name}`, scripts: { mutation: 'stryker run' }, ...stryker }), + JSON.stringify({ name: `@fixture/${fixture.name}`, ...scripts, ...stryker }), ) for (const file of fixture.files) await Deno.writeTextFile(join(dir, file), 'export {}\n') } @@ -23,7 +29,7 @@ const workspaceOf = async (packages: PackageFixture[]): Promise => { 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: [] }) + assertEquals(await planMutationShards(root), { packages: ['@fixture/core'], refusals: [], decisions: 1 }) }) Deno.test('a package whose mutate globs match no file is refused by name and globs', async () => { @@ -34,18 +40,22 @@ Deno.test('a package whose mutate globs match no file is refused by name and glo 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 () => { const root = await workspaceOf([{ name: 'core', - mutate: ['src/**/*.ts', '!src/**/*.test.ts'], - files: ['src/a.test.ts'], + mutate: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.workflow.ts'], + files: ['src/a.test.ts', 'src/order.workflow.ts'], }]) assertEquals(await planMutationShards(root), { packages: [], - refusals: ['@fixture/core (packages/core): stryker.mutate ["src/**/*.ts","!src/**/*.test.ts"] matches no files'], + refusals: [ + '@fixture/core (packages/core): stryker.mutate ["src/**/*.ts","!src/**/*.test.ts","!src/**/*.workflow.ts"] matches no files', + ], + decisions: 1, }) }) @@ -54,5 +64,25 @@ Deno.test('a mutation script without declared mutate globs is refused', async () assertEquals(await planMutationShards(root), { packages: [], refusals: ['@fixture/core (packages/core): stryker.mutate [] matches no files'], + decisions: 1, + }) +}) + +Deno.test('a workspace without a single *.workflow.ts file has no decisions to mutate', async () => { + const root = await workspaceOf([ + { name: 'site', mutate: ['src/**/*.workflow.ts'], files: ['src/page.tsx'] }, + { name: 'tools', mutation: false, files: ['src/cli.ts'] }, + ]) + assertEquals(await planMutationShards(root), { packages: [], refusals: [], decisions: 0 }) +}) + +Deno.test('decisions that no package mutates are refused as an empty set', async () => { + const root = await workspaceOf([{ name: 'core', mutation: false, files: ['src/order.workflow.ts'] }]) + assertEquals(await planMutationShards(root), { + packages: [], + refusals: [ + '1 *.workflow.ts file(s) but no workspace package declares a `mutation` script; the release gate refuses an empty set', + ], + decisions: 1, }) }) diff --git a/scripts/mutation-shards.ts b/scripts/mutation-shards.ts index 1bb18e99..bda3043b 100755 --- a/scripts/mutation-shards.ts +++ b/scripts/mutation-shards.ts @@ -7,7 +7,9 @@ import { parse } from '@std/yaml' type Manifest = { name?: string; scripts?: Record; stryker?: { mutate?: string[] } } -export type ShardPlan = { readonly packages: string[]; readonly refusals: string[] } +export type ShardPlan = { readonly packages: string[]; readonly refusals: string[]; readonly decisions: number } + +const DECISIONS = ['**/*.workflow.ts', '!**/.stryker-tmp/**'] const matchedFileCount = async (dir: string, mutate: string[]): Promise => { const exclude = ['**/node_modules/**', ...mutate.filter((glob) => glob.startsWith('!')).map((glob) => glob.slice(1))] @@ -22,8 +24,10 @@ 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 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 manifest = JSON.parse(await Deno.readTextFile(entry.path)) as Manifest if (manifest.name === undefined || manifest.scripts?.mutation === undefined) continue const mutate = manifest.stryker?.mutate ?? [] @@ -38,10 +42,13 @@ export const planMutationShards = async (root: string): Promise => { } } } + if (decisions === 0) return { packages: [], refusals: [], decisions } if (packages.length === 0 && refusals.length === 0) { - refusals.push('no workspace package declares a `mutation` script; the release gate refuses an empty set') + 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 } + return { packages: packages.sort(), refusals, decisions } } if (import.meta.main) { @@ -51,6 +58,9 @@ if (import.meta.main) { for (const refusal of plan.refusals) console.error(`mutation-shards: ${refusal}`) Deno.exit(1) } + if (plan.decisions === 0) { + console.log('::notice title=Release gate::No decisions to mutate: no workspace package has a *.workflow.ts file.') + } const line = `packages=${JSON.stringify(plan.packages)}` console.error(`mutation-shards: ${line}`) if (output) await Deno.writeTextFile(output, `${line}\n`, { append: true }) From 9c2cf145a77f5cbe17198494e36fa70310b6dda9 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 7 Oct 2026 01:41:48 +0000 Subject: [PATCH 5/7] ci(ci): pin the shared release tooling to main, as main does --- .github/workflows/changeset-check.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/changeset-check.yml b/.github/workflows/changeset-check.yml index 01326651..a42a28d3 100644 --- a/.github/workflows/changeset-check.yml +++ b/.github/workflows/changeset-check.yml @@ -11,9 +11,9 @@ permissions: jobs: check: name: changeset · shared tooling - uses: systemfsoftware/pnpm-release-management/.github/workflows/changeset-check.yml@prm/toolchain + uses: systemfsoftware/pnpm-release-management/.github/workflows/changeset-check.yml@main with: - tools-ref: prm/toolchain + tools-ref: main permissions: contents: read pull-requests: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e9404f7..4f1e717c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,9 +15,9 @@ permissions: jobs: release: name: release · shared tooling - uses: systemfsoftware/pnpm-release-management/.github/workflows/release.yml@prm/toolchain + uses: systemfsoftware/pnpm-release-management/.github/workflows/release.yml@main with: - tools-ref: prm/toolchain + tools-ref: main permissions: contents: write pull-requests: write From 6b3c059cbb2a4d4059d9ea15b940be0a32000cf0 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 7 Oct 2026 03:00:48 +0000 Subject: [PATCH 6/7] ci(ci): run the release gate only on pushes to main starter-verify F10 and F13 (Kiro ruling, cycle 35): drop workflow_dispatch so mutation runs on push to main only; the README no longer claims Cell workflows, Cell.provide, tenant-bound store ports or compile-time phase markers, and no longer tells readers to run pnpm mutation locally --- .github/workflows/release-gate.yml | 1 - README.md | 31 +++++++++++------------------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 5f9e5e72..a26198ca 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -3,7 +3,6 @@ name: Release gate on: push: branches: [main] - workflow_dispatch: permissions: contents: read diff --git a/README.md b/README.md index 3fc6082f..4b3a0ed5 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,15 @@ AI coding agents produce TypeScript that compiles cleanly and passes shallow uni `starter` establishes an uncompromising substrate. Invariants are not aspirational guidelines or doc comments; they are enforced mechanically by linter rules, complexity ceilings, mutation test floors, and continuous integration gates. -| Concern | The Naive AI-Assisted Default | The Endgame Architecture (`starter`) | -| ------------------------ | ------------------------------------------------------------ | -------------------------------------------------------------------- | -| **Domain Logic** | ❌ Interleaved I/O, clocks, random generators, and mutations | ✅ Pure functions returning tagged `Decision` or `Refusal` unions | -| **Branching** | ❌ Sprawling nested `if`/`else` and procedural loops | ✅ Cyclomatic complexity 1 via exhaustive pattern matching (`Match`) | -| **Boundary Data** | ❌ Unchecked casts (`as unknown as Type`, `@ts-ignore`) | ✅ Strict `Schema.decode` transforming raw bytes into branded types | -| **Effect Composition** | ❌ Ambient services and eager promise invocations | ✅ Lazy `Cell` workflows composed linearly via `.pipe()` | -| **Dependency Injection** | ❌ Captured instances and deep `provideService` calls | ✅ Single `Cell.provide` at the root with `R = never` at the entry | -| **State Storage** | ❌ Direct mutation and unvalidated store writes | ✅ Tenant-bound store ports carrying write guard predicates | -| **Test Verification** | ❌ Mock-heavy tests pinning internal implementation | ✅ 100% mutation kill floor (`Stryker`) and property-based tests | -| **Configuration** | ❌ Dozens of toggles that let agents bypass strictness | ✅ Zero knobs — one proven opinionated toolchain end to end | -| **Package Entries** | ❌ Star exports (`export *`) hiding dependency graphs | ✅ Explicit named re-exports enumerated one line per symbol | -| **Refactoring** | ❌ Patching around rotten legacy modules | ✅ Delete-first rebuild with published observable pinning | +| Concern | The Naive AI-Assisted Default | The Endgame Architecture (`starter`) | +| --------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------- | +| **Domain Logic** | ❌ Interleaved I/O, clocks, random generators, and mutations | ✅ Pure functions returning tagged `Decision` or `Refusal` unions | +| **Branching** | ❌ Sprawling nested `if`/`else` and procedural loops | ✅ Cyclomatic complexity 1 via exhaustive pattern matching (`Match`) | +| **Boundary Data** | ❌ Unchecked casts (`as unknown as Type`, `@ts-ignore`) | ✅ Strict `Schema.decode` transforming raw bytes into branded types | +| **Test Verification** | ❌ Mock-heavy tests pinning internal implementation | ✅ 100% mutation kill floor (`Stryker`) and property-based tests | +| **Configuration** | ❌ Dozens of toggles that let agents bypass strictness | ✅ Zero knobs — one proven opinionated toolchain end to end | +| **Package Entries** | ❌ Star exports (`export *`) hiding dependency graphs | ✅ Explicit named re-exports enumerated one line per symbol | +| **Refactoring** | ❌ Patching around rotten legacy modules | ✅ Delete-first rebuild with published observable pinning | --- @@ -45,8 +42,6 @@ read (impure) ──► decode (pure) ──► decide (pure) ──► shape (p 4. 📦 **`shape`** — Builds pure output documents and domain events from the decision. 5. 📤 **`write`** — Persists changes, emits domain events, or returns responses. -Phase ordering is guaranteed at compile time: each phase returns branded markers that the succeeding phase demands as input. - --- ## 🧰 Toolchain @@ -134,10 +129,6 @@ pnpm lint # Run unit and integration tests pnpm test - -# Run mutation tests -pnpm mutation - # Run full CI suite locally pnpm check:ci ``` @@ -149,7 +140,7 @@ pnpm check:ci
Why does starter pin Effect 4 RC instead of Effect 3? -Effect 4 introduces first-class primitives for cell composition, branded type ordering, and modern schema transformations that enable the endgame architecture. `starter` targets the future of Effect rather than supporting legacy patterns. +Effect 4 brings the schema transformations and typed services the template's decisions and boundaries are written with. `starter` targets the future of Effect rather than supporting legacy patterns.
@@ -163,7 +154,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. +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.
From 29d1b80233a3290438d3100b3574e066b9d821fe Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 7 Oct 2026 04:49:16 +0000 Subject: [PATCH 7/7] build(repo): drop darwin from the flake and the toolchain Ryan's standing rule via Kiro (cycle 52): no macOS anywhere. The flake builds x86_64-linux and aarch64-linux only; dprint pins only Linux archives; the comment-checker sandbox loses its sandbox-exec branch; AGENTS.md says CI is Linux only --- AGENTS.md | 2 +- flake.nix | 2 +- nix/comment-checker-sandbox.nix | 62 +-------------------------------- nix/dprint.nix | 12 ++----- 4 files changed, 5 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fab02d3a..82275cb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ applications — both are workspace globs in `pnpm-workspace.yaml`. Turbo declar ## CI -- Every job runs on GitHub-hosted runners: `ubuntu-latest`, and `macos-latest` for the macOS `check:ci` leg. This repository is public, and the org's self-hosted fleet runner group admits only private repositories (fork safety), so a `[self-hosted, systemfsoftware-runner, *]` job here queues forever. Gate: review of the workflow diff. +- Every job runs on GitHub-hosted `ubuntu-latest` runners; CI is Linux only. This repository is public, and the org's self-hosted fleet runner group admits only private repositories (fork safety), so a `[self-hosted, systemfsoftware-runner, *]` job here queues forever. Gate: review of the workflow diff. - Heavy suites split across parallel hosted jobs instead of a larger runner: Linux `check:ci` runs as one job per part (format, lint, typecheck, test, dist), and mutation on `main` runs one job per package. Gate: review of the workflow diff. ## End of Session diff --git a/flake.nix b/flake.nix index d46ace52..13086228 100644 --- a/flake.nix +++ b/flake.nix @@ -21,7 +21,7 @@ outputs = { self, nixpkgs, comment-checker, importPnpmLock }: let - systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + systems = [ "x86_64-linux" "aarch64-linux" ]; forEachSystem = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system}); in { diff --git a/nix/comment-checker-sandbox.nix b/nix/comment-checker-sandbox.nix index 41e60b3c..bd7d1a46 100644 --- a/nix/comment-checker-sandbox.nix +++ b/nix/comment-checker-sandbox.nix @@ -1,4 +1,4 @@ -{ stdenvNoCC, bubblewrap, writeShellScriptBin, writeText, comment-checker }: +{ stdenvNoCC, bubblewrap, writeShellScriptBin, comment-checker }: let bin = "${comment-checker}/bin/comment-checker"; @@ -43,66 +43,6 @@ let ''${binds[@]+"''${binds[@]}"} \ -- ${bin} "$@" ''; - - # Seatbelt baseline: (deny default) plus the minimum a native binary needs to - # exec (dyld shared cache, libSystem, mach lookups, the inherited TTY). - # Network stays denied by the default rule. - seatbelt = writeText "comment-checker.sb" '' - (version 1) - (deny default) - (allow process-fork) - (allow process-exec) - (allow signal (target same-sandbox)) - (allow sysctl-read) - (allow file-read-metadata) - (allow mach-lookup - (global-name "com.apple.system.notification_center") - (global-name "com.apple.system.logger") - (global-name "com.apple.distributed_notifications@Uv3") - (global-name "com.apple.CoreServices.coreservicesd") - (global-name "com.apple.FSEvents")) - (allow file-read-data (literal "/")) - (allow file-read* - (subpath "/nix") - (subpath "/usr/lib") - (subpath "/usr/share") - (subpath "/System") - (subpath "/Library") - (subpath "/private/etc") - (subpath "/private/var/db/dyld") - (subpath "/private/var/db/timezone")) - (allow file-read* (subpath "/dev/fd")) - (allow file-read* file-write* file-ioctl - (literal "/dev/tty") - (regex #"^/dev/ttys[0-9]+$")) - (allow file-read* file-write* - (literal "/dev/null") - (literal "/dev/zero") - (literal "/dev/random") - (literal "/dev/urandom")) - (allow file-write* (subpath "/private/tmp")) - ''; - - # sandbox-exec prints a deprecation warning on stderr on every call, and this - # hook's stderr is the channel the model reads, so that one line is filtered - # out and the child's exit status is preserved. - darwin = writeShellScriptBin "comment-checker" '' - ${cwdGuard} - cwdRules="" - safe_cwd "$PWD" && cwdRules=" (subpath \"$PWD\")" - real="$(pwd -P)" - [ "$real" != "$PWD" ] && safe_cwd "$real" && cwdRules="$cwdRules (subpath \"$real\")" - profile=$(cat ${seatbelt}) - [ -n "$cwdRules" ] && profile="$profile - (allow file-read*$cwdRules)" - err=$(mktemp "''${TMPDIR:-/tmp}/comment-checker.XXXXXX") - /usr/bin/env -i /usr/bin/sandbox-exec -p "$profile" -- ${bin} "$@" 2>"$err" - status=$? - grep -vF 'sandbox-exec is deprecated' "$err" >&2 - rm -f "$err" - exit $status - ''; in if stdenvNoCC.hostPlatform.isLinux then linux -else if stdenvNoCC.hostPlatform.isDarwin then darwin else throw "comment-checker: no sandbox for ${system}" diff --git a/nix/dprint.nix b/nix/dprint.nix index e4d45497..50452e00 100644 --- a/nix/dprint.nix +++ b/nix/dprint.nix @@ -18,14 +18,6 @@ let target = "aarch64-unknown-linux-gnu"; sha256 = "6b86329e17678ff3358f88d69a3774d371b601c665cc8cebbf2a4e1234a6d289"; }; - x86_64-darwin = { - target = "x86_64-apple-darwin"; - sha256 = "fdbffa16cf0890ca30e958ffdabe7748e733867651a438ede1501f0e1a7b5e91"; - }; - aarch64-darwin = { - target = "aarch64-apple-darwin"; - sha256 = "1d6a8fb14d66cba0f049738edd4ab3b1afc1de6d936cd32e483e33284cfd1ade"; - }; }; system = stdenvNoCC.hostPlatform.system; @@ -43,9 +35,9 @@ stdenvNoCC.mkDerivation { # The archive holds one bare executable, so there is no directory to enter. sourceRoot = "."; - nativeBuildInputs = [ unzip ] ++ lib.optional stdenvNoCC.hostPlatform.isLinux autoPatchelfHook; + nativeBuildInputs = [ unzip autoPatchelfHook ]; # `xz` supplies liblzma.so.5, which the official x86_64-linux binary links. - buildInputs = lib.optionals stdenvNoCC.hostPlatform.isLinux [ stdenv.cc.cc.lib xz ]; + buildInputs = [ stdenv.cc.cc.lib xz ]; installPhase = '' runHook preInstall