Skip to content

test(spec): a battery roster, floor and verdict handshake for check-duration-unit-keys - #18916

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-18512-duration-unit-keys-selftest-floor
Sep 18, 2026
Merged

os-bill merged 2 commits into
mainfrom
claude/issue-18512-duration-unit-keys-selftest-floor

Conversation

@os-bill

@os-bill os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

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 spec(gate): check-duration-unit-keys admits by declaration — retire the name-shape token list #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 flagbuild-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

… verdict handshake

`packages/spec/scripts/check-duration-unit-keys.ts` predates the self-test
battery discipline: no roster, no per-battery floor, no module-level verdict
flag. Measured on this file, not supposed — emptying `DURATION_ROOTS`
DE-REGISTERS the two per-root module-coupling cases the loop generates, and the
run goes 103 checkmarks to 101, every one of them green. The self-test printed
no case count at all, so even the hand-tally clue was absent.

Copies the three parts from `scripts/check-agent-model-declared.mjs` (⛔ never
imports them — every self-test keeps running standalone), with the TypeScript
surface of the sequential port in `scripts/check-test-typecheck.mts`:

  - `SELF_TEST_BATTERIES` — 13 declared batteries, name → minimum case count.
    A battery here is a SECTION, opened with `battery('<name>')`; every
    `expect()` registers against the one most recently opened, as its first
    statement, so the floor asserts REACH rather than success.
  - `SELF_TEST_BATTERY_FLOOR` — the roster's own size, so deleting an entry
    cannot silence its floor.
  - `selfTestReachedVerdict` — set after the verdict prints, refused at the
    dispatch in `main()`. An exit code is not a handshake: this file's 0 rides
    `selfTest()` → `main()` → `process.exit()` unchanged.

Two vacuous cases now discriminate:

  - `compliant by TYPE: name, describe and type all agree` asserted
    `rulesOf(...).join() === ''`, which a key OUTSIDE the population satisfies
    too — green with the type channel ablated away. It now reads the site:
    duration type, type units, key units, prose units, admission and verdict.
  - `the declared duration units are units this reader actually knows` was an
    `every` over a possibly-empty map — vacuously true in the same ablated run.
    It now asserts the vocabulary is non-empty first, and a negative control
    pins that the right-hand side can answer false.

The gate's verdicts do not move: the non-`--self-test` leg's output is
byte-identical before and after (203 unit-declaring keys, 0 offenders).

Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3
Co-authored-by: Claude <noreply@anthropic.com>
@os-bill os-bill added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 18, 2026 — with Claude
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 625db0e85310e83d46fdf0f2d0dbccb30cb3c695packageMentionDocs.

@os-bill
os-bill marked this pull request as ready for review September 18, 2026 05:55
@os-bill
os-bill added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 02f8e45 Sep 18, 2026
41 checks passed
@os-bill
os-bill deleted the claude/issue-18512-duration-unit-keys-selftest-floor branch September 18, 2026 06:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tooling

Projects

None yet

2 participants