Skip to content

check-half-states self-test: route every nullable predicate row through a wrapper, and delete the 14 literal stray spaces - #12390

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-12273-selftest-nullable-rows
Aug 26, 2026
Merged

check-half-states self-test: route every nullable predicate row through a wrapper, and delete the 14 literal stray spaces#12390
os-zhuang merged 3 commits into
mainfrom
claude/issue-12273-selftest-nullable-rows

Conversation

@claude

@claudeclaudeBot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes#12273, closes#12265

Two disjoint cards, folded into one PR by the skills-lane PM: a self-test
robustness fix in scripts/pm/check-half-states.mjs, and a typography pass over
three governed corpus files. The file sets do not overlap.

Draft, governed surface. The diff touches .claude/**, so under Prime
Directive 14 this is for the maintainer to land by hand: never flipped ready,
never queued, no auto-merge armed.


Card 1 — self-test nullable-row robustness

What the defect is

The self-test's message assertions read predicate(...).includes(needle) on
predicates that are three-valued by designnull when the card is clean,
a string when the row fires. The moment a change makes such a predicate go
clean, that line throws:

TypeError: Cannot read properties of null (reading 'includes')

The throw happens while evaluating t()'s arguments, before t() runs, so
no harness-level catch can turn it into a case. And because t() only pushes
onto a cases array that is printed in one loop at the very end, a single throw
means the suite prints nothing at all — not a partial run.

⚠️ The population is 253, not 111 — the card's table measured one of two shapes

The card counted direct call expressions (pred(...).includes(). Re-derived
mechanically on the base commit, that population reproduces exactly: 111
bare sites across the 18 tabled predicates. But it is not the whole shape.

populationwhat it looks likesites
A — direct call expressionh9OnHoldNoRestartWhen(hold()).includes('x')145
B — variable-mediatedconst h9NoLine = h9OnHoldNoRestartWhen(...) … then h9NoLine.includes('x')108
total unsafe receivers253

Population A's 145 = the card's 111 bare, plus 4 already written ?.includes
(safe from the throw, but reporting undefined rather than a clean false),
plus 30 reached through two local helpers the table did not name (halves
wrapping H8, dead27 wrapping H27).

Population B is invisible to any call-expression scan, and it spans four
predicates the card never mentions
— h20, h21, h23, h28. It is also what
actually mattered: with population A alone fixed, the ablation below still
aborted
, at h9NoLine.includes('cannot parse').

The fix

Per the triage direction: the per-predicate wrapper pattern PR #12272
established for H34, const h34row = (...args) => String(fn(...args) ?? ''),
generalized. Not a t() overload — the throw precedes t(), so the harness
cannot catch it.

  • 18 wrappers (h4rowh33row) declared in one block at the top of
    selfTest, plus halvesRow and dead27Row beside their own local helpers.
    145 call sites switched.
  • Population B, two shapes chosen per variable: 19 variables carrying no
    nullability assertion had their declaration wrapped in place (zero
    call-site churn); the 10 that are asserted with typeof X / X === null kept
    the raw const and gained an XRow sibling (56 sites switched) — stringifying
    those in place would have made their own assertions trivially true.

⚠️The predicates are unchanged, and are still asserted three-valued
directly.
Every typeof pred(...) === 'string' and pred(...) === null case
deliberately does not go through a wrapper.

An audit deriving the nullable set from the source (functions containing
return null) rather than from a hand-kept list now reports TOTAL A = 0,
TOTAL B = 0.

Acceptance (a) — full self-test green, case count unchanged

node scripts/pm/check-half-states.mjs --self-test → exit 0:

✓ check-half-states self-test: 1380 cases pass.

Stronger than "green": the run's entire stdout is byte-identical to the
pre-change baseline — same 1380 case names, same verdicts, same order. No case
changed its expected verdict, because no case changed its observable value.

Acceptance (b) — the ablation

⚠️ The card's own leg A no longer reproduces, and that is a real result

The card quotes a TypeError abort from widening CLAIM_COMMENT_MARKER to accept
U+2014. Re-run here on both the pristine base and the fix, that mutation now
behaves identically on both sides:

LEG before (base 7986d973f): exit 1 · 1381 cases printed · 12 named ✗ · TypeError lines: 0
LEG after (this branch) : exit 1 · 1381 cases printed · 12 named ✗ · TypeError lines: 0

All 12 failures sit in the H33/H34 blocks — the rows PR #12272 had already
wrapped. The abort the card recorded was observed before that wrapper
landed; once it did, this leg stopped aborting. So the card's before/after shape
could not be demonstrated with this mutation, and forcing it would have produced
a fabricated contrast.

Substitute leg, exercising the population the card is actually about

Mutation: invert H9's fireable-Restart-when guard — one token, in a block with
14 direct sites and 6 more behind h9NoLine. Every H9 fixture with no fireable
exit now returns null: "the row goes clean", the exact regression class the
card describes. Expected direction fixed before running: before → TypeError
abort; after → named failing cases, suite completes.

BEFORE (pristine base 7986d973f), verbatim:

file:///…/scripts/pm/check-half-states.mjs:8551
t('H9: …and the finding prescribes the close default', h9OnHoldNoRestartWhen(hold('parked')).includes('not planned'), true);
^
TypeError: Cannot read properties of null (reading 'includes')
at selfTest (file:///…/scripts/pm/check-half-states.mjs:8551:95)

→ cases printed: 0 · named failing cases: 0 · summary line: absent.
The suite reports nothing about the other 1379 cases.

AFTER (this branch):

 ✗ H9: hold with no Restart-when line -> finding (got "object", want "string")
✗ H9: …and the finding prescribes the close default (got false, want true)
✗ H9: closed-upstream form -> clean (got "`pm:on-hold` with its only `Restart-when:` …", want null)
✗ check-half-states self-test: 45 of 1380 case(s) failed.

→ cases printed: 1381 · named failing cases: 45, every one an H9 row ·
TypeError lines: 0. The suite runs to completion and names what broke.

Both legs proved the mutation on disk before running (anchor present → 1,
mutant present → 1, original absent → 0) and restored under
trap … EXIT INT TERM; restore was verified afterwards (git status --porcelain empty, anchor back to 1, mutant 0).

Acceptance (c) — companion sweep of sibling scripts/pm/*.mjs

Same two-population audit run over all 13 sibling scripts. check-half-states.mjs
is the only file that carries the shape.

filenullable fnspop Apop Btotal
check-half-states.mjs (this card)360 (was 145)0 (was 108)0
dispatch-gates.mjs8000
check-governed-merges.mjs4000
git-history.mjs3000
ci-failure.mjs2000
check-skill-line-ratchet.mjs1000
release-rehearsal-clone.mjs1000
bare-root-worklist.mjs · check-governed-prose.mjs · check-label-desc-cap.mjs · check-skill-id-lint.mjs · check-governed-queue-guard.mjs0000
check-dispatch-gates.mjsno selfTest function (it drives the tool's own --self-test)

The instrument was validated against a known-positive control before the
zeros were believed: run against the pristine base of check-half-states.mjs it
returns popA=111, reproducing the card's headline number exactly. Nothing to
fold, and no finding to file — the sweep's answer is genuinely "only here".


Card 2 — literal stray spaces

Re-derived on this tree with the card's predicate (literal Han + ASCII ,;
: + space + Han, within one line), using the exclusions #12263 used
fenced regions, YAML front matter, and multi-line 「…」 rulings — taken from the
ratchet's own initialState/advanceState rather than reimplemented.

Result: 7 + 6 + 1 = 14, reproducing the card's table exactly. All 14
deleted; byte delta exactly −14; line counts unchanged, so no line merges,
no CEILINGS row moves, and no table row is touched.

⚠️ Divergence from the dispatch estimate: 14 of 14 fixed, not ~11

The direction anticipated that ~3 of the 14 would be enumeration-marker sites to
leave. Measured, none of them is: the predicate requires a Han
successor, and a circled mark (U+2462 / U+2463 ) is not Han, so every
enumeration site falls outside the population by construction — as the card
itself noted about its line-end sibling. The judged-deliberate family is
therefore preserved without any of it needing to be excluded by hand.

What makes each of the 14 mechanical rather than stylistic: every one has a
same-line sibling using the identical mark with no space
实测存在,churn,
判据的,PM, 哈希:空, 它不是,它. The house style is settled on the same line
as the defect.

Per-site judgment — fixed

sitemarkverdictwhy
platform-readings.md:4;fixedmid-sentence prose, no marker follows
platform-readings.md:55;fixedsame line: 存在,churn — no space
platform-readings.md:198,fixedmid-sentence prose
platform-readings.md:274,fixedmid-sentence prose
platform-readings.md:276,fixedsame line: 判据的,PM — no space
platform-readings.md:306,fixedmid-sentence prose
platform-readings.md:313;fixedsame line: 盲区:窗口 — no space
os-dev.md:210,fixedmid-sentence prose
os-dev.md:232,fixedsame line: 推进它: — no space
os-dev.md:253,fixedsame line: 哈希:空, 失败;不匹配 — no space
os-dev.md:263,fixedsame line: 三种处置,不是 — no space
os-dev.md:337:fixedmid-sentence prose
os-dev.md:376:fixedsame line: 它不是,它 — no space
SKILL.md:127:fixedmid-sentence prose

Per-site judgment — kept

siteshapeverdictwhy
platform-readings.md:56; + U+2463keptenumeration marker — judged deliberate house style; outside the predicate (successor is not Han)
platform-readings.md:275; + U+2462keptenumeration marker — same
SKILL.md:883; + U+2462keptenumeration marker — same
platform-readings.md:26; + inline codekeptsuccessor is inline code, not Han; a space between CJK prose and a code span is intended spacing
os-dev.md:230: + inline codekeptsame
os-dev.md:236, + inline codekeptsame

No new gate, per the direction: hanAsciiPunctTail (landed in #12263)
already prevents new line-end sources, literal authorship is rare, and the
residual risk is accepted and recorded on the card rather than priced into a
permanent scanner.


Gates

Derived from the real change set — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which read the 4 changed paths from the merge base
itself (three-dot) rather than from a hand-written list — and run at the final
commit c5e68a4d5. Exit codes captured by redirecting to a file first, never
through a pipe. 24 families matched; all 24 green, each quoted from the
gate's own verdict line:

gateverdict line
check:pm-half-states✓ check-half-states self-test: 1380 cases pass.
check:pm-skill-ratchet✓ …os-dev.md is 461 lines (ceiling 470; headroom 9).
check:pm-skill-ratchet✓ …platform-readings.md is 314 lines (ceiling 314; headroom 0).
check:pm-skill-ratchet✓ …pm-dispatch/SKILL.md is 1005 lines (ceiling 1005; headroom 0).
check:pm-skill-ratchet✓ …pm-dispatch/SKILL.md: widest table row is 765 bytes (pin 765; headroom 0).
check:pm-skill-ratchet✓ …os-dev.md: widest table row is 0 bytes (pin 0; headroom 0).
check:pm-skill-ratchet✓ …platform-readings.md: widest table row is 0 bytes (pin 0; headroom 0).
check:doc-formula-expressions✓ …22 record-scoped formula example(s) across 422 files / 1449 TS blocks judged clean
check:agent-model-declared · check:agent-test-spelling · check:bash32-floor · check:cli-command-ids · check:cross-package-test-inputs · check:doc-authoring · check:entry-guard · check:nul-bytes · check:parse-guard · check:partof-closing-keyword · check:pm-governed-merges · check:pm-governed-prose · check:pm-skill-id-lint · check:pnpm-filter-targets · check:skill-frame-sync · check-ci-filter-parity · check-closing-keyword-parity · check-cross-package-test-inputs · check-governed-queue-guardexit 0

The MAX_TABLE_ROW_BYTES pins merged in #12263 are respected: the two files with
a 0 pin have no table rows, and SKILL.md sits at 765/765 — unchanged, since
this diff deletes one space from a prose line and touches no table row.

Two gates initially reported red and both said so themselves: the
doc-formula-expressions gate exited on an unbuilt-workspace prerequisite
("Nothing was measured … It is NOT a finding"), green after building
@objectstack/formula and @objectstack/lint; and check-partof-closing-keyword.mjs
refused for want of PR_BODY ("a wiring or usage failure, NOT a verdict") — it
is CI-wired to this PR's body and was additionally run locally against this text
before publication.

skip-changeset: the diff is .claude/** plus one scripts/pm/ self-test
harness. Nothing published to npm changes, so no changeset is owed.


Generated by Claude Code

…ssertion through a row-text wrapper
The self-test's message assertions read `predicate(...).includes(needle)` on
predicates that are three-valued by design (null when clean, a string when the
row fires). The moment a change makes such a predicate go clean, that line
throws `TypeError: Cannot read properties of null (reading 'includes')` while
evaluating t()'s arguments — before t() runs — so the suite ABORTS there,
every later case never runs, and the output names a TypeError instead of a row.
Generalizes the per-predicate wrapper PR #12272 established for H34
(`const h34row = (...args) => String(fn(...args) ?? '')`) to the remaining 18
predicates, plus the two local nullable helpers (`halves`, `dead27`) that carry
the same shape. 145 call sites switched; predicates themselves are unchanged and
their `typeof`/`=== null` assertions deliberately stay un-wrapped.
Self-test output is byte-identical to before: 1380 cases pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ
…nullable-.includes population
The card's table counted only DIRECT call expressions (`pred(...).includes(`).
A second population reaches the same throw through a local const —
`const h9NoLine = h9OnHoldNoRestartWhen(...)` then `h9NoLine.includes(...)` —
which a call-expression scan cannot see. Measured on the pristine base: 108
such sites across 29 variables, spanning four predicates the card never named
(h20, h21, h23, h28).
This half is what actually kept the suite aborting: with population A alone
fixed, the H9 ablation still died at the first `h9NoLine.includes`.
Two shapes, chosen per variable:
- no nullability assertion on the const -> wrap the DECLARATION in place
(19 variables, zero call-site churn)
- `typeof X` / `X === null` asserted -> keep the raw const and add a
`XRow` sibling (10 variables, 56 sites switched), because stringifying
in place would make those assertions trivially true
Audit now reports 0 unsafe receivers in both populations. Self-test output
remains byte-identical: 1380 cases pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ
…jacent ASCII mark
#12081 measured a LINE-END event (a soft break after an ASCII , ; : between Han
characters). A faithful re-wrap preserves that space as a literal ", " in
source, where the line-end predicate can no longer see it — so the defect
migrates rather than disappears.
Re-derived on this tree with the card's predicate (literal Han + ASCII ,;: +
space + Han, one line, excluding fenced / front-matter / multi-line ruling
regions via the ratchet's own state machine): 7 + 6 + 1 = 14, reproducing the
card's table exactly. All 14 deleted.
Each site has a same-line sibling using the identical mark with NO space
(实测存在,churn · 判据的,PM · 哈希:空 · 它不是,它), which is what makes the
judgment mechanical rather than stylistic.
The enumeration-marker family (semicolon + space + a circled mark such as
U+2462 / U+2463) is left untouched — measured, it falls OUTSIDE the predicate
by construction (the successor is not Han), so all 6 such sites survive
unchanged. Byte delta is exactly -14; line counts are unchanged, so no CEILINGS
row moves and no table row widens.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 26, 2026
@claude
claudeBot requested a review from os-zhuangAugust 26, 2026 00:02
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation labels Aug 26, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 26, 2026 00:06
@os-zhuang
os-zhuang enabled auto-merge August 26, 2026 00:06
@os-zhuang
os-zhuang added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 078bdcdAug 26, 2026
35 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-12273-selftest-nullable-rows branch August 26, 2026 00:34
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-zhuang@claude