Skip to content

fix: test adequacy and structural hardening - #7

Merged
eagle-head merged 11 commits into
mainfrom
fix/test-adequacy-structural
Jun 27, 2026
Merged

fix: test adequacy and structural hardening#7
eagle-head merged 11 commits into
mainfrom
fix/test-adequacy-structural

Conversation

@eagle-head

@eagle-head eagle-head commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Summary

Brings the test suite from "100% line coverage" to genuine adequacy and resolves the structural issues that audit surfaced. The branch upgrades the test infrastructure (Vitest 4, mutation testing, property-based tests), hardens the core engine, tightens CI/security, and fixes documentation that disagreed with the code. Every fix is a structural solution — no workarounds.

The final layer was a multi-agent review → re-adjudicate → fix → verify pipeline: a dimension-decomposed code review produced a finding catalog, each finding was adversarially re-checked for a real/not-real verdict, the real ones were resolved, and the result was independently ground-truthed (gate + coverage re-run by hand, not trusted from agent self-reports).

What changed

Testing infrastructure

  • Adopt Vitest 4; require Node 22 (build(deps)).
  • Add Stryker mutation suites for both packages with break thresholds (core 85, react 75) and dedicated *-mutation test files.
  • Add property-based decomposition tests (fast-check) asserting the reconstruction law and totality over the input space.

Core engine hardening

  • Harden clock handling: wrap every resolved time provider in a monotonic, finite-guarded source so NaN/Infinity/backward readings (NTP/DST/sleep-wake) are repaired instead of corrupting the countdown.
  • Introduce clampSeconds() as the single source of truth for seconds sanitization, shared by buildSnapshot, the formatters, and the published testing-utils (collapses three duplicated clamps into one).
  • buildSnapshot now decomposes the same clamped value it stores, so parts always reconstruct totalSeconds — including above MAX_SAFE_INTEGER, where a raw decompose() previously diverged (buildSnapshot(1e16) stored the cap but reconstructed 1e16; Number.MAX_VALUE reconstructed NaN). This consistency bug was caught during the ground-truth pass, after the fix agents had already reported SHIP.
  • signalStateChange is now the sole transition notifier — the redundant trailing emits on start/pause/resume/stop/complete are removed, so each transition emits exactly one snapshot.

CI / security

  • Add a shared bin/quality-gate.sh used identically by the git hooks and CI.
  • Bump GitHub Actions to latest majors and Node 22.
  • Harden the docs-deploy workflow against workflow_run abuse: default workflow-level permissions to none, scope the build job to contents:read, gate it to workflow_dispatch or a successful same-repository push, and isolate pages:write/id-token:write to the deploy job.

Tooling / docs

  • Drop Changesets in favor of the manual lockstep version-bump + tag-triggered publish flow (scripts/version-bump.sh, see RELEASING.md).
  • Complete the CountdownParts JSDoc (all nine fields, ranges, reconstruction law) and fix inaccurate examples / stale references across the docs site and the core README.

Verification

  • bash bin/quality-gate.sh --full — green (build + format:check + lint + typecheck + test).
  • Coverage 100% on both packages (statements / branches / functions / lines); branches improved from the prior 99.16% baseline — the clampSeconds consolidation and the direct decompose guard tests closed the remaining gaps.
  • Tests: core 420 passing, react 18 passing.
  • The MAX_SAFE_INTEGER consistency invariant is re-proven for the boundary inputs (1e16, Number.MAX_VALUE).

SemVer

This branch makes observable behavior changes (single-emit transitions, stricter sanitization, hardened clock). Under the 0.x policy these ride a minor bump; the actual version bump + tag is a separate release step per RELEASING.md and is intentionally not included here.

- Upgrade Vitest 1 -> 4 (coverage-v8, ui), jsdom -> 29, and
  @testing-library/react 15 -> 16 (+ @testing-library/dom 10).
- Add Stryker (core + vitest-runner) and fast-check; wire `npm run
  test:mutation` and the `version-bump` script (used by the release flow).
- Require Node >= 22 across all package manifests; bump packages to 0.3.0
  for the next release (changelog in the release commit).
- Vitest 4 changed the mock generic signature; migrate the affected suites
  (vi.fn<[Args], Return> -> vi.fn<(args) => Return>).
- Drop the @changesets/cli devDependency and exclude .stryker-tmp from Vitest
  so sandboxed copies are not collected as tests.
Five latent bugs (100% line coverage missed them; a deep test-adequacy
audit surfaced them) are fixed and pinned with red-first + property tests:

- A caller timeProvider returning NaN/Infinity or moving backward
  (NTP/DST/sleep-wake) could leak NaN into snapshots, run the timer forever,
  count it up, or complete it instantly. Wrap every provider in a monotonic,
  finite-enforcing guard and clamp remaining to [0, initialValue].
- A non-finite/non-positive tickIntervalMs reached setInterval as NaN/Infinity
  (a ~0ms tight loop); sanitize to the 100ms default / 10ms minimum.
- The year/week/day breakdown mixed bases (year=365d but weeks % 52 = 364d), so
  parts lost data (364 days rendered all-zero) and never reconstructed the
  total. Replace with one canonical successive-subtraction decompose() shared by
  the engine, formatters, and testing-utils (removing three divergent copies).
