From 52ce655d8e02b2730c72ca3db205ef02c62830dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 21:12:24 +0000 Subject: [PATCH] :construction_worker: Gate CI at 100% coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test:ci now runs c8 with --all --include "src/**/*.ts" --100, so the build job fails when any runtime file under src/ is untested. --all is what makes the gate non-vacuous: without it c8 counts only the files the suite happened to load, and a new untested module stays invisible. Add src/index.test.ts to load the public barrel, which was previously never imported at runtime and so read as 0% under --all. matcher-shared.ts is types-only (an empty runtime image) and carries a file-level c8 ignore with the reason. Why 100% and the rejected alternatives: development/ci.md § Coverage threshold. --- .gitea/workflows/ci.yml | 3 +++ CHANGELOG.md | 2 ++ CONTRIBUTING.md | 5 +++-- backlog.tasks | 6 +++--- development/ci.md | 33 +++++++++++++++++++++++++++++++++ development/testing.md | 3 ++- package.json | 2 +- src/index.test.ts | 27 +++++++++++++++++++++++++++ src/matcher-shared.ts | 1 + 9 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 src/index.test.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 50f6d96..b5b93fb 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -77,6 +77,9 @@ jobs: - run: npm ci - run: npm run build - run: npm run check + # Fails the build below 100% coverage on `src/` (`c8 --all --100`); + # the same run produces the report published below. See + # development/ci.md § Coverage threshold. - run: npm run test:ci # Publish this tag's coverage to the self-hosted pages server, # served read-only at diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aff1e2..e72b04e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- gate CI at 100% coverage: `test:ci` runs c8 with `--all --100` over `src/`, + and a test loads the `index.ts` barrel so it is measured - pin the duplicate-tag union collapse (members sharing a tag dispatch through one handler) with tests - track a TypeScript 5.0 baseline in the backlog diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ceb24c9..f84aad7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ faster tiers catch less, slower tiers are more thorough": | `npm run verify` | manual | Definition of done: `npm run check` + unit tests, one shot | ~6s | | `npm run fix` | manual | Auto-resolve fixable issues (lint, format) | ~3s | | `npm run maintain` | manual / CI (advisory) | `maintain:knip` + `maintain:outdated` (whole-project + network scans) | ~10s | -| CI build (auto) | on push to `main` / tag | `build` job (build + correctness + packaging) — see [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) | ~30s+ | +| CI build (auto) | on push to `main` / tag | `build` job (build + correctness + coverage + packaging) — see [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) | ~30s+ | | CI maintain (auto, non-blocking) | on push to `main` | `npm run maintain` — reports, never fails the build | ~10s | | CI publish (auto) | on tag | packaging checks + `publish:publint` / `publish:attw`, then the Gitea release page and `npm publish` (skipped, and the job failed, without `NPM_TOKEN`) | ~15s | @@ -125,7 +125,8 @@ CI step. Pick the prefix that matches the script's lifecycle: `npm run fix`; the diff is the review surface. - `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + unit tests); `test:unit` skips the typecheck for fast local iteration; - `test:ci` adds c8 coverage. + `test:ci` runs the suite under c8 and fails below 100% coverage on `src/` + (CI-only; `verify` stays coverage-free). - `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by `watch`. - `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project diff --git a/backlog.tasks b/backlog.tasks index d272be0..4c74fb7 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -12,9 +12,9 @@ v1.0: ☐ Finalize public exports in `src/index.ts` ☐ Document all exported types and functions ☐ Add JSDoc for public APIs -☐ Test coverage meets threshold - ☐ Achieve 100% branch coverage on `src/primitive-union.ts` - ☐ Achieve 100% branch coverage on `src/index.ts` +✔ Test coverage meets threshold @done + ✔ Achieve 100% branch coverage on `src/primitive-union.ts` @done + ✔ Achieve 100% branch coverage on `src/index.ts` @done Matcher: ✔ when using a union type as a property, the current behavior of tagged union matcher is @done diff --git a/development/ci.md b/development/ci.md index 374363e..5ff08a8 100644 --- a/development/ci.md +++ b/development/ci.md @@ -97,6 +97,39 @@ Leave `act_runner`'s `force_pull` disabled. (`docker rmi gitea.e1nsnull.de/tmu/act-ci:`); do not reach for force-pull. +## Coverage threshold + +#### Decision (2026-09) + +`npm run test:ci` fails below 100% statements / branches / functions / lines +across `src/**/*.ts` (`c8 --all --include "src/**/*.ts" --100`). The gate rides +the `build` job; `npm run verify` stays coverage-free. + +#### Why + +- The types are the feature, so an untested branch is a hole in the contract, + not a metric to trade off; 100% is the only threshold that means "no hole". +- `--all` counts a `src/` file no test imports. Without it c8 reports only the + files the suite happened to load, so a new untested module is invisible and + the threshold passes vacuously. +- The gate rides `test:ci`, which `build` already runs — no new job or step. +- `verify` stays fast and local; the slower coverage run is a CI-only tier (see + [workflow.md § Feedback tiers](./workflow.md#feedback-tiers)). + +#### Rejected + +- Per-file thresholds: a global 100% already forces every counted file to 100%. +- A `check:coverage` script: it would re-run the suite or read c8's temp dir, + and no `check:*` script runs tests. +- `--all` without `--include`: it would also sweep `scripts/`, which is not the + shipped surface. + +#### Known issue + +- `src/matcher-shared.ts` is types only, so its runtime image is empty; c8 still + lists it under `--all`. It carries a file-level `/* c8 ignore start */` with + the reason. Adding runtime code there means removing that directive. + ## Coverage serving #### Decision (2026-09) diff --git a/development/testing.md b/development/testing.md index 9125cbe..9fc8dfe 100644 --- a/development/testing.md +++ b/development/testing.md @@ -35,7 +35,8 @@ in [CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands). `c8` uses V8 coverage, so the `--strip-types` source is instrumented without a build step, and the runner relies on the `.ts` import-extension convention (see -[tooling.md](./tooling.md#source-imports-use-ts-extensions)). +[tooling.md](./tooling.md#source-imports-use-ts-extensions)). CI gates that +coverage at 100% (see [ci.md § Coverage threshold](./ci.md#coverage-threshold)). ## Handler arguments diff --git a/package.json b/package.json index ae089df..b1e8876 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "maintain:knip": "knip --include dependencies,exports,files", "maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node", "test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"", - "test:ci": "c8 --reporter=text --reporter=lcov --reporter=html node --test --strip-types \"src/**/*.test.ts\"", + "test:ci": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types \"src/**/*.test.ts\"", "test:unit": "node --test --strip-types \"src/**/*.test.ts\"", "verify": "npm run check && npm run test:unit", "watch": "npm run watch:test", diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..861a941 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,27 @@ +import { strict as assert } from "node:assert"; +import { test } from "node:test"; + +import { expectTypeOf } from "expect-type"; + +import { + getPrimitiveUnionMatcher, + getPrimitiveUnionMatcherW, + getTaggedUnionMatcher, + getTaggedUnionMatcherW, +} from "./index.ts"; + +// The published entry point is the barrel (`package.json` exports +// `./dist/index.js`), so every factory must be reachable from here. Importing it +// also loads the module, which is what lets c8's `--all` measure it — see +// development/ci.md § Coverage threshold. +test("index: the public entry point re-exports every matcher factory", () => { + // Assert + expectTypeOf(getPrimitiveUnionMatcher).toBeFunction(); + assert.equal(typeof getPrimitiveUnionMatcher, "function"); + expectTypeOf(getPrimitiveUnionMatcherW).toBeFunction(); + assert.equal(typeof getPrimitiveUnionMatcherW, "function"); + expectTypeOf(getTaggedUnionMatcher).toBeFunction(); + assert.equal(typeof getTaggedUnionMatcher, "function"); + expectTypeOf(getTaggedUnionMatcherW).toBeFunction(); + assert.equal(typeof getTaggedUnionMatcherW, "function"); +}); diff --git a/src/matcher-shared.ts b/src/matcher-shared.ts index ecb1732..8953ef5 100644 --- a/src/matcher-shared.ts +++ b/src/matcher-shared.ts @@ -1,3 +1,4 @@ +/* c8 ignore start -- types only: the module has no runtime image to cover */ import type { IsLiteral, IsNever, ValueOf } from "type-fest"; // The primitive-union and tagged-union matchers differ in their universe, but the