Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/release-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Release gate

on:
push:
branches: [main]

permissions:
contents: read

concurrency:
group: release-gate-${{ github.ref }}
cancel-in-progress: false

jobs:
plan:
name: plan · mutation shards
runs-on: ubuntu-latest
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]
if: ${{ needs.plan.outputs.packages != '[]' }}
runs-on: ubuntu-latest
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
31 changes: 11 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand All @@ -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
Expand Down Expand Up @@ -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
```
Expand All @@ -149,7 +140,7 @@ pnpm check:ci
<details>
<summary><strong>Why does starter pin Effect 4 RC instead of Effect 3?</strong></summary>

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.

</details>

Expand All @@ -163,7 +154,7 @@ Every configuration toggle provides a route for AI agents to downgrade verificat
<details>
<summary><strong>How does mutation testing work in this template?</strong></summary>

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.

</details>

Expand Down
4 changes: 2 additions & 2 deletions nix/dprint.nix
Original file line number Diff line number Diff line change
Expand Up @@ -35,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
Expand Down
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,20 @@
"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",
"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"
}
32 changes: 27 additions & 5 deletions packages/starter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
]
}
}
11 changes: 4 additions & 7 deletions packages/starter/stryker.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
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',
options: { prioritizePerformanceOverAccuracy: true },
},
],
checkers: [{ plugin: '@systemfsoftware/stryker-js-typescript-checker' }],
coverageAnalysis: 'perTest',
disableBail: true,
htmlReporter: { fileName: 'reports/mutation-report.html' },
Expand All @@ -15,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: {
Expand Down
13 changes: 13 additions & 0 deletions scripts/deno.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
52 changes: 52 additions & 0 deletions scripts/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 88 additions & 0 deletions scripts/mutation-shards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { assertEquals } from '@std/assert'
import { join } from '@std/path'

import { planMutationShards } from './mutation-shards.ts'

type PackageFixture = {
readonly name: string
readonly mutation?: false
readonly mutate?: string[]
readonly files: string[]
}

const workspaceOf = async (packages: PackageFixture[]): Promise<string> => {
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 } }
const scripts = fixture.mutation === false ? {} : { scripts: { 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')
}
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 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'],
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', '!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","!src/**/*.workflow.ts"] matches no files',
],
decisions: 1,
})
})

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'],
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,
})
})
Loading
Loading