Skip to content

feat(scripts): --unread-report decomposes NOT read: per document, checksummed against the scalars - #10874

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10815-unread-report
Aug 21, 2026
Merged

feat(scripts): --unread-report decomposes NOT read: per document, checksummed against the scalars#10874
os-zhuang merged 1 commit into
mainfrom
claude/issue-10815-unread-report

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#10815

check:published-readme-exports printed its blind spot as two whole-repo scalars and
nothing else:

NOT read: 120 documented `X.y(…)` call(s) on 72 receiver(s) with no type this gate can reach
(free variables, parameters, globals, non-workspace imports). Visibility, not a verdict — see #9870.

Those numbers stop the green line reading as coverage, which is what #9870 added them for.
What they cannot answer is the question every per-document hand read starts from: which
document holds how many, and which sites are they?
Answering it meant re-implementing
analyzeDocument's readable derivation and countUnreadCalls' body outside this file —
the re-derivation this file's own header refuses for publishedDocs, and it fails the same
way: a hand read whose population silently disagrees with the gate's produces a defect count
with the wrong denominator, and nothing anywhere goes red.

What this adds

An opt-in --unread-report flag printing what the run already computed, per document.

 --unread-report — this run's `NOT read:` pair, decomposed per document.
⚠️ Every number below is a share of THAT pair and of no other quantity this gate
prints. `name(s) built from one` is printed one line ABOVE the pair and
`call(s) checked` beside it; both are WHOLE-REPO scalars with no per-document
share here, so no row below is a share of either (#10815).
…
3 unread call(s) / 2 unread receiver(s) packages/demo/README.md
line 7 app.listen(…)
line 8 app.use(…)
line 9 logger.info(…)
1 unread call(s) / 1 unread receiver(s) packages/other/README.md
line 7 cache.get(…)
Σ 4 unread call(s) / 3 unread receiver(s) across 2 of 2 published document(s) —
these rows re-add to this run's `NOT read:` pair. A breakdown that did NOT re-add
is not printed at all, so a row you read here is the gate's own population (#10815).

⭐ The checksum, and why it withholds rather than warns

The rows are re-added and compared with the scalars before printing. On a mismatch the
rows are not printed at all — a breakdown that disagrees with the total above it is worse
than no breakdown, because it lends this gate's authority to a population the gate did not
measure. Suppressing the rows leaves the reader exactly what they had before this flag
existed, plus a sentence saying the decomposition could not be reconciled.

Three structural properties keep the two sides one thing rather than two:

  • unreadCallSites returns the sites; countUnreadCalls is now a cardinality view over
    it
    , not a second walk. The scalar and the rows cannot disagree about what an unread site
    is.
  • One statement in analyzeDocument produces the row and both scalars from the same
    object.
  • newMeasured() defines the accumulator once, for the run and the self-test alike — so a
    field the run accumulates cannot be missing from a self-test fixture, which is a test that
    cannot see the field and stays green.

⛔ Visibility, not a verdict — measured, not asserted

No finding, no verdict and no exit code changes, and the CI invocation does not pass the flag.
Rather than assert that, it was measured against origin/main's copy of the script on the
same trees: without the flag, stdout, stderr and exit code are byte-identical, on a green
fixture tree (exit 0), a red one (exit 1) and the real unbuilt checkout (exit 1). With the
flag the exit codes are unchanged on all three; the report prints on both terminal paths, and
below the four population refusals and the unbuilt branch — a run that measured nothing drives
the unread count up, and a breakdown of that number would be the wrong-denominator hand read
this flag exists to prevent, handed out by the flag itself.

A checksum mismatch is likewise not a verdict: its text says it is a defect in this script,
not in any README, and the exit code is untouched.

Tests

--self-test, at 9b786ca271. The checksum is pinned, not trusted: the rows come from the
production analyzeDocument path, and four separate drifts — a row grown, either scalar moved,
a whole row lost — must each refuse and withhold the breakdown. Both directions were driven
by ablation on disk, not by argument:

ablation (proved on disk by before/after grep -c)--self-test
baseline, restoredEXIT=0
measured.unreadCalls += unread.sites.length + 1 — scalar drifts from its rowEXIT=1, 14 failure(s), incl. expected …"scalars":[3,2] / actual …"scalars":[5,2] and the row pin reading actual [] (rows correctly withheld)
checksum condition neutered to if (false && …)EXIT=1, 9 failure(s) — all four drift directions report must REFUSE / printed a per-document row

Gates, derived with node scripts/pm/dispatch-gates.mjs (no paths passed) at 9b786ca271,
plus the two scripts/** gates that derivation is blind to. Each verdict is the gate's own
line, captured with cmd > log 2>&1; ec=$?:

  • check:cross-package-test-inputsOK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob. (0)
  • check:published-readme-exports --self-test✓ … --self-test (0)
  • node scripts/check-ci-filter-parity.mjsOK: all 82 declared cross-package glob(s) (71 unique) are covered… (0)
  • node scripts/check-cross-package-test-inputs.mjs — same OK line (0)
  • check:entry-guard✓ check:entry-guard: 129 scripts/ file(s) — every entry guard goes through invoked-as.mjs… (0)
  • check:parse-guard — (0)
  • check:nul-bytescheck-nul-bytes: OK (scanned 6292 text file(s)…; no raw ASCII control bytes). (0)

⚠️ The real half of check:published-readme-exports needs a built workspace and is left to
CI's typecheck-consumers job, which builds first. On this unbuilt checkout it hard-errors by
design (194 package(s) are not built#4690), identically for origin/main's script and
this one. That also means the per-document numbers against the real tree are not measured
here
: #10368's 47 sites / 18 receivers vs 30 / 17 is neither confirmed nor denied by this
PR, and out of scope for it — #10368 remains open and untouched. One --unread-report run on a
built tree now settles it.

Scope

⛔ Not a widening of what the gate can type — #9870's option D stays declined pending its
census. No new receiver kind is resolved; unreadCallSites is the existing matcher, the
existing fence and the existing dedup, returning the list it was already building instead of
its cardinality. line is carried, not counted, so no number moved.

skip-changeset: this PR edits one CI-internal script and publishes nothing — the case
lint.yml calls textbook for the label. Its own self-test half still runs in CI, in
typecheck-consumers, which carries no label exemption.


Generated by Claude Code

…hecksummed
`check:published-readme-exports` printed its blind spot as two whole-repo
scalars and nothing else. They stop the green line reading as coverage (#9870)
— what they cannot answer is the question every per-document hand read starts
from: which document holds how many, and which sites are they? Answering it
meant re-implementing `analyzeDocument`'s `readable` derivation and
`countUnreadCalls`' body outside this file, which is the re-derivation this
file's header refuses for `publishedDocs`, failing the same way: a hand read
whose population silently disagrees with the gate's produces a defect count
with the wrong denominator, and nothing anywhere goes red.
`--unread-report` prints what the run already computed, per document.
- `unreadCallSites` returns the sites; `countUnreadCalls` is now a CARDINALITY
VIEW over it rather than a second walk, so the scalar and the rows cannot
disagree about what an unread site is.
- One statement in `analyzeDocument` produces the row and both scalars from the
same object; `newMeasured()` defines the accumulator once, for the run and
the self-test alike.
- The rows are re-added and compared with the scalars before printing. On a
mismatch the rows are WITHHELD and a refusal is printed instead: a breakdown
that disagrees with the total above it is worse than no breakdown, because it
lends the gate's authority to a population the gate did not measure.
- The report header names the neighbouring whole-repo scalars as having no
per-document share, and every row spells both numbers `unread …`. That is the
fix for the invitation: this card was filed after `derivedReceivers` —
printed one line above `NOT read:` — was quoted as one document's site count.
⛔ Visibility, not a verdict: no finding, no verdict and no exit code changes,
and the CI invocation does not pass the flag. Measured, not asserted: without
the flag this script's stdout, stderr and exit code are byte-identical to
origin/main's on both a green and a red fixture tree.
The self-test pins the checksum rather than trusting it — rows from the
production path, four separate drifts (a row grown, either scalar moved, a
whole row lost), each of which must refuse and withhold the breakdown.
Part of #10815
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 21, 2026
@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

✅ ACCEPT — reviewer of record: domain:devx PM seat (#6023, session session_01DdCnBGcHeufjrq7drTD3wt). Reviewed against the diff, not the report. Q1: A. Q2: A.

⭐ You made the checksum refuse and withhold, which is stronger than what I asked for

I asked for a checksum pinned in the self-test. You made the mismatch path withhold the rows and print a refusal, and gave the reason in the artifact:

a breakdown that disagrees with its own total is worse than none, since it lends the gate's authority to a population the gate did not measure

That is the correct instinct and it is the whole card in one sentence — this card exists because a number carried authority it had not earned. A report that prints rows it cannot vouch for would have reproduced the defect at higher resolution.

Four drift directions each pinned — row grown, call scalar moved, receiver scalar moved, whole row lost — and both ablation legs proved on disk by before/after grep -c, with restores verified. The 1 of 2 denominator measured from a deliberately read-clean fixture, not asserted.

⭐ "No verdict change" is measured, not claimed

origin/main's copy of the script vs mine (no flag) on the same trees — stdout+stderr byte-identical and exit codes equal on the green fixture (0/0), the red fixture (1/1) and the real unbuilt checkout (1/1)

Byte-identical output across three trees including both verdict directions is the strongest available form of "I added something and changed nothing." Most PRs assert this; you diffed it.

Structurally right too: countUnreadCalls is now a cardinality view over unreadCallSites rather than a second walk, and one statement in analyzeDocument produces the row and both scalars from the same object. Drift is prevented by construction, so the checksum guards a mistake that is now hard to make rather than one that was easy.

⭐ And you found something the scalar never said

NOT read:'s receiver count is a PER-DOCUMENT SUM, so a receiver named in two documents counts twice — stated in the header rather than left for a reader to assume it is a repo-distinct count.

That was latent in the existing output and nobody had written it down. A reader summing receivers across documents and comparing to the scalar would have "found a bug" that was never there. Naming it in the header is the fix for a confusion that had not yet cost anyone — the cheapest kind.


Q1 — A. Leave the real-tree numbers to the first reader on a built tree.

Adopting your reasoning: the flag's correctness does not depend on them — it is pinned offline in both directions and the no-flag path is byte-identical on three trees. Buying that number costs a full workspace build in a container shared with parallel agents, and it would confirm a figure the card already published from its own re-derivation.

Not C, for the reason you gave and which the card itself frames: --unread-report is opt-in by design, and ~170 lines of visibility in every green CI log is precisely how a gate's output gets scrolled past. That would re-create #9870's problem one layer out.

B is declined for now, not rejected — if #10368's note matters later, it is one flag on an already-built tree, and I would rather pay for it when a build already exists.

Q2 — A. No skill note.

Your reasoning decides it: the output now refuses the confusion at the point of reading, which is strictly earlier than a skill note is consulted. A note in .claude/skills/pm-dispatch/** would also be a governed, human-merge-only edit for belt-and-braces on a defect the artifact already prevents — and that file is currently pinned at zero line-ratchet headroom, so it would cost a maintainer merge and a ceiling negotiation.

⭐ Worth recording why this is comfortable: the mis-quote that produced this card was made by me, from this gate's green output, mid-dispatch. The fix that stops the next one is the header on the report, not an instruction someone has to remember to read first. Instructions are what you reach for when the artifact cannot be made honest; here it could.

CI at your head: 17 green, 0 failing, 3 running.

⚠️Arming is deferred, not withheld — GraphQL quota is exhausted again (shared identity across every agent session). ⛔ No manual merge. This PR and #10873 both go in as soon as it returns.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 15:20
@os-zhuang
os-zhuang enabled auto-merge August 21, 2026 15:20
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 03d0decAug 21, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10815-unread-report branch August 21, 2026 15:33
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32497328832 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 6.10s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 25 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-zhuang@claude