Skip to content

Commit 02f8e45

Browse files
os-billclaude
andauthored
test(spec): a battery roster, floor and verdict handshake for check-duration-unit-keys (#18916)
Fixes #18512 Clause-②: no ## The defect, re-measured on this tree before anything was written `packages/spec/scripts/check-duration-unit-keys.ts` (1471 lines) predates the self-test battery discipline: no battery roster, no pinned per-battery minimum, no module-level verdict flag. Re-run first-hand at `origin/main` 84ba4a8, not inherited: | leg | reading | |---|---| | `tsx scripts/check-duration-unit-keys.ts --self-test`, untouched | 103 checkmark lines, 0 cross lines, exit 0 | | its only summary line | `self-test: all cases pass` | | the same run with `DURATION_ROOTS` emptied | 101 registered — 95 green, 6 red, exit 1 | Two corrections to the card's own wording, both measured here: - The card says "the printed count stays non-zero" and "a reader watching the count would see 101". This self-test printed **no case count at all**. The 103 and 101 are a tally a reader has to make by hand off the checkmark lines, so the reader did not even have the clue the card credits them with. That makes the defect worse, not milder. - The card pins the census at "185 rows / 0 offenders". Today's census reads **203 unit-declaring numeric keys across 2520 source files, 0 offenders**. 185 is a stale figure from the filing date; what this PR holds is the invariant behind it — the non-self-test leg's output is byte-identical before and after. ## What landed ### The three parts, copied from `scripts/check-agent-model-declared.mjs`, never imported Copied per AGENTS.md's "Copy a landed one, never import one" — every self-test must keep running standalone. The TypeScript surface follows the sequential port in `scripts/check-test-typecheck.mts` (a readonly string-to-number record over `Object.freeze`, a `battery()` opener, an `UNATTRIBUTED_BATTERY` key). No symbol is imported from either. - `SELF_TEST_BATTERIES` — 13 declared batteries, name to minimum case count. The pattern file is table-driven, so its ROW is its battery; this self-test is a sequence of `expect()` calls, so a **section** is the battery: each opens with `battery('name')` and every `expect()` after it registers against that name. Registration is the first statement of `expect()`, before the line prints and before the failure counter moves, so the floor asserts REACH — a case that runs and fails still registers, and only a case that never runs goes missing. - `SELF_TEST_BATTERY_FLOOR` — the roster's own size (13), so deleting an entry cannot silence its floor. - `selfTestReachedVerdict` — set after the verdict line prints, refused at the dispatch in `main()`. Worth spelling out here because the exit code travels further in this file than in the pattern: `selfTest()` returns a number, `main()` returns it, `process.exit(main(...))` reports it, and a 0 rides that whole path just as happily when it comes from a `return` placed above the verdict. Two floors are deliberately below their live count, and the roster comment says why: the two workspace-direction batteries generate one case per workspace root (11 each today), and adding or removing a workspace package is ordinary work that must not red this file. Their floor of 1 pins REACH — the silently-empty enumeration this file's own docblock names — not the size of the workspace. ### The two vacuous cases now discriminate - `compliant by TYPE: name, describe and type all agree` asserted `rulesOf(...).join() === ''`. A key OUTSIDE the population yields `''` too, so with the type channel ablated away it read green — "compliant" was indistinguishable from "absent". It now reads the site: duration type, type units, key units, prose units, admission and verdict. This is the tightening PR #18486 already landed twenty lines above it, applied to the case it left behind. - `the declared duration units are units this reader actually knows` was an `every` over a possibly-empty map, vacuously true in exactly the ablated run where the two coupling cases stopped existing. Non-emptiness is asserted first; how many there must be is the floor's job. A negative control was added beside it, in this file's own convention, so the right-hand side is known to be able to answer false. ## LIT — the floor fires, and the cases that were vacuous go red Every ablation ran from the committed fix, under a trap that restores on EXIT/INT/TERM, each mutation proven on disk by occurrence count before the run and each restore proven afterwards by `git hash-object` equality with the HEAD blob plus an empty `git diff HEAD`. | ablation | on the PRE-FIX file | on this branch | |---|---|---| | `DURATION_ROOTS` emptied (the card's item 3) | 95 green + 6 red = **101 registered**, no floor, no count printed | 94 green + 8 red + **2 floor lines**, `102 case(s) registered` printed | | — the case `compliant by TYPE: name, describe and type all agree` | **green** (vacuous) | **red** | | — the case `the declared duration units are units this reader actually knows` | **green** (vacuous) | **red** | | one `battery()` opener deleted (de-register a battery) | n/a | red: battery `the walk's exclusions, pinned BEHAVIOURALLY (#15682)` **DID NOT RUN — 0 cases registered, 2 pinned** | | one roster entry deleted | n/a | red twice: roster **declares 12, below the pinned 13**, and its cases are **attributed to no declared battery** | | a `return 0;` inserted above the verdict | exit **0**, **zero bytes** of output | exit **1**, the dispatch refusing: "selfTest() returned without reaching its verdict" | The floor's text names how many are missing, which is what the card asked for: ``` self-test floor: self-test battery "the duration vocabulary is really exported, and its units are ones this reader knows" registered 2 case(s), below its pinned floor of 4 — 2 case(s) that used to run no longer do. ``` ## DARK — the gate's verdicts do not move The non-`--self-test` leg, run before and after on the same tree, and again after merging `origin/main`: ``` ✓ check:duration-unit-keys — 203 unit-declaring numeric key(s) across 2520 source file(s) all carry their unit in the key name (or in a sibling `unit`, or under a declared exemption: 6 declared duration type(s) (`DurationMs`/`DurationSeconds`), 15 declared `EpochMs` instant(s), 13 declared `externalVocabulary` mirror(s), 0 declared `dimensionless` number(s)); zero offenders, no baseline. ``` All four captures are byte-identical, sha256 `6a62415e9609b578e4228b7336f04a69c25c91d15fa870eec154a9d7c9540ff5`. ## Count leg — 103 to 104, enumerated The roster is a partition of the cases that already ran; adding it registered no case and removed none. Exactly **one** case is new: - `negative control: a unit this reader does NOT know is absent from UNIT_SPELLINGS` — the control for the de-vacuumed `every` above. The other two touched cases are rewrites in place, not additions: `compliant by TYPE` and `the declared duration units are units this reader actually knows` each still print one line. The verdict line now prints that number instead of leaving it to a hand-tally: ``` self-test: all cases pass — 104 case(s) across 13 batteries, every battery at or above its pinned floor ``` Printing the count is a fourth thing the card's item 1 did not name, so it is argued rather than slipped in: the pattern being copied prints one on its own verdict line, so omitting it would have been a deviation from the copy; AGENTS.md's objection is that a count is "EVIDENCE, NOT PROOF", and the proof it asks for lands in the same commit; and a landed sibling (`scripts/pm/changeset-deadline-census.mjs`) already prints "all cases passed across N batteries". It moves no verdict. ## Acceptance notes Noted, not filed: - The card's "185 rows / 0 offenders" is stale: the census reads 203 keys across 2520 files today. Nothing is wrong with the gate; the figure was taken at the filing head. - Sibling gates share the gap, named here for a successor card rather than touched: of the 13 `--self-test`-carrying scripts wired in `packages/spec/package.json` that live in this package, **12 carry neither a roster nor a verdict flag** — `build-declaration-map.ts`, `build-export-origins.ts`, `build-migration-registry.ts`, `check-browser-reachable-entries.ts`, `check-dual-source-exports.ts`, `check-error-code-provenance.ts`, `check-exported-any.ts`, `check-llms-txt.ts`, `check-objectui-pin-citations.ts`, `check-skill-examples.ts`, `check-template-manifests.ts`, `check-yaml-examples.ts`. This one is now the thirteenth. Untouched, as fenced: the agreement carve-out and its two cases labelled `DEFERRED to #18075`, the `dimensionless` renderer half (#18500), and every line under `packages/spec/src/`. ## Verification - `pnpm --filter @objectstack/spec typecheck` — exit 0 (includes `check:scripts-typecheck`, the program that type-checks this file). - `pnpm --filter @objectstack/spec test` — exit 0, 487 test files / 14125 tests passed. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` — 54 families derived, all 54 run with exit codes landed to disk, then reconciled with `--ran`: 54 accounted for, 0 unrun. 50 exit 0; 4 exit **3 = PREREQUISITE NOT MET** in a worktree with no `dist/` (`check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`) — by those gates' own words neither a pass nor a failure, NOT MEASURED here, and CI builds before running them. - `skip-changeset` measured rather than assumed: `npm pack --dry-run` on `@objectstack/spec` lists 275 entries; **zero** under `scripts/` and zero naming this file, with README.md and 202 `src/**/*.zod.ts` present as the positive control. The tarball does not move. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 02bdeaa commit 02f8e45

1 file changed

Lines changed: 211 additions & 6 deletions

File tree

0 commit comments

Comments
 (0)