- setSeconds(n) now validates like the constructor and reset(n), throwing on an
  invalid value instead of silently coercing it.

Also corrects two existing tests that had encoded the old buggy boundary values.
Close the oracle gaps the 100%-covered suite missed: per-file kill-tests that
assert observable behavior (not just execution), plus property/totality checks.
This lifts the core mutation score from ~80% to ~94% (decompose.ts at 100%) and
react to ~78%; the rest are documented equivalent mutants.

Configure Stryker per package (vitest runner, break thresholds core 85 / react
75; react uses inPlace for the source-aliased monorepo) and ignore the
.stryker-tmp / reports artifacts.
- Set the CI test matrix to Node 22 (dropping 18/20, which the >=22 engines and
  Stryker no longer support).
- Update all actions to current majors: checkout v7, setup-node v6,
  configure-pages v6, deploy-pages v5, upload-pages-artifact v5.
- Document the new setSeconds/reset throw-on-invalid contract in the core and
  react READMEs and core-usage.
- Update Node to 22 (consumer requirement in core README; contributor toolchain
  in CONTRIBUTING) and note Vitest 4, Stryker (`npm run test:mutation`),
  fast-check, and the canonical decompose.ts in CONTRIBUTING and CLAUDE.md.
- Replace Changesets references with the version-bump + tag release flow, and
  point the CHANGELOG guidance at SemVer + Keep a Changelog.
Replace Changesets with a tag-triggered npm publish, modeled on the erli18n
release pattern and npm's publishing docs:

- Remove .changeset/ (the @changesets/cli devDep and changeset/version/release
  scripts were dropped in the toolchain commit) in favor of a manual lockstep
  bump, scripts/version-bump.sh (sed-based, SemVer-validated, keeps react -> core
  in step).
- Add .github/workflows/release.yml: on a v* tag it validates the tag against
  BOTH package versions, runs the full gate + dry-run, publishes core then react
  (polling the registry between) with --provenance, and cuts a GitHub Release
  from the CHANGELOG. Gated behind the npm-publish environment (one-time
  NPM_TOKEN); OIDC trusted publishing documented as an alternative.
- Reformat both CHANGELOGs to Keep a Changelog and migrate the pending changeset
  into the 0.3.0 section; document the flow in RELEASING.md and update the PR
  template to ask for a CHANGELOG entry instead of a changeset.
Adopt erli18n's single-source-of-truth gate, adapted to npm:

- bin/quality-gate.sh runs build + format:check + lint + typecheck (+ test in
  the full lane). hooks/ (pre-commit -> fast, pre-push -> full) install via
  hooks/install.sh (git core.hooksPath).
- CI runs the SAME script in one 'gate (Node 22)' job instead of four separate
  jobs, and gains least-privilege 'permissions: contents: read'.
- Bring pre-existing files into Prettier compliance, now that format:check is
  enforced by the gate (it had never been run before).
- Document the gate and hooks in CONTRIBUTING and CLAUDE.md.

Branch protection required-checks are updated out-of-band (gh) to 'gate (Node 22)'.
Introduce clampSeconds() as the single source of truth for sanitizing a
seconds-like input (non-finite/negative -> 0, floor, cap at
MAX_SAFE_INTEGER), shared by buildSnapshot, the formatters, and the
published testing-utils so every entry point sanitizes identically and
the three duplicated clamp expressions collapse into one. (F09)

buildSnapshot now decomposes the SAME clamped value it stores, so parts
always reconstruct the stored totalSeconds — including above
MAX_SAFE_INTEGER, where a raw decompose() diverged: buildSnapshot(1e16)
stored the cap but reconstructed 1e16, and Number.MAX_VALUE
reconstructed NaN. Both stored second counts are now non-negative
integers for every caller. (F01)

signalStateChange is now the sole transition notifier; the redundant
trailing emits on start/pause/resume/stop/complete are removed, so each
transition emits exactly one snapshot. (F11)

Complete the CountdownParts JSDoc with all nine fields, the field
ranges, and the reconstruction law. (F13)

Tests: drive decompose's own non-finite/negative/non-integer guard
directly (F22), add MAX_SAFE_INTEGER boundary regressions (1e16,
MAX_VALUE), a timer-mutation case, and update the React hook test for
the single-emit transition sequence.
Correct documented behavior and examples that disagreed with the code:
align the changelog with the actual API surface, fix the core-usage and
api-reference snippets, the examples page, FAQ, and React integration
notes, and add the missing details to the core README.
Apply least privilege to the docs deploy workflow: default the
workflow-level permissions to none, scope the build job to
contents:read, and gate it so it only runs on workflow_dispatch or a
successful push workflow_run originating from this repository (closing
the pwn-request / fork-branch bypass). Isolate pages:write and
id-token:write to the deploy job. (F02)
Align the @types/node devDependency with the Node 22 entry in the CI
matrix. (F20)
@eagle-head eagle-head self-assigned this Jun 27, 2026
@eagle-head
eagle-head merged commit 3ffc8b6 into main Jun 27, 2026
1 check passed
@eagle-head
eagle-head deleted the fix/test-adequacy-structural branch June 27, 2026 01:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant