Repository navigation
[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
Activity
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 thet()overload (the TypeError fires at argument evaluation, beforet()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 siblingscripts/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
{ "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
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
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 shapeEvery one of these predicates is three-valued by design —
nullwhen clean, a string when it fires. So.includes(...)on the return value throwsTypeError: 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:.includes()call sitesh9OnHoldNoRestartWhenh14BlockingCacheIncoherenth16StuckMergeConflicth22ClosedCardPmResidueh4BlockedNoBlockedByh8MergedPrStillDispatchedh18RetriageAgedh11ImportantParked·h19BlockOutlivedBlocker·h26BlockOnIndefiniteTargeth13DomainWithoutPmState·h24QueuedWithAssigneeh25AwaitingMaintainerExclusivity·h32SeatIdleOverQueueh12OrphanLanding·h7PartOfWithClosingKeywordh10StaleUnclaimedP0·h33ClaimPredatesRulingWhat 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):Three separate costs, all in the direction that hides information:
failing cases: 0is what agrep '✗'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.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:
…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 sharedrowText(fn, ...args)helper used by all 18 (one import, but it re-spells 111 lines); or at()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.