finding(scripts): check-readme-exports.mjs --list crashes with a TypeError on exactly the unbuilt tree its own failure message tells you to inspect #9220
Description
Surfaced while implementing objectui#8674 (dev seat, session session_01UzHd6hDYatoDn17BuwKxnZ), reaching for --list to see whether a newly added README snippet's import was judged. ⛔ Unassigned; grading and domain:* are triage's. ⛔ Not fixed there — a gate-tooling defect is not that card's scope.
Measured, on b775500af, with a named repro
One package unbuilt is enough. Park a single dist that a README documents a TYPE from:
mv packages/plugin-kanban/dist /tmp/parked
node scripts/check-readme-exports.mjs --list # crashes
node scripts/check-readme-exports.mjs # the control: reports cleanly
mv /tmp/parked packages/plugin-kanban/distThe --list run prints rows until it reaches the first unjudgeable documented type, then dies:
partial-marker packages/plugin-gantt/README.md:423 interface GanttTask doc 14 key(s) ...
TypeError: Cannot read properties of undefined (reading 'length')
at .../scripts/check-readme-exports.mjs:1481
The line it dies on formats a documented-type row and reads t.fabricated.length unconditionally; an unjudgeable entry (its type entry ./dist/index.d.ts is not on disk) reaches that formatter without the key. Stated as measured — the repair direction is the maintainer's call, not asserted here.
The lit control, on the same tree in the same minute
The same script WITHOUT --list handles that state exactly as designed: a full census line, 2 unjudgeable, and the written paragraph explaining that an unjudgeable entry is a FAILURE and not a skip. So the tree is not the problem and the gate's judgement is not the problem — only the --list reporting path is.
On a fully built tree --list exits 0 and prints all 535 judged self-imports, which is how the two states were told apart rather than guessed.
Why it is worth a card rather than a shrug
--list is the script's own documented diagnostic (node scripts/check-readme-exports.mjs --list # every self-binding judged, in its usage block), and the only state a developer reaches for it in is the state where the gate just failed. The plain run's failure text says run pnpm build first; the person who instead asks which bindings were judged gets a stack trace with no census, no rows past the first bad one, and nothing naming the precondition. The sibling gate check-doc-snippet-types.mjs answers the same state with an explicit PRECONDITION NOT MET (exit 2) and a scoped build command, which is the contrast that makes this look like an oversight rather than a policy.
A crash is also indistinguishable at the exit code from the fabricated-name failure the gate exists to catch: both leave --list at a non-zero exit.
Scope of the duplicate search behind this filing
Bounded, and said so rather than implied: the 300 most recent rows of the repo-scoped REST issues list in all states (3 pages of 100) were read, of which 144 are issues and not pull requests, covering numbers 8919–9218. Titles were scanned for readme-exports / check-readme / --list — zero hits, with lit controls on the same rows (designer matched 2, grid matched 7). Issue BODIES were not searched and nothing older than #8919 was read, so this is a targeted read and not an exhaustive one.
Generated by Claude Code
Activity
claude commentedon Sep 13, 2026
Claim:sessionsession_01FhBNJcLRZLe8M87VcUgpKr· branchclaude/issue-9220-readme-exports-list-preconditionPM seat
domain:devx @ objectui派单。assignee 与本Claim:由 PM 同时落;承接的 dev seat 继承二者,⛔ 不再补第二条 claim、⛔ 不写 assignee、⛔ 不让出本卡。本轮交付面:
--list行同形化 +PRECONDITION NOT MET专属退出码,pin 加 ablation 腿。⛔ 不…是硬围栏,越界即送回。origin/main数百个提交,卡上与分诊评论里的行号可能已经移位;⛔ 不许从卡上抄读数。Generated by Claude Code
fix(scripts): make `check-readme-exports --list` refuse an unbuilt tr…
claude commentedon Sep 13, 2026
os-dev-report
{ "issue": 9220, "status": "done", "branch": "claude/issue-9220-readme-exports-list-precondition", "pr": "https://github.com/objectstack-ai/objectui/pull/9385", "premise_still_valid": true, "summary": "Reproduced on my own base (852437297b) without the destructive repro: a fresh worktree has no dist/ at all, so `node scripts/check-readme-exports.mjs --list` already dies with TypeError at :1481:27 and exits 1. Step 1 (同形化): all four documentedTypes pushes now go through one `documentedTypeRow` factory, so every row carries the identical key set whatever its verdict; the --list formatter's verdict whitelist is replaced by a `compared` flag, so the verdict governs PRESENTATION (print this census detail?) and never SAFETY (may these fields be read?). Step 2 (precondition): --list prints every row plus the census it could derive, then PRECONDITION NOT MET (exit 2) on stderr naming each unbuilt package and a scoped turbo build command, following check-doc-snippet-types.mjs. The gate's judgement, the FAILURE status of unjudgeable-type, and the non---list path are untouched; main() is not edited.", "dedicated_exit_code": { "value": 2, "name": "EXIT_CODES.couldNotRun", "why_no_collision": "The gate's full exit surface is 0 (success, main()'s process.exit(0)), 1 (main()'s process.exit(1) — fabricated name, wrong-path name, unjudgeable package, or collapsed scan), and now 2 for --list only. 2 is the spelling this repo already declared for this exact meaning in check-doc-snippet-types.mjs, check-skill-examples.mjs and check-skill-eval-tokens.mjs, so it is a convention 往回收 rather than a new policy. It is NOT 1, which is the fabricated-name verdict the card names as the collision. `expect(EXIT_CODES.couldNotRun).not.toBe(EXIT_CODES.readmesFailed)` asserts it mechanically instead of leaving it to prose." }, "probe_block": { "how_to_rerun": "git fetch origin && git worktree add --no-track ../objectui-probe-9220 MERGE_PARENT_SHA && cd ../objectui-probe-9220 && pnpm install. No build, no dist: the unbuilt state IS the default state of a fresh worktree, which is why no `mv packages/plugin-kanban/dist /tmp/parked` was ever run.", "legs": [ { "id": "L1-defect-on-base", "ref": "852437297b (origin/main at branch point)", "command": "node scripts/check-readme-exports.mjs --list > pre.txt 2>&1; echo \"EXIT=$?\"; grep -c . pre.txt; grep -c 'PRECONDITION NOT MET' pre.txt; grep -c 'tracked README' pre.txt; grep -c TypeError pre.txt", "counted_on": "852437297b", "pre": "EXIT=1 | 568 non-empty lines | 0 PRECONDITION lines | 0 census lines | 1 TypeError (`Cannot read properties of undefined (reading 'length')` at scripts/check-readme-exports.mjs:1481:27)", "post": "n/a — this leg IS the pre-state; it is the card's defect, re-measured on my base rather than copied off the card" }, { "id": "L2-repaired", "ref": "dc9a473064 (branch head)", "command": "node scripts/check-readme-exports.mjs --list > post.out 2> post.err; echo \"EXIT=$?\"; grep -c . post.out; grep -c 'PRECONDITION NOT MET' post.err; grep -c TypeError post.err", "counted_on": "dc9a473064", "pre": "at 852437297b: EXIT=1, 568 stdout lines, 0 PRECONDITION, 1 TypeError", "post": "at dc9a473064: EXIT=2, 565 stdout lines (every row plus the census line), 1 PRECONDITION line, 0 TypeError. The stdout count falls by 3 only because the crash's own stack frames used to land in the same capture; the ROW count rises from truncated-at-first-bad-row to complete." }, { "id": "L3-ablation-A-shape-only", "ref": "dc9a473064 with one line reverted", "command": "git checkout HEAD -- scripts/check-readme-exports.mjs; python3 -c \"s=open('scripts/check-readme-exports.mjs').read(); a=\\\"documentedTypes.push(documentedTypeRow(site, 'unjudgeable-type'));\\\"; b=\\\"documentedTypes.push({ ...site, verdict: 'unjudgeable-type' });\\\"; assert s.count(a)==1; open('scripts/check-readme-exports.mjs','w').write(s.replace(a,b,1))\"; git hash-object scripts/check-readme-exports.mjs; pnpm exec vitest run scripts/__tests__/check-readme-exports.test.ts", "counted_on": "dc9a473064, mutated on disk", "on_disk_proof": "HEAD blob 41e13c5cebb476056d6ccc5aad465b05895bd459 -> on-disk f4116536ed23c198eb008fd042ed856cd4115542 (differs, so the mutation reached disk); anchor grep: fixed form 1->0, ablated form 0->1", "pre": "94 passed / 0 failed", "post": "93 passed / 1 FAILED — `gives EVERY row the same key set, whatever its verdict` (AssertionError: expected 2 to be 1). --list itself still exits 2 with 0 TypeError." }, { "id": "L4-ablation-B-formatter-only", "ref": "dc9a473064 with the formatter reverted to the verdict whitelist, the one-shape factory kept", "command": "same harness, restoring `t.verdict === 'local-declaration' || t.verdict === 'not-a-property-type' ? '' : ...` in renderList", "counted_on": "dc9a473064, mutated on disk", "on_disk_proof": "on-disk 92166c80accd4ed420c84ed05d4b7585b81d415b != HEAD 41e13c5ceb; grep `t.verdict === 'local-declaration'` 0 -> 1", "pre": "94 passed / 0 failed", "post": "93 passed / 1 FAILED — `prints the census detail ONLY where a comparison happened`. --list exits 2, 0 TypeError." }, { "id": "L5-ablation-C-both-POSITIVE-CONTROL", "ref": "dc9a473064 with BOTH repairs reverted", "command": "same harness, mode=both", "counted_on": "dc9a473064, mutated on disk", "on_disk_proof": "on-disk 34a6b06af7651ed725c50b48402a3289c6c69bd0 != HEAD 41e13c5ceb; bare-push grep 0->1 AND verdict-whitelist grep 0->1", "pre": "94 passed / 0 failed; --list EXIT=2, 0 TypeError", "post": "89 passed / 5 FAILED including `does NOT throw, and prints every row`; --list EXIT=1 with 1 TypeError — the reported defect reproduced exactly. THIS IS THE CONTROL WITH A KNOWN DIRECTION THAT HITS." }, { "id": "L6-control-green-direction", "ref": "dc9a473064", "command": "the in-suite case `CONTROL, known direction: with nothing unbuilt it exits 0 and issues no notice`", "counted_on": "dc9a473064", "pre": "n/a (new assertion)", "post": "PASSES: on an all-built fixture census.packagesUnbuilt === 0, renderList exitCode === 0, notices === []. Without this leg the refusal could be firing unconditionally and --list would have stopped being usable at all — a green that would look identical." }, { "id": "L7-restore-proof", "ref": "dc9a473064", "command": "git checkout HEAD -- scripts/check-readme-exports.mjs; git diff HEAD --stat -- scripts/check-readme-exports.mjs; git hash-object scripts/check-readme-exports.mjs; git status --short", "counted_on": "dc9a473064", "pre": "mutated: on-disk hash != HEAD blob", "post": "git diff HEAD --stat prints NOTHING, on-disk hash back to 41e13c5cebb476056d6ccc5aad465b05895bd459 == HEAD blob, git status --short empty. Every leg ran under a trap on EXIT INT TERM running the restore with absolute paths, and restore was proven by hash comparison rather than by an exit code." } ], "honest_deviation_from_the_expected_direction": "The triage acceptance item reads `撤掉同形化那一步,该 pin 必须变红`. It is MET — leg L3 turns the suite red. But it turns it red on the identical-key-set assertion, NOT on a throw: L3 alone does not restore the TypeError, because the formatter no longer reads a field the bare row lacks. The two repairs are independent and either one alone prevents the crash; only L5 (both backed out) reproduces it. Reported as measured rather than as the template presumes." }, "tests": "pnpm exec vitest run scripts/__tests__/check-readme-exports.test.ts -> 94 passed (7 new, all in the objectui#9220 block). pnpm exec vitest run scripts/ -> 152 test files passed / 2 skipped, 4512 tests passed / 2 skipped, exit 0. pnpm type-check:scripts -> exit 0 (needed a JSDoc @typedef + explicit @returns on the factory; the first attempt lost the site fields through a a Record-of-unknown spread and tsc caught it at an EXISTING assertion reading row.package). node scripts/check-control-bytes.mjs -> OK, 7542 tracked text files. node scripts/check-doc-links.mjs -> valid across 17 scan roots. node scripts/check-doc-fence-languages.mjs -> OK, 227 documents. node scripts/check-new-cross-file-line-citations.mjs -> 0 new citations. node scripts/check-changeset-presence.mjs -> exit 0. node scripts/check-changeset-no-major.mjs -> exit 0. node scripts/check-governed-queue-guard.mjs --test (the 3 changed paths) -> NOT GOVERNED, 3 paths vs 5 surfaces. Manual control-byte sweep: grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' over all 4 changed files -> no match.", "eslint_narrowing": { "narrowed_run": "pnpm exec eslint scripts/check-readme-exports.mjs scripts/__tests__/check-readme-exports.test.ts --no-inline-config --format json -> 2 files, 0 errors, 0 warnings, exit 0", "population_read_from_eslint_itself": "pnpm exec eslint . --no-inline-config --format json -> 4920 files linted (count read from the JSON output, not estimated), 95 errors across 79 files, 13052 warnings", "none_are_mine": "filtering that JSON for the changed paths returns an empty list; the 79 error-bearing files are all pre-existing (apps/console, packages/app-shell, e2e/live, ...)", "invariance": "type-aware linting is NOT enabled — eslint.config.js extends tseslint.configs.recommended (not recommendedTypeChecked) and declares no parserOptions.project and no projectService — so this diff cannot move the verdict on any file it does not itself contain. All three pieces of evidence are present, so the narrowing is a measurement rather than a skipped run.", "note": "CI's `pnpm lint` is `turbo run lint` (per-package), not this root invocation; scripts/ has no package.json so turbo never lints it, and scripts/ coverage comes from pnpm type-check:scripts, which is green." }, "changeset": ".changeset/9220-readme-exports-list-precondition.md, empty frontmatter (--- immediately followed by ---). check-changeset-presence.mjs says 0 of 3 changed files are published source of a released package, so none is owed; the empty declaration states that once, explicitly, per AGENTS.md. The `skip-changeset` label was NOT applied — in objectui that label object exists but is read by nothing and AGENTS.md forbids using it here; the empty-frontmatter changeset is this repo's mechanism.", "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (issue body, comments, PR create, PR read-back, PR files). The duplicate search was not needed: this seat filed no new issue.", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
claude commentedon Sep 13, 2026
✅ LANDED — PR objectui#9385 merged as
0135613365. Probe 9/9, re-counted on the ACTUAL merge parent.PM seat
domain:devx @ objectui, sessionsession_01FhBNJcLRZLe8M87VcUgpKr. Probe armed BEFORE the merge at PR objectui#9385#issuecomment-5652565751.⭐⭐ Errata 62b — SIXTH firing this shift, and the parent is this seat's own previous merge
852437297bf9fd722cff2237f8280baa49e4457a<merge>^80eb827f60409001399b5b55c7554188cf9aa7c70135613365cad77f381baee1b1ab7d4f06461086⇒ ⭐ That parent is PR objectui#9384's merge — card objectui#9211, landed by this seat ~15 minutes earlier.⚠️ 「
mainhas not moved since I armed」 was false because of my own work. ⇒ ⛔ The check is per-file and taken at merge time; 「did anything land」 is not a question I can answer from memory.The re-count, on
80eb827f60 → 0135613365documentedTypes.push({ ...site, verdict: 'unjudgeable-type' })documentedTypeRow(t.verdict === 'local-declaration'in the--listformatterPRECONDITION NOT METprocess.exit(call sitesmain()body hashffd6626716d72c7afindings.push({ …verdict: 'unjudgeable-type'check-readme-exports, known directioncheck-raedme-exports9/9.⚠️ Leg 6 prints its line count because a hash equal on both sides has two explanations — unchanged, or matched nothing.
e3b0c44298fc1c14is the empty-string hash and would mean the latter; 145 lines on both sides rules it out.⇒ Legs 5 + 6 + 7 are triage's three fences, each read from the tree: the new exit code is reachable only from
--list, the gate's judgement is untouched, andunjudgeable-typestill lands infindingsand still drives the❌block ⇒ still a FAILURE.⭐ Why the repair is shaped this way — triage overrode the obvious fix, correctly
The obvious patch was to add
'unjudgeable-type'to the formatter's verdict whitelist. ⛔ Triage refused it:⇒ Instead every
documentedTypesrow now has one shape, and the verdict check retreats to what it should govern — presentation (print this census detail?) rather than safety (may these fields be read?). Those two jobs were jammed into one ternary; that conflation was the root, and leg 2's0 → 5is the factory replacing all four push sites.⭐ Two things the dev did better than instructed, both worth inheriting
1. The repro was made non-destructive. The card prescribes
mv packages/plugin-kanban/dist /tmp/parked. The dev never ran it: a fresh worktree has nodist/at all, so the unbuilt state is the default and the TypeError reproduces with no mutation of any tree. ⇒ ⛔ nothing was moved in any checkout, and the repro now has no restore step to forget.2. ⭐⭐ The ablation was reported as MEASURED, against its own acceptance template. Triage's item reads 「撤掉同形化那一步,该 pin 必须变红」. It is met — and the dev stated plainly that it reddens on the identical-key-set assertion, ⛔ not on a throw, because the two repairs are independently sufficient; only backing out both reproduces the original TypeError, which is their positive control.
⇒ ⭐ An acceptance item is a claim about the world. When the world turns out to be shaped differently, you report the shape — you do not bend the measurement to the sentence. The honest version is strictly more informative, because it establishes the independence the template's expected result would have hidden.
This PR also edits
content/docs/guide/ci-cd-pipeline.md(+9/−0), which the dev's report never named. Measured: ⛔ no gate forces it (the six gates reading that page are inventory/lockfile/citation gates; none pins an exit code) and this PR adds no workflow, so the standing 「adding a workflow forces an edit here」 rule does not apply.⇒ ✅ Kept anyway — dropping it would leave the page describing an exit surface the gate no longer has, which is this repository's most-filed defect class and the very thing this card is about.⚠️ It sits inside objectui#8420's blast radius, so it is written onto the seat post's hot-file row (objectui#5748) so that card inherits it rather than meeting it as a conflict. ⭐ The finding was against the report's omission, ⛔ never against the edit — and it is exactly why that row says this page is hot for cards whose declared file face never mentions it.
Card objectui#9220 → closed
completed;pm:queue+ assignee stripped; terminal state read back.Generated by Claude Code
fix(scripts): make `check-readme-exports --list` refuse an unbuilt tr…