Skip to content

[finding] check-half-states self-test: 111 bare .includes() on nullable predicate returns turn a mutation into a TypeError that ABORTS the suite instead of a named failing case #12273

Description

@os-zhuang

Finding — recording only, unassigned. Hit live on 2026-08-25 while reverse-verifying #12090 (PR #12272); filed rather than fixed there because it is a different defect class (self-test harness robustness vs. claim-separator visibility) and the right shape is a judgement call, not pinned by existing evidence.

Measured

scripts/pm/check-half-states.mjs's self-test asserts a row's TEXT with the shape

t('H33: …and it names both stamps', h33ClaimPredatesRuling(dispatched33(), [claimRow33, rulingRow33]).includes(RULING_AT), true);

Every one of these predicates is three-valued by design — null when clean, a string when it fires. So .includes(...) on the return value throws TypeError: Cannot read properties of null (reading 'includes') the moment a change makes that predicate go clean.

Counted over the self-test body only (selfTest() through the assertion loop), grouped by predicate:

predicate bare .includes() call sites
h9OnHoldNoRestartWhen 14
h14BlockingCacheIncoherent 14
h16StuckMergeConflict 12
h22ClosedCardPmResidue 8
h4BlockedNoBlockedBy 8
h8MergedPrStillDispatched 8
h18RetriageAged 7
h11ImportantParked · h19BlockOutlivedBlocker · h26BlockOnIndefiniteTarget 6 each
h13DomainWithoutPmState · h24QueuedWithAssignee 5 each
h25AwaitingMaintainerExclusivity · h32SeatIdleOverQueue 3 each
h12OrphanLanding · h7PartOfWithClosingKeyword 2 each
h10StaleUnclaimedP0 · h33ClaimPredatesRuling 1 each
total 111 across 18 predicates

What it costs — measured, not hypothesized

Reverse-verifying a new row in this file means mutating a predicate and reading which cases go red. Observed on the first ablation leg (widening CLAIM_COMMENT_MARKER, a mutation that legitimately makes several rows go clean):

TypeError: Cannot read properties of null (reading 'includes')
    at selfTest (…/check-half-states.mjs:11117:121)
Node.js v22.22.2

Three separate costs, all in the direction that hides information:

  1. The suite aborts at the first such line. Every case after it never runs, so the run reports nothing about the rest of the file — including cases that would have gone red for the real reason.
  2. The output names a TypeError, not a row. failing cases: 0 is what a grep '✗' count reads, next to a non-zero exit — a shape that reads like a harness problem rather than a predicate verdict, and sends the reader to debug the test harness instead of the change.
  3. It is silently order-dependent. Which cases you learn about depends on which fragile line the mutation happens to reach first.

⚠️ The direction matters: this never makes a red run look green, so nothing has shipped wrong because of it. What it degrades is the ablation discipline this file's own headers lean on repeatedly ("that a check reports nothing today is worth distinguishing from a check that CANNOT report anything") — the cheapest way to prove a new row is failable is exactly the operation this shape breaks.

Shape of a fix — ⛔ not decided here

The one used in PR #12272 for the new row's own cases, as a local precedent rather than a proposal for the other 111:

const h34row = (...args) => String(h34ClaimShapedNonCanonicalSeparator(...args) ?? '');

…so a nulled row reports (got false, want true) under the row's own case name and the suite runs to completion. Alternatives triage should weigh: a shared rowText(fn, ...args) helper used by all 18 (one import, but it re-spells 111 lines); or a t() overload that stringifies nullable actuals centrally (touches the harness, not the cases, but makes every case's failure text less literal). Not obvious which is right, and the third option — leave it, on the ground that a crash is loud and the shape only bites during deliberate mutation — is defensible enough that it should be ruled on rather than assumed.

Not measured here: whether sibling patrol scripts under scripts/pm/ carry the same shape in their own self-tests. Worth a companion sweep if this lands.

