enhancement(#537): pilot TS build-at-publish for bin/lib (semver-compare) (#541)

* docs(#457): rewrite ADR-457 to ground truth and accept build-at-publish

The prior draft asserted a codebase state that never existed (13 tsc-generated
files, src/ trees, a tests/cjs-ts-parity.test.cjs). Corrected to verified ground
truth (84 bin/lib .cjs, 1 value-baked package-identity.cjs, no tsc pipeline),
distinguished value-baking from transpilation so package-identity stops being
miscited as precedent, made check-in-the-artifact vs build-at-publish the central
decision, and flipped status to Accepted (build-at-publish).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* build(#537): pilot TS build-at-publish for bin/lib (semver-compare)

First hand-written module collapsed to a TypeScript source of truth per ADR-457.
src/semver-compare.cts compiles (tsc, strict, noEmitOnError) to a gitignored
get-shit-done/bin/lib/semver-compare.cjs. build:lib is wired into build, pretest,
pretest:coverage, and prepublishOnly so the artifact is built before test and
shipped on publish. Type-aware ESLint on src/**/*.cts immediately caught the
params were over-typed as `unknown` (no-base-to-string); narrowed to a honest
VersionInput domain type. Behavior preserved: semver-compare.test.cjs (14) and
bug-10 (4) pass against the generated output; runtime consumer changeset/cli.cjs
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): make build-at-publish robust across all CI paths (codex review)

Adversarial review found the pilot's generated artifact would be missing on
clean CI checkouts. `pretest`/`pretest:coverage` only fire for `npm test`, but
CI runs `test:unit`/`test:integration`/`test:install` and `node run-tests.cjs`
directly — none of which built the artifact, so any suite requiring
semver-compare.cjs would hit module-not-found on a clean checkout, and
install-smoke's `npm pack` could ship without it.

- Add a `prepare` script (`npm run build:lib`). `npm ci` runs it automatically,
  so every CI test job and install-smoke's pack emit the artifact before use.
  This is the idiomatic npm mechanism for compiled-output-not-in-git and fixes
  both the test and pack paths in one place.
- Add `src/` + `tsconfig.build.json` to ci-test-scope and the install-smoke /
  mutation path filters, so a source-only edit to a migrated module still
  triggers its tests and mutation coverage (prevents silent CI skips).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): map src/*.cts to built artifact in mutation changed-files detection

Follow-up to the codex re-review. The prior commit added src/**/*.cts to the
mutation workflow's path trigger but left its "compute changed core lib files"
step diffing only get-shit-done/bin/lib/**/*.cjs — which are now gitignored and
never appear in a diff. A source-only edit would trigger the workflow then
early-exit ("no core lib files changed"), silently skipping mutation testing.

Map each changed src/*.cts to its built get-shit-done/bin/lib/*.cjs path (the
on-disk artifact Stryker mutates after prepare/build:lib), merge with the
hand-written .cjs diff, and apply the test/excluded-module filters once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): use 'src/' pathspec in mutation diff (git glob doesn't match top-level)

Codex review caught that `git diff -- 'src/**/*.cts'` returns empty for a
top-level file like src/semver-compare.cts — git's default pathspec glob does
not match `**` across zero directories (verified on git 2.50.1). The prior
commit's src-detection therefore never fired, so source-only changes still
skipped mutation. Switch to the dir-scoped pathspec 'src/' + a `.cts` grep
(robust for flat and nested layouts), and broaden the workflow path trigger to
'src/**' to match install-smoke. Verified end-to-end: a change to
src/semver-compare.cts now resolves to get-shit-done/bin/lib/semver-compare.cjs
in the --mutate list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#537): add changeset fragment for build-at-publish pilot

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#537): replace prepare with prepack + build-if-missing; defer mutation wiring

CI surfaced three real issues the local run and codex review missed:

1. lockfile-sync failed on every platform. Root cause: `npm ci --dry-run` (the
   repo's lockfile health check) RUNS the `prepare` script, but in dry-run the
   devDependencies aren't installed, so `tsc` is not found (exit 127) and the
   check reports a misleading "out of sync". `prepare` is the wrong hook for a
   build needing a devDep. Replace it with `prepack` (runs only on pack/publish,
   when node_modules exists) for the tarball path, and build the artifact inside
   scripts/run-tests.cjs (build-if-missing) for the test path — the universal
   chokepoint every CI test invocation funnels through, including the direct
   `node run-tests.cjs --files-from` step that bypasses npm lifecycle hooks. The
   guard is a no-op once built, so the run-tests harness test is unaffected.

2. The Stryker mutation gate ran only 1 test against semver-compare (~0% score,
   71/71 mutants surviving) — a Stryker test-selection problem orthogonal to the
   build migration, and raising the score needs property tests (ADR-456). Revert
   the mutation.yml src wiring; mutation coverage for src-authored modules is a
   separate follow-up tracked in #537. (The deletion of the gitignored top-level
   .cjs does not match the workflow's `bin/lib/**/*.cjs` git pathspec, so the
   gate skips cleanly.)

Verified: clean-room `npm ci --dry-run` exits 0; deleting the artifact then
running a suite rebuilds it; run-tests harness 22/22 green; `npm pack` includes
the built artifact via prepack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-31 14:47:48 -04:00
committed by GitHub
parent 6fc84528e5
commit 5cd52eb151
11 changed files with 280 additions and 86 deletions

51
src/semver-compare.cts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Shared semver comparison utility (ADR-457 pilot: first hand-written
* bin/lib/*.cjs collapsed to a TypeScript source of truth).
*
* Logic is preserved byte-for-behaviour from the prior hand-written
* `get-shit-done/bin/lib/semver-compare.cjs`; only types are added. The
* normalization policy here is locked by `tests/semver-compare.test.cjs` and
* consumed by update-check, statusline dev-install detection, and changeset
* range compare (`scripts/changeset/cli.cjs`).
*/
/** [major, minor, patch] — non-negative integers, never NaN. */
export type SemverTuple = [number, number, number];
/** Comparison result: -1 (a < b), 0 (equal), 1 (a > b). */
export type CompareResult = -1 | 0 | 1;
/**
* What callers actually pass: a version string (`"1.2.3"`, `"v1.2.3-rc.1"`), a
* bare number, or a missing value. The old hand-written `.cjs` typed these as
* `unknown` and leaned on `String()` — which the type-aware lint flagged as an
* `[object Object]` hazard. Narrowing to the real domain type is the fix.
*/
export type VersionInput = string | number | null | undefined;
export function toNumericTuple(input: VersionInput): SemverTuple {
const cleaned = String(input == null ? '' : input).trim().replace(/^v/, '');
const base = cleaned.replace(/[-+].*$/, '');
const parts = base.split('.');
const major = Number.parseInt(parts[0], 10) || 0;
const minor = Number.parseInt(parts[1], 10) || 0;
const patch = Number.parseInt(parts[2], 10) || 0;
return [major, minor, patch];
}
export function compareSemverCore(a: VersionInput, b: VersionInput): CompareResult {
const [a0, a1, a2] = toNumericTuple(a);
const [b0, b1, b2] = toNumericTuple(b);
if (a0 !== b0) return a0 > b0 ? 1 : -1;
if (a1 !== b1) return a1 > b1 ? 1 : -1;
if (a2 !== b2) return a2 > b2 ? 1 : -1;
return 0;
}
export function isSemverNewer(a: VersionInput, b: VersionInput): boolean {
return compareSemverCore(a, b) > 0;
}
export function isStableTripletSemver(v: VersionInput): boolean {
return /^\d+\.\d+\.\d+$/.test(String(v || '').replace(/^v/, ''));
}