👷 Gate CI at 100% coverage
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.
This commit is contained in:
1 parent
76327a8c47
commit
52ce655d8e
9 files changed
+75
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -97,6 +97,39 @@ Leave `act_runner`'s `force_pull` disabled.
|
||||
(`docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>`); 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user