From 27daf4bbba468230f965399f9be34cd8541eb7ec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:40:10 +0100 Subject: [PATCH 1/2] docs(readme): replace the non-null assertion in the usage example Indexing a roster result is T | undefined under noUncheckedIndexedAccess; showing a ! assertion in the first example teaches exactly the pattern the package's own lint config bans. Look the session up by name and guard the undefined case instead. --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2aa2a52..764e78b 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,11 @@ peer.on("message", (m) => console.log(`${m.fromName ?? m.from}: ${m.body}`)); peer.on("receipt", (r) => console.log(`status: ${r.status}`)); const sessions = await peer.roster(); -const { msgId } = await peer.send(sessions[0]!.pid, "hello from my app"); - -await peer.subscribeIdle(sessions[0]!.pid); +const first = sessions.find((s) => s.name === "claude"); +if (first !== undefined) { + const { msgId } = await peer.send({ pid: first.pid }, "hello from my app"); + await peer.subscribeIdle({ pid: first.pid }); +} peer.on("idle", (n) => console.log(`session ${n.state}`)); // …later From 6c0ecdc0e7c73cd70409b32b10e11ba1e7c6ade5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 12 Sep 2026 22:54:48 +0100 Subject: [PATCH 2/2] ci: gate coverage and mutation score at 100 percent with a lint ban on exemptions vitest.config.ts enforces 100 percent statements, branches, functions, and lines: the coverage run exits non-zero below any threshold, so the CI step running it is the gate rather than a report. stryker.config.ts sets thresholds.break to 100 over src/domain and src/schemas, and CI runs the mutation suite as a required verify step alongside coverage; prepublishOnly chains the coverage gate ahead of publish. A local ESLint rule (local/no-stryker-disable, proven firing on a canary) bans mutation-exemption comments in any form, keeping the 100 percent score honest: an unkilled mutant must be killed by a test or fixed in the design, never waived by a comment. --- .github/workflows/ci.yml | 9 +++- eslint.config.ts | 31 ++++++++++++ package.json | 6 ++- pnpm-lock.yaml | 103 +++++++++++++++++++++++++++++++++++++-- stryker.config.ts | 7 +++ 5 files changed, 147 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37aa6d2..df4b045 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,8 +43,13 @@ jobs: - name: Typecheck run: pnpm typecheck - - name: Test - run: pnpm test + - name: Test with coverage gate + # vitest.config.ts enforces 100% statements/branches/functions/lines: the run exits non-zero below any threshold, so this step IS the coverage gate rather than a report-only pass. + run: pnpm test:coverage + + - name: Mutation gate + # stryker.config.ts enforces a 100% mutation score (thresholds.break): any surviving mutant in src/domain or src/schemas fails the build. + run: pnpm test:mutation - name: Build run: pnpm build diff --git a/eslint.config.ts b/eslint.config.ts index b00deb6..402426f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,6 +1,31 @@ import { exadevConfig } from "@exadev/eslint-config"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; import globals from "globals"; +import type { Rule } from "eslint"; + +/** + * Bans mutation-exemption comments in any form (the "Stryker" + "disable" pair, case-insensitive). The mutation score is gated at 100% (stryker.config.ts), so exempting a mutant anywhere is a silent hole in that gate: the ban keeps the score honest by forcing an unkilled mutant to be either killed by a test or fixed in the design, never waived by a comment. + */ +const noStrykerDisable: Rule.RuleModule = { + create(context) { + const comments = context.sourceCode.getAllComments(); + for (const comment of comments) { + const loc = comment.loc; + if ( + loc !== undefined && + loc !== null && + /stryker\s+disable/i.test(comment.value) + ) { + context.report({ + loc, + message: + "Mutation-exemption comments are banned: kill the mutant with a test or fix the design, never exempt it.", + }); + } + } + return {}; + }, +}; export default exadevConfig( {}, @@ -32,6 +57,12 @@ export default exadevConfig( ], }, }, + { + plugins: { + local: { rules: { "no-stryker-disable": noStrykerDisable } }, + }, + rules: { "local/no-stryker-disable": "error" }, + }, /* Test fixtures legitimately encode raw protocol values (16-byte tokens, 24-hex hop ids, chain lengths); naming them would obscure the fixture. */ { diff --git a/package.json b/package.json index 4aee532..a183864 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,9 @@ "test": "vitest run", "test:watch": "vitest", "test:mutation": "stryker run", - "prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && publint && attw --pack", - "prepare": "husky" + "prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm build && publint && attw --pack", + "prepare": "husky", + "test:coverage": "vitest run --coverage" }, "dependencies": { "zod": "4.5.4" @@ -90,6 +91,7 @@ "@stryker-mutator/core": "10.0.0", "@stryker-mutator/vitest-runner": "10.0.0", "@types/node": "26.4.1", + "@vitest/coverage-v8": "5.0.0", "commitlint": "21.2.2", "eslint": "10.10.0", "eslint-config-prettier": "10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c471942..131d026 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,10 +57,13 @@ importers: version: 10.0.0(@types/node@26.4.1) '@stryker-mutator/vitest-runner': specifier: 10.0.0 - version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@5.0.0) '@types/node': specifier: 26.4.1 version: 26.4.1 + '@vitest/coverage-v8': + specifier: 5.0.0 + version: 5.0.0(vitest@5.0.0) commitlint: specifier: 21.2.2 version: 21.2.2(@types/node@26.4.1)(conventional-commits-filter@6.0.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) @@ -108,7 +111,7 @@ importers: version: 6.0.3 vitest: specifier: 5.0.0 - version: 5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -257,6 +260,10 @@ packages: resolution: {integrity: sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0': resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} engines: {node: ^22.18.0 || >=24.11.0} @@ -277,6 +284,11 @@ packages: resolution: {integrity: sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@8.0.4': resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} engines: {node: ^22.18.0 || >=24.11.0} @@ -374,10 +386,18 @@ packages: resolution: {integrity: sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==} engines: {node: ^22.18.0 || >=24.11.0} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@babel/types@8.0.4': resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} engines: {node: ^22.18.0 || >=24.11.0} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@braidai/lang@1.1.2': resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} @@ -1319,6 +1339,23 @@ packages: resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/coverage-v8@5.0.0': + resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} + peerDependencies: + '@vitest/browser': 5.0.0 + vitest: 5.0.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@1.0.1': + resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.1': + resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==} + engines: {node: '>=22'} + '@vitest/mocker@5.0.0': resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} peerDependencies: @@ -1551,6 +1588,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -2500,6 +2540,9 @@ packages: magic-string@1.2.3: resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + make-asynchronous@1.1.0: resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} engines: {node: '>=18'} @@ -3190,6 +3233,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3723,6 +3770,8 @@ snapshots: '@babel/traverse': 8.0.4 '@babel/types': 8.0.4 + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -3736,6 +3785,10 @@ snapshots: '@babel/template': 8.0.0 '@babel/types': 8.0.4 + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + '@babel/parser@8.0.4': dependencies: '@babel/types': 8.0.4 @@ -3847,11 +3900,18 @@ snapshots: '@babel/types': 8.0.4 obug: 2.1.4 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.4': dependencies: '@babel/helper-string-parser': 8.0.0 '@babel/helper-validator-identifier': 8.0.4 + '@bcoe/v8-coverage@1.0.2': {} + '@braidai/lang@1.1.2': {} '@cacheable/memory@2.2.0': @@ -4616,14 +4676,14 @@ snapshots: '@stryker-mutator/util@10.0.0': {} - '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))': + '@stryker-mutator/vitest-runner@10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.4.1))(vitest@5.0.0)': dependencies: '@stryker-mutator/api': 10.0.0 '@stryker-mutator/core': 10.0.0(@types/node@26.4.1) '@stryker-mutator/util': 10.0.0 semver: 7.8.5 tslib: 2.8.1 - vitest: 5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@turbo/darwin-64@2.10.12': optional: true @@ -4808,6 +4868,24 @@ snapshots: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 + '@vitest/coverage-v8@5.0.0(vitest@5.0.0)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.1 + '@vitest/istanbul-lib-report': 1.0.1 + ast-v8-to-istanbul: 1.0.5 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + + '@vitest/istanbul-lib-coverage@1.0.1': {} + + '@vitest/istanbul-lib-report@1.0.1': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.1 + '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -4966,6 +5044,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + balanced-match@4.0.4: {} baseline-browser-mapping@2.11.21: {} @@ -5874,6 +5958,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + make-asynchronous@1.1.0: dependencies: p-event: 6.0.1 @@ -6501,6 +6591,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinyrainbow@3.1.1: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6670,7 +6762,7 @@ snapshots: tsx: 4.23.13 yaml: 2.9.0 - vitest@5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -6688,6 +6780,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.4.1 + '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) transitivePeerDependencies: - msw diff --git a/stryker.config.ts b/stryker.config.ts index d99c6ad..10e4c34 100644 --- a/stryker.config.ts +++ b/stryker.config.ts @@ -4,6 +4,13 @@ const options = { coverageAnalysis: "perTest", mutate: ["src/domain/**/*.ts", "src/schemas/**/*.ts", "!src/**/*.test.ts"], tempDirName: ".stryker-tmp", + // 100% across the board: a surviving mutant is a behaviour the test suite does not pin. Mutation-exemption comments are banned by the lint rule in eslint.config.ts: a mutant that genuinely cannot be killed is a design smell to fix, not to exempt. + thresholds: { + high: 100, + low: 100, + break: 100, + }, + ignorePatterns: ["dist", "dist-sea", "coverage", ".turbo"], }; export default options;