Activity

  1. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    Skills-lane self-triage: finding → promoted with direction (seat authority — internal test harness): the per-predicate wrapper pattern #12272 already established (const h34row = (...args) => String(fn(...args) ?? '')) generalized — one wrapper per predicate (~18 lines), the 111 call sites mechanically switched to them. ⛔ Not the t() overload (the TypeError fires at argument evaluation, before t() runs — the harness cannot catch it) and not leave-it (the ablation discipline this file's own headers lean on is exactly what it breaks; measured live twice today). Acceptance: re-run a known ablation (e.g. #12272's leg A mutation) and show named failing cases with the suite running to completion instead of a TypeError abort; full suite green untouched. Companion sweep of sibling scripts/pm/* self-tests: MEASURE and report; fold fixes only if same-shape and mechanical, else file.

    Claim: skills seat, session session_01RMTpSRF5CjMmQBFfPtPCwJ. Folded dispatch — harness-and-typography pack (#12265 + this). Branch: claude/issue-12273-selftest-nullable-rows.


    Generated by Claude Code

  2. self-assigned this
    on Aug 25, 2026
  3. claude commented on Aug 26, 2026

    @claude
    Contributor
    {
      "issue": 12273,
      "status": "done",
      "branch": "claude/issue-12273-selftest-nullable-rows",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12390",
      "premise_still_valid": true,
      "summary": "Folded two-card pack, both delivered in draft PR #12390 (governed surface: .claude/** — draft only, never flipped ready, no auto-merge). CARD 12273: generalized #12272's per-predicate wrapper (String(fn(...) ?? '')) across the self-test. TWO DIVERGENCES FROM THE CARD, both measured. (1) The population is 253, not 111. The card counted only DIRECT call expressions; re-derived on base that reproduces exactly (111 bare across the 18 tabled predicates), but a second population of 108 sites reaches the same throw through a local const (const h9NoLine = h9OnHoldNoRestartWhen(...) then h9NoLine.includes(...)), spanning four predicates the card never names (h20, h21, h23, h28); plus 30 more via two local helpers (halves->H8, dead27->H27) and 4 already-optional-chained. This second half is what MATTERED: with the card's population alone fixed, the ablation STILL aborted, at h9NoLine.includes('cannot parse'). Fixed both: 18 wrappers + halvesRow/dead27Row (145 sites), then 19 declarations wrapped in place and 10 XRow siblings for consts whose nullability is itself asserted (56 sites). Predicates unchanged; every typeof/=== null case deliberately left un-wrapped. Audit now reports 0 unsafe receivers in both populations. (2) The card's leg-A ablation NO LONGER REPRODUCES — see tests. CARD 12265: re-derived the predicate on this tree with #12263's exclusions taken from the ratchet's own state machine: 7+6+1 = 14, reproducing the card's table exactly; all 14 deleted, byte delta exactly -14, line counts UNCHANGED so no CEILINGS row moves and no table row is touched. DIVERGENCE FROM THE DISPATCH ESTIMATE: the direction expected ~11 of 14 fixed with ~3 enumeration-marker sites left; measured, NONE of the 14 is an enumeration site — the predicate requires a Han successor and a circled mark (U+2462/U+2463) is not Han, so all 6 enumeration/inline-code sites fall outside the population by construction and are preserved without needing a hand exclusion. 14 of 14 fixed. Per-site judgment table (14 fixed + 6 kept, each with its reason) is in the PR body as required. NOT FILED, surfaced for PM triage instead: the file now carries two safe reader shapes — the new hNrow wrappers and the pre-existing says() helper used by H29/H30/H31, which returns a DESCRIBING string ('NO MESSAGE (null)') and so also separates 'row fired without the needle' from 'row went silent' on a case expecting false. Neither shape can abort the suite, so this is a legibility/consistency observation and NOT a defect; I recorded it in a code comment beside the wrapper block rather than opening a card. Say the word if you want it filed. PR housekeeping: skip-changeset applied via the additive endpoint and CONFIRMED BY READBACK after the size-labeler ran (labels came back ['size/l','skip-changeset']); review from os-zhuang was NOT refused — GitHub accepted it (HTTP 201) and the reviewer is attached.",
      "tests": "All gates and runs at the FINAL commit c5e68a4d5 (tree clean; branch pushed at that sha). Exit codes captured by redirecting to a file first, never through a pipe. NOTE ON REBUILD: these are plain .mjs scripts executed directly from source with no dist/ and no compile step, so the ablation rebuild requirement does not apply here — stating that explicitly rather than skipping it silently; the mutation was still proven on disk on every leg. GATES: derived with 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack', which read the 4 changed paths from the merge base itself (three-dot), not from a hand-written diff. 24 families matched; ALL 24 GREEN. Quoted verdict lines: 'check-half-states self-test: 1380 cases pass.' | 'check-skill-line-ratchet: .claude/agents/os-dev.md is 461 lines (ceiling 470; headroom 9).' | '...platform-readings.md is 314 lines (ceiling 314; headroom 0).' | '...pm-dispatch/SKILL.md is 1005 lines (ceiling 1005; headroom 0).' | '...pm-dispatch/SKILL.md: widest table row is 765 bytes (pin 765; headroom 0).' (MAX_TABLE_ROW_BYTES from #12263 respected — unchanged, no row widened) | 'check:doc-formula-expressions: 22 record-scoped formula example(s) across 422 files / 1449 TS blocks judged clean' | 'check:partof-closing-keyword: this PR carries no Part-of/closing-keyword contradiction.' TWO GATES INITIALLY RED, BOTH SELF-DECLARED NON-VERDICTS: doc-formula-expressions exited on an unbuilt workspace prerequisite ('Nothing was measured ... It is NOT a finding') and went green after building @objectstack/formula then @objectstack/lint; check-partof-closing-keyword.mjs refused for want of PR_BODY ('a wiring or usage failure, NOT a verdict') and was then run locally against the real PR body -> exit 0. ACCEPTANCE (a): 'node scripts/pm/check-half-states.mjs --self-test' exit 0, 'check-half-states self-test: 1380 cases pass.' Case count unchanged. Stronger: the run's ENTIRE STDOUT is BYTE-IDENTICAL to the pre-change baseline (diff empty) — same 1380 names, verdicts and order — so no case's expected verdict moved, because no case's observable value moved. ACCEPTANCE (b): the card's leg-A mutation (widening CLAIM_COMMENT_MARKER to accept U+2014) NO LONGER REPRODUCES THE ABORT. Run on BOTH the pristine base 7986d973f and this branch it behaves identically: exit 1, 1381 cases printed, 12 named failing cases, 0 TypeError lines, on both sides. All 12 failures sit in the H33/H34 blocks — the rows PR #12272 had ALREADY wrapped — so the TypeError the card quotes was observed BEFORE that wrapper landed and that leg stopped aborting when it did. Reporting the observed direction rather than forcing the card's template. SUBSTITUTE LEG, exercising the population the card is actually about: invert H9's fireable-Restart-when guard (one token; 14 direct sites + 6 behind h9NoLine); expected direction fixed BEFORE running. BEFORE (base 7986d973f): 'TypeError: Cannot read properties of null (reading 'includes')' at check-half-states.mjs:8551 in \"t('H9: ...and the finding prescribes the close default', h9OnHoldNoRestartWhen(hold('parked')).includes('not planned'), true)\" -> cases printed 0, named failing cases 0, NO summary line: the suite reports nothing about the other 1379 cases. AFTER (this branch): no TypeError, cases printed 1381, and 'check-half-states self-test: 45 of 1380 case(s) failed.' with every one of the 45 a named H9 row, e.g. 'H9: ...and the finding prescribes the close default (got false, want true)'. MUTATION CONFIRMED ON DISK on every leg, anchored to the text being changed rather than an exit code: pre-mutation ORIG anchor count = 1, post-mutation ORIG = 0 and MUTANT = 1, asserted before the run and aborting the leg otherwise. Both legs restored under trap '<restore>' EXIT INT TERM, and restore VERIFIED afterwards: 'git status --porcelain' empty, ORIG back to 1, MUTANT 0. ACCEPTANCE (c) COMPANION SWEEP over all 13 sibling scripts/pm/*.mjs: check-half-states.mjs is the ONLY file carrying the shape — every sibling measures popA=0, popB=0 (dispatch-gates.mjs 8 nullable fns/0 sites; check-governed-merges.mjs 4/0; git-history.mjs 3/0; ci-failure.mjs 2/0; check-skill-line-ratchet.mjs 1/0; release-rehearsal-clone.mjs 1/0; five others have 0 nullable fns; check-dispatch-gates.mjs has no 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, nothing to file. CARD 12265 VERIFICATION: strict population re-derived post-fix = 0 in all three files, while the 6 non-Han-successor sites survive unchanged (3 + 2 + 1); byte deltas -7/-6/-1 = exactly -14; line counts 314/461/1005 unchanged before and after; 'git diff --shortstat' = 3 files changed, 14 insertions, 14 deletions (one line each, no merges). Control-character self-scan over all four touched files clean, and check:nul-bytes green.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  4. os-zhuang commented on Aug 26, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT (pack: #12273 + #12265) — PR #12390 reviewed on its actual diff. The population correction is the find of the round: the card's 111 direct sites were only half the shape — 142 more reach the same TypeError through local consts and two helpers across four predicates the card never named, proven by the ablation still aborting after the card's population alone was fixed. All 253 receivers now safe; the substitute ablation (H9 guard inversion) flips the failure mode from "TypeError, 0 cases reported" to "45 named H9 rows, suite completes" — exactly what the ablation discipline needed. Byte-identical stdout on the untouched run is the strongest no-behavior-change evidence available. #12265: 14/14 literals closed with the enumeration-marker family proven outside the population by construction (circled marks aren't Han) — better than the estimated 11/14, with the per-site table on the PR. Leg-A no-longer-reproduces correctly reported as observed rather than template-forced. The says()-vs-hNrow consistency observation: recorded in-code is the right weight; do not file.

    Landing under the maintainer's shift-close instruction, 2026-08-25: 「当前任务处理完,合并了就下班。」 — arming auto-merge on the reviewed draft.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions