feat(scripts): pin documented interface blocks against the shipped type, both ways - #7306

Merged
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins
Sep 2, 2026
Merged

feat(scripts): pin documented interface blocks against the shipped type, both ways#7306
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6214

Tier 2 of #5043, and not the shape the card assumed. The card asked for two things — compile the README code blocks, and add bidirectional pins for documented interface blocks. The two measurements the unlock comment left owed were taken first, and the first of them says the compile half already has a gate. So this PR is the pin, and the compile half is reported rather than rebuilt.

Both measurements are below with their numbers, because both change what got built.


Measurement (a) — the compile half is already check:doc-snippets' job

scripts/check-doc-snippet-types.mjs does not scan content/docs only. Its collector walks that tree and every packages/PKG/README.md (the listDocuments walk, and the scan surface is stated in its header in the same breath as the coverage rule). Coverage there is declared, never assumed: a document is compiled unless it is named in UNGATED_DOCS with a reason, and that ledger is shrink-only — an entry naming a file that does not exist, or that holds no ts/tsx block, fails as stale.

Measured on this branch's base:

count
package READMEs in that gate's scan surface39
of them compiled today8 (cli, components, create-plugin, plugin-grid, plugin-timeline, runner, test-support, vscode-extension)
of them on the UNGATED_DOCS debt ledger, each with a reason31
total entries on that ledger43 (31 READMEs + 12 .mdx pages)

#5174's batches are burning that ledger down — its header records batch 1 clearing 90 diagnostics, batch 2 eighty-nine, batch 3 eighty-one, batch 4 one hundred and four, and batch 5 is live on content/docs right now. So a second README compiler here would duplicate a working gate and split its ledger in two, and every README it brought under itself would then have two places to declare a fragment. Not built, deliberately; the reasoning is in this gate's header under "Compiling the blocks" so the next reader does not re-derive it.

One honest gap that measurement exposes, recorded and not fixed here: the doc-snippets collector reads packages/NAME/README.md at the package root only, so the four nested READMEs this gate walks (packages/types/src/zod/README.md and friends) are outside its surface entirely — neither covered nor on its ledger. That is #5174's own class of finding, one directory deeper, and it belongs to that gate.

Measurement (b) — the tier-2 baseline, re-taken on the merged ref

The card was blocked on this: "Running all 15 compilable plugin-gantt blocks under strict, after #5012 landed, still exits rc=2: two TS7006."#5057 has since reached completed state, by way of merged PR #5900 and the README moved again in 231d1b93c, so the figure was re-taken with the existing instrumentcheck-doc-snippet-types.mjs's own analyze + compileSnippets, with plugin-gantt lifted out of UNGATED_DOCS — rather than a new compiler:

targets: packages/plugin-gantt/README.md
blocks to compile: 18 | declared fragments: 0
parse failures on targets: 2
semantic-failing blocks on targets: 5
total diagnostics on targets: 13 {"TS2304":7,"TS7006":1,"TS7031":2,"TS2552":1,"TS2322":2}

It does not compile clean, and the shape of the red moved. The filed rc=2 / two TS7006 is now one TS7006 plus two TS7031 (the same unannotated-callback class, redistributed), and the bulk is a different family: seven TS2304 from blocks that are deliberately-not-valid property sketches ({ type, tasks: … }, bare dependencies: [ … ]), which is precisely the case FRAGMENT_MARKER exists for over there. Two blocks do not even parse. One diagnostic is real drift a compile tier would catch — TS2322: Type '"gantt"' is not assignable to type '"object-gantt"'.

Under the card's clause — "decide explicitly whether to list it as excluded or add a class, don't let it fall out silently" — this is listed, not inherited: packages/plugin-gantt/README.md stays on UNGATED_DOCS (untouched by this PR, it is #5174's file) and the number above is on the record. No shrink-only exclusion entry is added here for it, because this PR adds no compile pass for it to be excluded from.


What this PR builds: the bidirectional interface pin

A README that writes out a type it also exports makes a claim tier 1's name check cannot reach. The card records why, and the pin is built on it:

A documented interface block compiles green no matter what it says. A standalone interface GanttTask { … } in a README is a local declaration unrelated to the real type; feeding it to tsc as-is proves nothing.

So for every fenced block that declares interface X { … } or type X = { … } where X is an export of that README's own package, the documented property names are compared against the shipped declaration's, in both directions:

verdictdirectionjudged against
fabricated-keydocumented, shipped lacks itthe type's full property set, inherited members included
stale-omissionshipped, documented never mentions itthe interface's own declared members only

The asymmetry is the design, not a shortcut. Documenting a key that arrives through extends is correct, so the doc side is judged leniently; an excerpt of a type with a large base is not stale for leaving the base's keys to the base's own page, so the shipped side is judged narrowly. Measured on the real tree: judging the shipped side against all properties instead would report 36 omissions on DetailViewSchema alone, most of them inherited BaseSchema members.

One direction pins only half, which is why both are here — and a rename is visible only as the pair, the new spelling as fabricated-key and the old one as stale-omission on the same declaration and the same line. No single direction reports it.

The bound, stated in the script header because it is narrow

The card's other measurement is written into the header verbatim in substance: the typed-example half cannot carry the key-rename class, because a property-level type error short-circuits the missing-property detail — const task: GanttTask = { name: …, start: '2024-01-01' } reports two TS2322 and never that title is gone. This pin is what covers renames on interface blocks, and the header says so and promises nothing more.

Where it deliberately stops (first cut, all four in the header)

  • Method and index signatures are counted and skipped, on both sides. packages/types/README.md's DataSource is entirely method signatures, so it resolves and is compared over zero keys — and the census says 1 compared over ZERO documented keys rather than letting it read as a verified declaration.
  • Property names only, never types. That half belongs to a compile tier.
  • X is resolved against the README's own package only. Widening to any workspace package pulls in packages/plugin-detail/README.md's DetailViewSchema (owned by @object-ui/types) at the 36 omissions above. That widening needs its own inheritance policy and its own card.
  • Nested declarations are not walked — a type declared inside a function body in an example is the example's scaffolding, not a claim about the surface.

Declaring a deliberate excerpt — two homes, two different claims

Mirroring the pair check-doc-snippet-types.mjs already runs (UNGATED_DOCS + FRAGMENT_MARKER), and for the same reason: they say different things.

  • PARTIAL_MARKER — an HTML comment in the README, above the fence, whose body reads readme-exports: partial GanttTask — reason. (Spelled with a placeholder here on purpose: an HTML comment written literally into a GitHub body does not survive to the reader. The exact literal is published as PARTIAL_MARKER_EXAMPLE in the script, a test asserts the regex accepts it, and the gate prints it in its own failure message — so the one place a reader meets it is the one place it is guaranteed correct.) It says this excerpt is deliberate. The grammar is deliberately the same family as FRAGMENT_MARKER (marker word, an em dash / double hyphen / colon, then a reason of at least 12 characters), so a reader who knows one knows the other. The verb differs because the claim doesdoc-snippet: fragment says a block cannot compile, and that must not double as permission to omit a key. Only the HTML spelling exists here: these are README.md files, not MDX.
    It names the interface, which FRAGMENT_MARKER has no need to do: packages/plugin-kanban/README.md declares two types in one block, and a marker that silenced a whole block would silence the neighbour nobody looked at. Pinned as a test.
  • PARTIAL_EXCERPTS — a ledger in the script. It says this is drift, owed to a content card.

Both suppress stale-omission only. Neither can hide a fabricated-key — an excerpt may leave a key out, it may not invent one, and there is no reading of "partial" under which a ledger should be able to hide that. Both are shrink-only and enforced as such: a marker or entry that suppressed nothing fails, so the lists cannot quietly stop shrinking.

An unbuilt package is a FAILURE on this side too

Tier 1's rule, kept, and it needed its own branch: with no export surface on disk every documented type resolves to nothing and would read as a serene local-declaration, so the pin would report green over blocks it never judged. Scoped exactly as the import side is — it fails only where the missing surface would have changed a verdict, i.e. where a block declares a type. Verdict unjudgeable-type, in the census, pinned by a test.

Non-vacuity

Three new FLOORS counters — typeDeclarations, typesResolved, keysCompared — so the pin walk can collapse and name itself instead of hiding behind a healthy import walk. Pinned by a three-leg independence test on its own fixture tree (a control leg proving nothing breaches while both walks are healthy, an exact-equality leg on the three pin counters alone, and a leg pinning that the package-side counters are unchanged) — the same shape, and the same exactness argument, as tier 1's.

Its own fixture tree, deliberately: tier 1's independence test asserts exportSymbolsexactly (toBe(4)), and adding exports to alpha to give this half something to compare would have turned that assertion into a number nobody chose.


The census, before and after

Before, at eb33a8d4c (this branch's base):

✅ check-readme-exports: OK (43 tracked README(s) under packages/ (0 outside any package), 407 fenced
block(s) (309 parsed as code, 6 untagged); 506 import binding(s), 386 of them self-imports judged (386
real, 0 wrong-path, 0 fabricated); 3292 export symbol(s) read from 37 of 40 tracked package(s) (39 carry
a README) (0 unbuilt, 3 declare no types); 17 side-effect import(s), 0 namespace, 11 deep self-path, 92
to other packages).

After, at 15a06cff3 (the head of this branch, after the merge of main) — the same line with the pin's half appended:

✅ check-readme-exports: OK (… 92 to other packages; 6 documented type(s) in 5 block(s) (4 resolve to a
shipped shape of their own package, 2 local, 0 not a property type, 0 unjudgeable), 52 key(s) compared
both ways (0 fabricated, 0 stale omission(s); 0 excerpt(s) declared by marker, 3 by ledger; 1 compared
over ZERO documented keys)).

Green at rest, with the counts that say what was skipped travelling beside the ones that say what was judged.

The exclusion ledger this opens with — three entries, two READMEs

Well under the ten-README ceiling the order set, so this ships rather than escalating. Every one is the stale-omission direction; zero fabricated keys anywhere in the tree.

entryomitsdisposition
packages/plugin-gantt/README.md::GanttTaskfields, hasOwnDatesgenuinely an excerpt — the prose immediately below the block already names both as populated by ObjectGantt itself. It wants the in-README marker, not this ledger.
packages/plugin-kanban/README.md::KanbanColumncollapsedstaleness — nothing on the page says the block is partial
packages/plugin-kanban/README.md::KanbanCardcardSubtitle, cardFieldCells, coverImagestaleness, same page, three keys behind

No README content is edited by this PR, including the marker GanttTask deserves: the order scoped this card to the gate, so all three are recorded here with the card number and filed as a content follow-up, #7302 (unassigned, finding). The kanban entry notes that #6155 — four disagreeing declarations of that pair — can move its omission set, because that set is derived from what the package exports, not copied into the entry.

scan takes an excerpts option so the ledger can be switched off, and a test asserts that with it off the repo reds with exactly these three and that every red is a stale-omission — so the ledger is provably not covering a fabricated key or anything else. That test is written to hold built and unbuilt, like tier 1's repo state block: on an unbuilt tree the same declarations are unjudgeable-type, which is the failure the rule requires and not a skip.


Ablation — seven legs, no tracked file ever mutated

Every leg runs against a scratch copy handed to the gate through tier 1's --readme REAL=SCRATCH override. Each plant is proved on disk by an anchored count before and after, never by an editor's exit code, and the runner carries a trap … EXIT INT TERM that deletes the scratch files and re-verifies the tracked README's blob.

The plant proof has its own negative control, run first: an anchor that is deliberately never written must be rejected. It was (PLANT NOT ON DISK … its reading is VOID, exit 9) — so the passes below mean something.

legplantedpredictedmeasured
controla correctly documented interface GanttMarkergreenEXIT=0, verdict matches
(i)fabricated key iconred, names itEXIT=1interface GanttMarker documents 'icon', not on the shipped type
(ii)renamelabel to captionred in both directions, same declarationEXIT=1documents 'caption'andomits 'label', both at :718
(iii)omitted shipped key colorredEXIT=1interface GanttMarker omits 'color'
(iv)the same omission + the partial markergreenEXIT=0, census 1 excerpt(s) declared by marker
(v)a fabricated key under a markerstill redEXIT=1 — the marker does not cover fabrication
(vi)a marker over a complete declarationred as staleEXIT=1declares an excerpt that omits nothing any more

Every leg names the README, the interface and the key. Re-run in full on the merged head e32824dae; the only commit after it (15a06cff3) edits three reason strings inside PARTIAL_EXCERPTS, none of which any leg above reads — every leg plants and judges GanttMarker, which the ledger does not name. The restore proof closes it:

restore-proof: packages/plugin-gantt/README.md blob db8293c38832626e497519279681184caf4c69cb
== HEAD blob db8293c38832626e497519279681184caf4c69cb

git status --porcelain was empty after the run.


Verification

All at the branch head 15a06cff3 unless a line says otherwise, gate verdicts quoted from the line each gate printed, every exit code captured by redirect before any pipe.

pnpm check:readme-exports EXIT=0 ✅ OK (census above)
pnpm type-check:scripts EXIT=0
pnpm check:control-bytes EXIT=0 ✅ OK (scanned 6008 tracked text file(s); skipped 85 binary)
pnpm check:entry-guard EXIT=0 ✓ 58 scripts/ file(s) — no entry guard outside the baseline
pnpm check:doc-fences EXIT=0 ✅ every TypeScript block in 224 document(s) …
node scripts/check-changeset-presence.mjs EXIT=0
node scripts/check-lint-coverage.mjs EXIT=0 ✅ 46/46 packages linted, 0 with outstanding errors
node scripts/check-type-check-coverage.mjs EXIT=0
pnpm lint:root EXIT=0 ✖ 29 problems (0 errors, 29 warnings) — all pre-existing
vitest run scripts/__tests__/check-readme-exports.test.ts EXIT=0 83 passed (83)

Root vitest only (#3378), never package-scoped. The suite grew 47 → 83 cases.

Authored by Claude Code in session session_01BGMDbrVa8JjZcCQ7DWYH1b (https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b) — written into the prose here because a footer does not survive a later edit of this body.

One gate went red and it was mine to fix.type-check:scripts reported five TS2353 on excerpts — the option existed at runtime and not in scan's JSDoc, which is what that gate reads as the declaration. Contract-first: the producer's declaration was the stale half, fixed there rather than loosened at the five call sites. The vitest suite was green through all of it, which is why the type gate is a separate reading and not a duplicate of it.

No changeset, and the gate says so in its own words:"2 file(s) changed, 0 of them published source of a package the release covers … no changeset is owed." Everything in this diff is under scripts/. Same disposition as #6212, which shipped scripts-only the same way; the skip-changeset label is deliberately not applied — in this repository that label is read by no workflow and exempts nothing, and the gate's verdict line is the authority.

Test scope, derived from disk not assumed.git grep -l 'check-readme-exports' across *.test.ts returns exactly one file: scripts/__tests__/check-readme-exports.test.ts. That is the whole set of suites that read anything this diff changes, and it was run in full on the final head.

Coverage of the type gate, measured rather than claimed.tsc -p tsconfig.scripts.json --listFiles reports 1 hit each for scripts/check-readme-exports.mjs and scripts/__tests__/check-readme-exports.test.ts — so "type-check is clean" is a statement about both edited files and not a true sentence about a population that excludes them.

Lint scope, stated rather than assumed — and this is the full run for this diff, not a narrowing.pnpm lint:root is eslint . with four ignore patterns; read from eslint's own config via --format json, its population is 226 files, 0 errors, 29 warnings, and both edited files are in it with zero messages. No type-aware linting is configured (no projectService, no parserOptions.project), so this diff cannot move the verdict of any untouched file. Every code file in the diff is under scripts/, i.e. entirely inside that population.

Declared narrowing

Two, both stated so a reader can price them:

  1. The repo-wide pnpm lint (turbo run lint, per package) was not run. Zero package source changed, and the three-part evidence above — population read from eslint's own config, file counts from --format json, and no type-aware linting — is what makes that a measurement rather than a skip.
  2. scripts/__tests__ was not run whole, only the one suite. Derived from disk, as above: no other test file reads any changed path. CI runs the directory regardless.

The authoritative reading is the CI job conclusions on this PR.

Merge, never a rebase

One merge of origin/main (eb33a8d4cec0a7b846), no conflicts. git diff --name-only across that range touches five files, all under scripts/, and zero under packages/ — so the built dist/ this gate reads is still exactly the merged tree's, and the census above is a reading about the tree being shipped rather than a stale one.

Follow-ups


Generated by Claude Code

…type, both ways
Tier 2 of objectui#5043, the half the card scoped as "bidirectional pins for
documented `interface` blocks". A README that writes out a type it also exports
is making a claim the tier-1 name check cannot reach: a standalone
`interface GanttTask { ... }` in a fenced block is a LOCAL declaration, so it
compiles green no matter what it says.
`scripts/check-readme-exports.mjs` now compares, for every fenced block that
declares `interface X { ... }` or `type X = { ... }` where `X` is an export of
that README's own package, the documented property names against the shipped
declaration's, in both directions:
fabricated-key a documented key the shipped type does not have, judged
against the FULL property set so an inherited key is not
reported as invented;
stale-omission a shipped key the block never mentions, judged against the
interface's OWN declared members so an excerpt of a type
with a large base is not stale for leaving the base alone.
One direction pins only half, and a RENAME is visible only as the pair -- the
new spelling as `fabricated-key`, the old one as `stale-omission` on the same
declaration. The header states the bound the card recorded: the typed-example
half cannot carry that class, because a property-level type error
short-circuits the missing-property detail.
Two ways to declare a deliberate excerpt, with different meanings: a
`PARTIAL_MARKER` HTML comment in the README (grammar deliberately in
`check-doc-snippet-types.mjs`'s `FRAGMENT_MARKER` family), and a
`PARTIAL_EXCERPTS` ledger here for drift owed to a content card. Both are
shrink-only and both suppress omissions ONLY -- neither can hide a fabricated
key. An unbuilt package is a FAILURE on this side too, never a serene
`local-declaration`, and three new floors let the pin walk collapse and name
itself independently of the import walk.
No new compiler: `check-doc-snippet-types.mjs` already collects every
`packages/<name>/README.md` and compiles the covered ones (39 in its surface, 8
compiled, 31 on its shrink-only ledger). Measured and written into the header.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
`type-check:scripts` reads the JSDoc as the declaration for this module, so the
new option existed at runtime and not in the contract — every test passing it
was a TS2353 while the suite itself was green. The producer's declaration was
the stale half; fixed there rather than loosened at the five call sites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
The ledger is a debt list, and a debt with no card is one nobody picks up.
Each entry now names objectui#7302 (the README fixes) beside objectui#6214
(the gate that recorded it), and the kanban pair notes that objectui#6155 can
move its omission set — that set is read from what the package exports, not
copied into the entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
…compared
`Test (shard 2/4)` went red on the interface pin's first CI run. Not a flake:
the test shards run `pnpm install` then `pnpm test` and NEVER build, so on CI
every documented type comes back `unjudgeable-type` — and the three real
`PARTIAL_EXCERPTS` entries then suppressed no omission, so the shrink-only
sweep reported all three as `stale-excerpt-entry`.
That is the wrong verdict, and wrong in the direction that costs most: it tells
a reader to DELETE a ledger entry that is still true. "This entry suppressed
nothing" has two causes that look identical from outside — the README caught up
(stale), or the declaration could not be judged at all (still true) — and they
must not share a verdict.
So a declaration that resolves to `unjudgeable-type` now SUSPENDS the rule for
its ledger entry and its marker rather than satisfying it. Both are counted as
`not judged` in the census, and the run still FAILS — under `unjudgeable-type`,
which names the real problem. The counter increments only where an excerpt was
actually declared, so the census means "claims this run could not check" and
not "declarations it could not judge", which `typesUnjudgeable` already says.
Tests: four fixture legs, on the fixture tree so they hold in both build states
— the ledger case, the marker case, the no-excerpt case pinning the counter's
meaning, and a MUST-FAIL CONTROL asserting the same entry on a built tree is
still reported stale. Without that control all three would also pass if the
suspension had swallowed the rule outright, which is the opposite defect and
the more expensive one: a ledger that can never shrink again.
Tier 1's `repo state` test filtered `!== 'unjudgeable'` to mean "could not
judge". That class has two verdicts now, so the filter names both — the
assertion that the unbuilt tree must still FAIL is unchanged — and its unbuilt
branch now asserts zero stale-excerpt and zero stale-marker rows by name, so
this regression cannot come back silently.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(scripts): pin documented interface blocks against the shipped type, both ways - #7306

Merged
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins
Sep 2, 2026
Merged

feat(scripts): pin documented interface blocks against the shipped type, both ways#7306
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6214

Tier 2 of #5043, and not the shape the card assumed. The card asked for two things — compile the README code blocks, and add bidirectional pins for documented interface blocks. The two measurements the unlock comment left owed were taken first, and the first of them says the compile half already has a gate. So this PR is the pin, and the compile half is reported rather than rebuilt.

Both measurements are below with their numbers, because both change what got built.


Measurement (a) — the compile half is already check:doc-snippets' job

scripts/check-doc-snippet-types.mjs does not scan content/docs only. Its collector walks that tree and every packages/PKG/README.md (the listDocuments walk, and the scan surface is stated in its header in the same breath as the coverage rule). Coverage there is declared, never assumed: a document is compiled unless it is named in UNGATED_DOCS with a reason, and that ledger is shrink-only — an entry naming a file that does not exist, or that holds no ts/tsx block, fails as stale.

Measured on this branch's base:

count
package READMEs in that gate's scan surface39
of them compiled today8 (cli, components, create-plugin, plugin-grid, plugin-timeline, runner, test-support, vscode-extension)
of them on the UNGATED_DOCS debt ledger, each with a reason31
total entries on that ledger43 (31 READMEs + 12 .mdx pages)

#5174's batches are burning that ledger down — its header records batch 1 clearing 90 diagnostics, batch 2 eighty-nine, batch 3 eighty-one, batch 4 one hundred and four, and batch 5 is live on content/docs right now. So a second README compiler here would duplicate a working gate and split its ledger in two, and every README it brought under itself would then have two places to declare a fragment. Not built, deliberately; the reasoning is in this gate's header under "Compiling the blocks" so the next reader does not re-derive it.

One honest gap that measurement exposes, recorded and not fixed here: the doc-snippets collector reads packages/NAME/README.md at the package root only, so the four nested READMEs this gate walks (packages/types/src/zod/README.md and friends) are outside its surface entirely — neither covered nor on its ledger. That is #5174's own class of finding, one directory deeper, and it belongs to that gate.

Measurement (b) — the tier-2 baseline, re-taken on the merged ref

The card was blocked on this: "Running all 15 compilable plugin-gantt blocks under strict, after #5012 landed, still exits rc=2: two TS7006."#5057 has since reached completed state, by way of merged PR #5900 and the README moved again in 231d1b93c, so the figure was re-taken with the existing instrumentcheck-doc-snippet-types.mjs's own analyze + compileSnippets, with plugin-gantt lifted out of UNGATED_DOCS — rather than a new compiler:

targets: packages/plugin-gantt/README.md
blocks to compile: 18 | declared fragments: 0
parse failures on targets: 2
semantic-failing blocks on targets: 5
total diagnostics on targets: 13 {"TS2304":7,"TS7006":1,"TS7031":2,"TS2552":1,"TS2322":2}

It does not compile clean, and the shape of the red moved. The filed rc=2 / two TS7006 is now one TS7006 plus two TS7031 (the same unannotated-callback class, redistributed), and the bulk is a different family: seven TS2304 from blocks that are deliberately-not-valid property sketches ({ type, tasks: … }, bare dependencies: [ … ]), which is precisely the case FRAGMENT_MARKER exists for over there. Two blocks do not even parse. One diagnostic is real drift a compile tier would catch — TS2322: Type '"gantt"' is not assignable to type '"object-gantt"'.

Under the card's clause — "decide explicitly whether to list it as excluded or add a class, don't let it fall out silently" — this is listed, not inherited: packages/plugin-gantt/README.md stays on UNGATED_DOCS (untouched by this PR, it is #5174's file) and the number above is on the record. No shrink-only exclusion entry is added here for it, because this PR adds no compile pass for it to be excluded from.


What this PR builds: the bidirectional interface pin

A README that writes out a type it also exports makes a claim tier 1's name check cannot reach. The card records why, and the pin is built on it:

A documented interface block compiles green no matter what it says. A standalone interface GanttTask { … } in a README is a local declaration unrelated to the real type; feeding it to tsc as-is proves nothing.

So for every fenced block that declares interface X { … } or type X = { … } where X is an export of that README's own package, the documented property names are compared against the shipped declaration's, in both directions:

verdictdirectionjudged against
fabricated-keydocumented, shipped lacks itthe type's full property set, inherited members included
stale-omissionshipped, documented never mentions itthe interface's own declared members only

The asymmetry is the design, not a shortcut. Documenting a key that arrives through extends is correct, so the doc side is judged leniently; an excerpt of a type with a large base is not stale for leaving the base's keys to the base's own page, so the shipped side is judged narrowly. Measured on the real tree: judging the shipped side against all properties instead would report 36 omissions on DetailViewSchema alone, most of them inherited BaseSchema members.

One direction pins only half, which is why both are here — and a rename is visible only as the pair, the new spelling as fabricated-key and the old one as stale-omission on the same declaration and the same line. No single direction reports it.

The bound, stated in the script header because it is narrow

The card's other measurement is written into the header verbatim in substance: the typed-example half cannot carry the key-rename class, because a property-level type error short-circuits the missing-property detail — const task: GanttTask = { name: …, start: '2024-01-01' } reports two TS2322 and never that title is gone. This pin is what covers renames on interface blocks, and the header says so and promises nothing more.

Where it deliberately stops (first cut, all four in the header)

  • Method and index signatures are counted and skipped, on both sides. packages/types/README.md's DataSource is entirely method signatures, so it resolves and is compared over zero keys — and the census says 1 compared over ZERO documented keys rather than letting it read as a verified declaration.
  • Property names only, never types. That half belongs to a compile tier.
  • X is resolved against the README's own package only. Widening to any workspace package pulls in packages/plugin-detail/README.md's DetailViewSchema (owned by @object-ui/types) at the 36 omissions above. That widening needs its own inheritance policy and its own card.
  • Nested declarations are not walked — a type declared inside a function body in an example is the example's scaffolding, not a claim about the surface.

Declaring a deliberate excerpt — two homes, two different claims

Mirroring the pair check-doc-snippet-types.mjs already runs (UNGATED_DOCS + FRAGMENT_MARKER), and for the same reason: they say different things.

  • PARTIAL_MARKER — an HTML comment in the README, above the fence, whose body reads readme-exports: partial GanttTask — reason. (Spelled with a placeholder here on purpose: an HTML comment written literally into a GitHub body does not survive to the reader. The exact literal is published as PARTIAL_MARKER_EXAMPLE in the script, a test asserts the regex accepts it, and the gate prints it in its own failure message — so the one place a reader meets it is the one place it is guaranteed correct.) It says this excerpt is deliberate. The grammar is deliberately the same family as FRAGMENT_MARKER (marker word, an em dash / double hyphen / colon, then a reason of at least 12 characters), so a reader who knows one knows the other. The verb differs because the claim doesdoc-snippet: fragment says a block cannot compile, and that must not double as permission to omit a key. Only the HTML spelling exists here: these are README.md files, not MDX.
    It names the interface, which FRAGMENT_MARKER has no need to do: packages/plugin-kanban/README.md declares two types in one block, and a marker that silenced a whole block would silence the neighbour nobody looked at. Pinned as a test.
  • PARTIAL_EXCERPTS — a ledger in the script. It says this is drift, owed to a content card.

Both suppress stale-omission only. Neither can hide a fabricated-key — an excerpt may leave a key out, it may not invent one, and there is no reading of "partial" under which a ledger should be able to hide that. Both are shrink-only and enforced as such: a marker or entry that suppressed nothing fails, so the lists cannot quietly stop shrinking.

An unbuilt package is a FAILURE on this side too

Tier 1's rule, kept, and it needed its own branch: with no export surface on disk every documented type resolves to nothing and would read as a serene local-declaration, so the pin would report green over blocks it never judged. Scoped exactly as the import side is — it fails only where the missing surface would have changed a verdict, i.e. where a block declares a type. Verdict unjudgeable-type, in the census, pinned by a test.

Non-vacuity

Three new FLOORS counters — typeDeclarations, typesResolved, keysCompared — so the pin walk can collapse and name itself instead of hiding behind a healthy import walk. Pinned by a three-leg independence test on its own fixture tree (a control leg proving nothing breaches while both walks are healthy, an exact-equality leg on the three pin counters alone, and a leg pinning that the package-side counters are unchanged) — the same shape, and the same exactness argument, as tier 1's.

Its own fixture tree, deliberately: tier 1's independence test asserts exportSymbolsexactly (toBe(4)), and adding exports to alpha to give this half something to compare would have turned that assertion into a number nobody chose.


The census, before and after

Before, at eb33a8d4c (this branch's base):

✅ check-readme-exports: OK (43 tracked README(s) under packages/ (0 outside any package), 407 fenced
block(s) (309 parsed as code, 6 untagged); 506 import binding(s), 386 of them self-imports judged (386
real, 0 wrong-path, 0 fabricated); 3292 export symbol(s) read from 37 of 40 tracked package(s) (39 carry
a README) (0 unbuilt, 3 declare no types); 17 side-effect import(s), 0 namespace, 11 deep self-path, 92
to other packages).

After, at 15a06cff3 (the head of this branch, after the merge of main) — the same line with the pin's half appended:

✅ check-readme-exports: OK (… 92 to other packages; 6 documented type(s) in 5 block(s) (4 resolve to a
shipped shape of their own package, 2 local, 0 not a property type, 0 unjudgeable), 52 key(s) compared
both ways (0 fabricated, 0 stale omission(s); 0 excerpt(s) declared by marker, 3 by ledger; 1 compared
over ZERO documented keys)).

Green at rest, with the counts that say what was skipped travelling beside the ones that say what was judged.

The exclusion ledger this opens with — three entries, two READMEs

Well under the ten-README ceiling the order set, so this ships rather than escalating. Every one is the stale-omission direction; zero fabricated keys anywhere in the tree.

entryomitsdisposition
packages/plugin-gantt/README.md::GanttTaskfields, hasOwnDatesgenuinely an excerpt — the prose immediately below the block already names both as populated by ObjectGantt itself. It wants the in-README marker, not this ledger.
packages/plugin-kanban/README.md::KanbanColumncollapsedstaleness — nothing on the page says the block is partial
packages/plugin-kanban/README.md::KanbanCardcardSubtitle, cardFieldCells, coverImagestaleness, same page, three keys behind

No README content is edited by this PR, including the marker GanttTask deserves: the order scoped this card to the gate, so all three are recorded here with the card number and filed as a content follow-up, #7302 (unassigned, finding). The kanban entry notes that #6155 — four disagreeing declarations of that pair — can move its omission set, because that set is derived from what the package exports, not copied into the entry.

scan takes an excerpts option so the ledger can be switched off, and a test asserts that with it off the repo reds with exactly these three and that every red is a stale-omission — so the ledger is provably not covering a fabricated key or anything else. That test is written to hold built and unbuilt, like tier 1's repo state block: on an unbuilt tree the same declarations are unjudgeable-type, which is the failure the rule requires and not a skip.


Ablation — seven legs, no tracked file ever mutated

Every leg runs against a scratch copy handed to the gate through tier 1's --readme REAL=SCRATCH override. Each plant is proved on disk by an anchored count before and after, never by an editor's exit code, and the runner carries a trap … EXIT INT TERM that deletes the scratch files and re-verifies the tracked README's blob.

The plant proof has its own negative control, run first: an anchor that is deliberately never written must be rejected. It was (PLANT NOT ON DISK … its reading is VOID, exit 9) — so the passes below mean something.

legplantedpredictedmeasured
controla correctly documented interface GanttMarkergreenEXIT=0, verdict matches
(i)fabricated key iconred, names itEXIT=1interface GanttMarker documents 'icon', not on the shipped type
(ii)renamelabel to captionred in both directions, same declarationEXIT=1documents 'caption'andomits 'label', both at :718
(iii)omitted shipped key colorredEXIT=1interface GanttMarker omits 'color'
(iv)the same omission + the partial markergreenEXIT=0, census 1 excerpt(s) declared by marker
(v)a fabricated key under a markerstill redEXIT=1 — the marker does not cover fabrication
(vi)a marker over a complete declarationred as staleEXIT=1declares an excerpt that omits nothing any more

Every leg names the README, the interface and the key. Re-run in full on the merged head e32824dae; the only commit after it (15a06cff3) edits three reason strings inside PARTIAL_EXCERPTS, none of which any leg above reads — every leg plants and judges GanttMarker, which the ledger does not name. The restore proof closes it:

restore-proof: packages/plugin-gantt/README.md blob db8293c38832626e497519279681184caf4c69cb
== HEAD blob db8293c38832626e497519279681184caf4c69cb

git status --porcelain was empty after the run.


Verification

All at the branch head 15a06cff3 unless a line says otherwise, gate verdicts quoted from the line each gate printed, every exit code captured by redirect before any pipe.

pnpm check:readme-exports EXIT=0 ✅ OK (census above)
pnpm type-check:scripts EXIT=0
pnpm check:control-bytes EXIT=0 ✅ OK (scanned 6008 tracked text file(s); skipped 85 binary)
pnpm check:entry-guard EXIT=0 ✓ 58 scripts/ file(s) — no entry guard outside the baseline
pnpm check:doc-fences EXIT=0 ✅ every TypeScript block in 224 document(s) …
node scripts/check-changeset-presence.mjs EXIT=0
node scripts/check-lint-coverage.mjs EXIT=0 ✅ 46/46 packages linted, 0 with outstanding errors
node scripts/check-type-check-coverage.mjs EXIT=0
pnpm lint:root EXIT=0 ✖ 29 problems (0 errors, 29 warnings) — all pre-existing
vitest run scripts/__tests__/check-readme-exports.test.ts EXIT=0 83 passed (83)

Root vitest only (#3378), never package-scoped. The suite grew 47 → 83 cases.

Authored by Claude Code in session session_01BGMDbrVa8JjZcCQ7DWYH1b (https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b) — written into the prose here because a footer does not survive a later edit of this body.

One gate went red and it was mine to fix.type-check:scripts reported five TS2353 on excerpts — the option existed at runtime and not in scan's JSDoc, which is what that gate reads as the declaration. Contract-first: the producer's declaration was the stale half, fixed there rather than loosened at the five call sites. The vitest suite was green through all of it, which is why the type gate is a separate reading and not a duplicate of it.

No changeset, and the gate says so in its own words:"2 file(s) changed, 0 of them published source of a package the release covers … no changeset is owed." Everything in this diff is under scripts/. Same disposition as #6212, which shipped scripts-only the same way; the skip-changeset label is deliberately not applied — in this repository that label is read by no workflow and exempts nothing, and the gate's verdict line is the authority.

Test scope, derived from disk not assumed.git grep -l 'check-readme-exports' across *.test.ts returns exactly one file: scripts/__tests__/check-readme-exports.test.ts. That is the whole set of suites that read anything this diff changes, and it was run in full on the final head.

Coverage of the type gate, measured rather than claimed.tsc -p tsconfig.scripts.json --listFiles reports 1 hit each for scripts/check-readme-exports.mjs and scripts/__tests__/check-readme-exports.test.ts — so "type-check is clean" is a statement about both edited files and not a true sentence about a population that excludes them.

Lint scope, stated rather than assumed — and this is the full run for this diff, not a narrowing.pnpm lint:root is eslint . with four ignore patterns; read from eslint's own config via --format json, its population is 226 files, 0 errors, 29 warnings, and both edited files are in it with zero messages. No type-aware linting is configured (no projectService, no parserOptions.project), so this diff cannot move the verdict of any untouched file. Every code file in the diff is under scripts/, i.e. entirely inside that population.

Declared narrowing

Two, both stated so a reader can price them:

  1. The repo-wide pnpm lint (turbo run lint, per package) was not run. Zero package source changed, and the three-part evidence above — population read from eslint's own config, file counts from --format json, and no type-aware linting — is what makes that a measurement rather than a skip.
  2. scripts/__tests__ was not run whole, only the one suite. Derived from disk, as above: no other test file reads any changed path. CI runs the directory regardless.

The authoritative reading is the CI job conclusions on this PR.

Merge, never a rebase

One merge of origin/main (eb33a8d4cec0a7b846), no conflicts. git diff --name-only across that range touches five files, all under scripts/, and zero under packages/ — so the built dist/ this gate reads is still exactly the merged tree's, and the census above is a reading about the tree being shipped rather than a stale one.

Follow-ups


Generated by Claude Code

…type, both ways
Tier 2 of objectui#5043, the half the card scoped as "bidirectional pins for
documented `interface` blocks". A README that writes out a type it also exports
is making a claim the tier-1 name check cannot reach: a standalone
`interface GanttTask { ... }` in a fenced block is a LOCAL declaration, so it
compiles green no matter what it says.
`scripts/check-readme-exports.mjs` now compares, for every fenced block that
declares `interface X { ... }` or `type X = { ... }` where `X` is an export of
that README's own package, the documented property names against the shipped
declaration's, in both directions:
fabricated-key a documented key the shipped type does not have, judged
against the FULL property set so an inherited key is not
reported as invented;
stale-omission a shipped key the block never mentions, judged against the
interface's OWN declared members so an excerpt of a type
with a large base is not stale for leaving the base alone.
One direction pins only half, and a RENAME is visible only as the pair -- the
new spelling as `fabricated-key`, the old one as `stale-omission` on the same
declaration. The header states the bound the card recorded: the typed-example
half cannot carry that class, because a property-level type error
short-circuits the missing-property detail.
Two ways to declare a deliberate excerpt, with different meanings: a
`PARTIAL_MARKER` HTML comment in the README (grammar deliberately in
`check-doc-snippet-types.mjs`'s `FRAGMENT_MARKER` family), and a
`PARTIAL_EXCERPTS` ledger here for drift owed to a content card. Both are
shrink-only and both suppress omissions ONLY -- neither can hide a fabricated
key. An unbuilt package is a FAILURE on this side too, never a serene
`local-declaration`, and three new floors let the pin walk collapse and name
itself independently of the import walk.
No new compiler: `check-doc-snippet-types.mjs` already collects every
`packages/<name>/README.md` and compiles the covered ones (39 in its surface, 8
compiled, 31 on its shrink-only ledger). Measured and written into the header.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
`type-check:scripts` reads the JSDoc as the declaration for this module, so the
new option existed at runtime and not in the contract — every test passing it
was a TS2353 while the suite itself was green. The producer's declaration was
the stale half; fixed there rather than loosened at the five call sites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
The ledger is a debt list, and a debt with no card is one nobody picks up.
Each entry now names objectui#7302 (the README fixes) beside objectui#6214
(the gate that recorded it), and the kanban pair notes that objectui#6155 can
move its omission set — that set is read from what the package exports, not
copied into the entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
…compared
`Test (shard 2/4)` went red on the interface pin's first CI run. Not a flake:
the test shards run `pnpm install` then `pnpm test` and NEVER build, so on CI
every documented type comes back `unjudgeable-type` — and the three real
`PARTIAL_EXCERPTS` entries then suppressed no omission, so the shrink-only
sweep reported all three as `stale-excerpt-entry`.
That is the wrong verdict, and wrong in the direction that costs most: it tells
a reader to DELETE a ledger entry that is still true. "This entry suppressed
nothing" has two causes that look identical from outside — the README caught up
(stale), or the declaration could not be judged at all (still true) — and they
must not share a verdict.
So a declaration that resolves to `unjudgeable-type` now SUSPENDS the rule for
its ledger entry and its marker rather than satisfying it. Both are counted as
`not judged` in the census, and the run still FAILS — under `unjudgeable-type`,
which names the real problem. The counter increments only where an excerpt was
actually declared, so the census means "claims this run could not check" and
not "declarations it could not judge", which `typesUnjudgeable` already says.
Tests: four fixture legs, on the fixture tree so they hold in both build states
— the ledger case, the marker case, the no-excerpt case pinning the counter's
meaning, and a MUST-FAIL CONTROL asserting the same entry on a built tree is
still reported stale. Without that control all three would also pass if the
suspension had swallowed the rule outright, which is the opposite defect and
the more expensive one: a ledger that can never shrink again.
Tier 1's `repo state` test filtered `!== 'unjudgeable'` to mean "could not
judge". That class has two verdicts now, so the filter names both — the
assertion that the unbuilt tree must still FAIL is unchanged — and its unbuilt
branch now asserts zero stale-excerpt and zero stale-marker rows by name, so
this regression cannot come back silently.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(scripts): pin documented interface blocks against the shipped type, both ways - #7306

Merged
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins
Sep 2, 2026
Merged

feat(scripts): pin documented interface blocks against the shipped type, both ways#7306
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6214

Tier 2 of #5043, and not the shape the card assumed. The card asked for two things — compile the README code blocks, and add bidirectional pins for documented interface blocks. The two measurements the unlock comment left owed were taken first, and the first of them says the compile half already has a gate. So this PR is the pin, and the compile half is reported rather than rebuilt.

Both measurements are below with their numbers, because both change what got built.


Measurement (a) — the compile half is already check:doc-snippets' job

scripts/check-doc-snippet-types.mjs does not scan content/docs only. Its collector walks that tree and every packages/PKG/README.md (the listDocuments walk, and the scan surface is stated in its header in the same breath as the coverage rule). Coverage there is declared, never assumed: a document is compiled unless it is named in UNGATED_DOCS with a reason, and that ledger is shrink-only — an entry naming a file that does not exist, or that holds no ts/tsx block, fails as stale.

Measured on this branch's base:

count
package READMEs in that gate's scan surface39
of them compiled today8 (cli, components, create-plugin, plugin-grid, plugin-timeline, runner, test-support, vscode-extension)
of them on the UNGATED_DOCS debt ledger, each with a reason31
total entries on that ledger43 (31 READMEs + 12 .mdx pages)

#5174's batches are burning that ledger down — its header records batch 1 clearing 90 diagnostics, batch 2 eighty-nine, batch 3 eighty-one, batch 4 one hundred and four, and batch 5 is live on content/docs right now. So a second README compiler here would duplicate a working gate and split its ledger in two, and every README it brought under itself would then have two places to declare a fragment. Not built, deliberately; the reasoning is in this gate's header under "Compiling the blocks" so the next reader does not re-derive it.

One honest gap that measurement exposes, recorded and not fixed here: the doc-snippets collector reads packages/NAME/README.md at the package root only, so the four nested READMEs this gate walks (packages/types/src/zod/README.md and friends) are outside its surface entirely — neither covered nor on its ledger. That is #5174's own class of finding, one directory deeper, and it belongs to that gate.

Measurement (b) — the tier-2 baseline, re-taken on the merged ref

The card was blocked on this: "Running all 15 compilable plugin-gantt blocks under strict, after #5012 landed, still exits rc=2: two TS7006."#5057 has since reached completed state, by way of merged PR #5900 and the README moved again in 231d1b93c, so the figure was re-taken with the existing instrumentcheck-doc-snippet-types.mjs's own analyze + compileSnippets, with plugin-gantt lifted out of UNGATED_DOCS — rather than a new compiler:

targets: packages/plugin-gantt/README.md
blocks to compile: 18 | declared fragments: 0
parse failures on targets: 2
semantic-failing blocks on targets: 5
total diagnostics on targets: 13 {"TS2304":7,"TS7006":1,"TS7031":2,"TS2552":1,"TS2322":2}

It does not compile clean, and the shape of the red moved. The filed rc=2 / two TS7006 is now one TS7006 plus two TS7031 (the same unannotated-callback class, redistributed), and the bulk is a different family: seven TS2304 from blocks that are deliberately-not-valid property sketches ({ type, tasks: … }, bare dependencies: [ … ]), which is precisely the case FRAGMENT_MARKER exists for over there. Two blocks do not even parse. One diagnostic is real drift a compile tier would catch — TS2322: Type '"gantt"' is not assignable to type '"object-gantt"'.

Under the card's clause — "decide explicitly whether to list it as excluded or add a class, don't let it fall out silently" — this is listed, not inherited: packages/plugin-gantt/README.md stays on UNGATED_DOCS (untouched by this PR, it is #5174's file) and the number above is on the record. No shrink-only exclusion entry is added here for it, because this PR adds no compile pass for it to be excluded from.


What this PR builds: the bidirectional interface pin

A README that writes out a type it also exports makes a claim tier 1's name check cannot reach. The card records why, and the pin is built on it:

A documented interface block compiles green no matter what it says. A standalone interface GanttTask { … } in a README is a local declaration unrelated to the real type; feeding it to tsc as-is proves nothing.

So for every fenced block that declares interface X { … } or type X = { … } where X is an export of that README's own package, the documented property names are compared against the shipped declaration's, in both directions:

verdictdirectionjudged against
fabricated-keydocumented, shipped lacks itthe type's full property set, inherited members included
stale-omissionshipped, documented never mentions itthe interface's own declared members only

The asymmetry is the design, not a shortcut. Documenting a key that arrives through extends is correct, so the doc side is judged leniently; an excerpt of a type with a large base is not stale for leaving the base's keys to the base's own page, so the shipped side is judged narrowly. Measured on the real tree: judging the shipped side against all properties instead would report 36 omissions on DetailViewSchema alone, most of them inherited BaseSchema members.

One direction pins only half, which is why both are here — and a rename is visible only as the pair, the new spelling as fabricated-key and the old one as stale-omission on the same declaration and the same line. No single direction reports it.

The bound, stated in the script header because it is narrow

The card's other measurement is written into the header verbatim in substance: the typed-example half cannot carry the key-rename class, because a property-level type error short-circuits the missing-property detail — const task: GanttTask = { name: …, start: '2024-01-01' } reports two TS2322 and never that title is gone. This pin is what covers renames on interface blocks, and the header says so and promises nothing more.

Where it deliberately stops (first cut, all four in the header)

  • Method and index signatures are counted and skipped, on both sides. packages/types/README.md's DataSource is entirely method signatures, so it resolves and is compared over zero keys — and the census says 1 compared over ZERO documented keys rather than letting it read as a verified declaration.
  • Property names only, never types. That half belongs to a compile tier.
  • X is resolved against the README's own package only. Widening to any workspace package pulls in packages/plugin-detail/README.md's DetailViewSchema (owned by @object-ui/types) at the 36 omissions above. That widening needs its own inheritance policy and its own card.
  • Nested declarations are not walked — a type declared inside a function body in an example is the example's scaffolding, not a claim about the surface.

Declaring a deliberate excerpt — two homes, two different claims

Mirroring the pair check-doc-snippet-types.mjs already runs (UNGATED_DOCS + FRAGMENT_MARKER), and for the same reason: they say different things.

  • PARTIAL_MARKER — an HTML comment in the README, above the fence, whose body reads readme-exports: partial GanttTask — reason. (Spelled with a placeholder here on purpose: an HTML comment written literally into a GitHub body does not survive to the reader. The exact literal is published as PARTIAL_MARKER_EXAMPLE in the script, a test asserts the regex accepts it, and the gate prints it in its own failure message — so the one place a reader meets it is the one place it is guaranteed correct.) It says this excerpt is deliberate. The grammar is deliberately the same family as FRAGMENT_MARKER (marker word, an em dash / double hyphen / colon, then a reason of at least 12 characters), so a reader who knows one knows the other. The verb differs because the claim doesdoc-snippet: fragment says a block cannot compile, and that must not double as permission to omit a key. Only the HTML spelling exists here: these are README.md files, not MDX.
    It names the interface, which FRAGMENT_MARKER has no need to do: packages/plugin-kanban/README.md declares two types in one block, and a marker that silenced a whole block would silence the neighbour nobody looked at. Pinned as a test.
  • PARTIAL_EXCERPTS — a ledger in the script. It says this is drift, owed to a content card.

Both suppress stale-omission only. Neither can hide a fabricated-key — an excerpt may leave a key out, it may not invent one, and there is no reading of "partial" under which a ledger should be able to hide that. Both are shrink-only and enforced as such: a marker or entry that suppressed nothing fails, so the lists cannot quietly stop shrinking.

An unbuilt package is a FAILURE on this side too

Tier 1's rule, kept, and it needed its own branch: with no export surface on disk every documented type resolves to nothing and would read as a serene local-declaration, so the pin would report green over blocks it never judged. Scoped exactly as the import side is — it fails only where the missing surface would have changed a verdict, i.e. where a block declares a type. Verdict unjudgeable-type, in the census, pinned by a test.

Non-vacuity

Three new FLOORS counters — typeDeclarations, typesResolved, keysCompared — so the pin walk can collapse and name itself instead of hiding behind a healthy import walk. Pinned by a three-leg independence test on its own fixture tree (a control leg proving nothing breaches while both walks are healthy, an exact-equality leg on the three pin counters alone, and a leg pinning that the package-side counters are unchanged) — the same shape, and the same exactness argument, as tier 1's.

Its own fixture tree, deliberately: tier 1's independence test asserts exportSymbolsexactly (toBe(4)), and adding exports to alpha to give this half something to compare would have turned that assertion into a number nobody chose.


The census, before and after

Before, at eb33a8d4c (this branch's base):

✅ check-readme-exports: OK (43 tracked README(s) under packages/ (0 outside any package), 407 fenced
block(s) (309 parsed as code, 6 untagged); 506 import binding(s), 386 of them self-imports judged (386
real, 0 wrong-path, 0 fabricated); 3292 export symbol(s) read from 37 of 40 tracked package(s) (39 carry
a README) (0 unbuilt, 3 declare no types); 17 side-effect import(s), 0 namespace, 11 deep self-path, 92
to other packages).

After, at 15a06cff3 (the head of this branch, after the merge of main) — the same line with the pin's half appended:

✅ check-readme-exports: OK (… 92 to other packages; 6 documented type(s) in 5 block(s) (4 resolve to a
shipped shape of their own package, 2 local, 0 not a property type, 0 unjudgeable), 52 key(s) compared
both ways (0 fabricated, 0 stale omission(s); 0 excerpt(s) declared by marker, 3 by ledger; 1 compared
over ZERO documented keys)).

Green at rest, with the counts that say what was skipped travelling beside the ones that say what was judged.

The exclusion ledger this opens with — three entries, two READMEs

Well under the ten-README ceiling the order set, so this ships rather than escalating. Every one is the stale-omission direction; zero fabricated keys anywhere in the tree.

entryomitsdisposition
packages/plugin-gantt/README.md::GanttTaskfields, hasOwnDatesgenuinely an excerpt — the prose immediately below the block already names both as populated by ObjectGantt itself. It wants the in-README marker, not this ledger.
packages/plugin-kanban/README.md::KanbanColumncollapsedstaleness — nothing on the page says the block is partial
packages/plugin-kanban/README.md::KanbanCardcardSubtitle, cardFieldCells, coverImagestaleness, same page, three keys behind

No README content is edited by this PR, including the marker GanttTask deserves: the order scoped this card to the gate, so all three are recorded here with the card number and filed as a content follow-up, #7302 (unassigned, finding). The kanban entry notes that #6155 — four disagreeing declarations of that pair — can move its omission set, because that set is derived from what the package exports, not copied into the entry.

scan takes an excerpts option so the ledger can be switched off, and a test asserts that with it off the repo reds with exactly these three and that every red is a stale-omission — so the ledger is provably not covering a fabricated key or anything else. That test is written to hold built and unbuilt, like tier 1's repo state block: on an unbuilt tree the same declarations are unjudgeable-type, which is the failure the rule requires and not a skip.


Ablation — seven legs, no tracked file ever mutated

Every leg runs against a scratch copy handed to the gate through tier 1's --readme REAL=SCRATCH override. Each plant is proved on disk by an anchored count before and after, never by an editor's exit code, and the runner carries a trap … EXIT INT TERM that deletes the scratch files and re-verifies the tracked README's blob.

The plant proof has its own negative control, run first: an anchor that is deliberately never written must be rejected. It was (PLANT NOT ON DISK … its reading is VOID, exit 9) — so the passes below mean something.

legplantedpredictedmeasured
controla correctly documented interface GanttMarkergreenEXIT=0, verdict matches
(i)fabricated key iconred, names itEXIT=1interface GanttMarker documents 'icon', not on the shipped type
(ii)renamelabel to captionred in both directions, same declarationEXIT=1documents 'caption'andomits 'label', both at :718
(iii)omitted shipped key colorredEXIT=1interface GanttMarker omits 'color'
(iv)the same omission + the partial markergreenEXIT=0, census 1 excerpt(s) declared by marker
(v)a fabricated key under a markerstill redEXIT=1 — the marker does not cover fabrication
(vi)a marker over a complete declarationred as staleEXIT=1declares an excerpt that omits nothing any more

Every leg names the README, the interface and the key. Re-run in full on the merged head e32824dae; the only commit after it (15a06cff3) edits three reason strings inside PARTIAL_EXCERPTS, none of which any leg above reads — every leg plants and judges GanttMarker, which the ledger does not name. The restore proof closes it:

restore-proof: packages/plugin-gantt/README.md blob db8293c38832626e497519279681184caf4c69cb
== HEAD blob db8293c38832626e497519279681184caf4c69cb

git status --porcelain was empty after the run.


Verification

All at the branch head 15a06cff3 unless a line says otherwise, gate verdicts quoted from the line each gate printed, every exit code captured by redirect before any pipe.

pnpm check:readme-exports EXIT=0 ✅ OK (census above)
pnpm type-check:scripts EXIT=0
pnpm check:control-bytes EXIT=0 ✅ OK (scanned 6008 tracked text file(s); skipped 85 binary)
pnpm check:entry-guard EXIT=0 ✓ 58 scripts/ file(s) — no entry guard outside the baseline
pnpm check:doc-fences EXIT=0 ✅ every TypeScript block in 224 document(s) …
node scripts/check-changeset-presence.mjs EXIT=0
node scripts/check-lint-coverage.mjs EXIT=0 ✅ 46/46 packages linted, 0 with outstanding errors
node scripts/check-type-check-coverage.mjs EXIT=0
pnpm lint:root EXIT=0 ✖ 29 problems (0 errors, 29 warnings) — all pre-existing
vitest run scripts/__tests__/check-readme-exports.test.ts EXIT=0 83 passed (83)

Root vitest only (#3378), never package-scoped. The suite grew 47 → 83 cases.

Authored by Claude Code in session session_01BGMDbrVa8JjZcCQ7DWYH1b (https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b) — written into the prose here because a footer does not survive a later edit of this body.

One gate went red and it was mine to fix.type-check:scripts reported five TS2353 on excerpts — the option existed at runtime and not in scan's JSDoc, which is what that gate reads as the declaration. Contract-first: the producer's declaration was the stale half, fixed there rather than loosened at the five call sites. The vitest suite was green through all of it, which is why the type gate is a separate reading and not a duplicate of it.

No changeset, and the gate says so in its own words:"2 file(s) changed, 0 of them published source of a package the release covers … no changeset is owed." Everything in this diff is under scripts/. Same disposition as #6212, which shipped scripts-only the same way; the skip-changeset label is deliberately not applied — in this repository that label is read by no workflow and exempts nothing, and the gate's verdict line is the authority.

Test scope, derived from disk not assumed.git grep -l 'check-readme-exports' across *.test.ts returns exactly one file: scripts/__tests__/check-readme-exports.test.ts. That is the whole set of suites that read anything this diff changes, and it was run in full on the final head.

Coverage of the type gate, measured rather than claimed.tsc -p tsconfig.scripts.json --listFiles reports 1 hit each for scripts/check-readme-exports.mjs and scripts/__tests__/check-readme-exports.test.ts — so "type-check is clean" is a statement about both edited files and not a true sentence about a population that excludes them.

Lint scope, stated rather than assumed — and this is the full run for this diff, not a narrowing.pnpm lint:root is eslint . with four ignore patterns; read from eslint's own config via --format json, its population is 226 files, 0 errors, 29 warnings, and both edited files are in it with zero messages. No type-aware linting is configured (no projectService, no parserOptions.project), so this diff cannot move the verdict of any untouched file. Every code file in the diff is under scripts/, i.e. entirely inside that population.

Declared narrowing

Two, both stated so a reader can price them:

  1. The repo-wide pnpm lint (turbo run lint, per package) was not run. Zero package source changed, and the three-part evidence above — population read from eslint's own config, file counts from --format json, and no type-aware linting — is what makes that a measurement rather than a skip.
  2. scripts/__tests__ was not run whole, only the one suite. Derived from disk, as above: no other test file reads any changed path. CI runs the directory regardless.

The authoritative reading is the CI job conclusions on this PR.

Merge, never a rebase

One merge of origin/main (eb33a8d4cec0a7b846), no conflicts. git diff --name-only across that range touches five files, all under scripts/, and zero under packages/ — so the built dist/ this gate reads is still exactly the merged tree's, and the census above is a reading about the tree being shipped rather than a stale one.

Follow-ups


Generated by Claude Code

…type, both ways
Tier 2 of objectui#5043, the half the card scoped as "bidirectional pins for
documented `interface` blocks". A README that writes out a type it also exports
is making a claim the tier-1 name check cannot reach: a standalone
`interface GanttTask { ... }` in a fenced block is a LOCAL declaration, so it
compiles green no matter what it says.
`scripts/check-readme-exports.mjs` now compares, for every fenced block that
declares `interface X { ... }` or `type X = { ... }` where `X` is an export of
that README's own package, the documented property names against the shipped
declaration's, in both directions:
fabricated-key a documented key the shipped type does not have, judged
against the FULL property set so an inherited key is not
reported as invented;
stale-omission a shipped key the block never mentions, judged against the
interface's OWN declared members so an excerpt of a type
with a large base is not stale for leaving the base alone.
One direction pins only half, and a RENAME is visible only as the pair -- the
new spelling as `fabricated-key`, the old one as `stale-omission` on the same
declaration. The header states the bound the card recorded: the typed-example
half cannot carry that class, because a property-level type error
short-circuits the missing-property detail.
Two ways to declare a deliberate excerpt, with different meanings: a
`PARTIAL_MARKER` HTML comment in the README (grammar deliberately in
`check-doc-snippet-types.mjs`'s `FRAGMENT_MARKER` family), and a
`PARTIAL_EXCERPTS` ledger here for drift owed to a content card. Both are
shrink-only and both suppress omissions ONLY -- neither can hide a fabricated
key. An unbuilt package is a FAILURE on this side too, never a serene
`local-declaration`, and three new floors let the pin walk collapse and name
itself independently of the import walk.
No new compiler: `check-doc-snippet-types.mjs` already collects every
`packages/<name>/README.md` and compiles the covered ones (39 in its surface, 8
compiled, 31 on its shrink-only ledger). Measured and written into the header.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
`type-check:scripts` reads the JSDoc as the declaration for this module, so the
new option existed at runtime and not in the contract — every test passing it
was a TS2353 while the suite itself was green. The producer's declaration was
the stale half; fixed there rather than loosened at the five call sites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
The ledger is a debt list, and a debt with no card is one nobody picks up.
Each entry now names objectui#7302 (the README fixes) beside objectui#6214
(the gate that recorded it), and the kanban pair notes that objectui#6155 can
move its omission set — that set is read from what the package exports, not
copied into the entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
…compared
`Test (shard 2/4)` went red on the interface pin's first CI run. Not a flake:
the test shards run `pnpm install` then `pnpm test` and NEVER build, so on CI
every documented type comes back `unjudgeable-type` — and the three real
`PARTIAL_EXCERPTS` entries then suppressed no omission, so the shrink-only
sweep reported all three as `stale-excerpt-entry`.
That is the wrong verdict, and wrong in the direction that costs most: it tells
a reader to DELETE a ledger entry that is still true. "This entry suppressed
nothing" has two causes that look identical from outside — the README caught up
(stale), or the declaration could not be judged at all (still true) — and they
must not share a verdict.
So a declaration that resolves to `unjudgeable-type` now SUSPENDS the rule for
its ledger entry and its marker rather than satisfying it. Both are counted as
`not judged` in the census, and the run still FAILS — under `unjudgeable-type`,
which names the real problem. The counter increments only where an excerpt was
actually declared, so the census means "claims this run could not check" and
not "declarations it could not judge", which `typesUnjudgeable` already says.
Tests: four fixture legs, on the fixture tree so they hold in both build states
— the ledger case, the marker case, the no-excerpt case pinning the counter's
meaning, and a MUST-FAIL CONTROL asserting the same entry on a built tree is
still reported stale. Without that control all three would also pass if the
suspension had swallowed the rule outright, which is the opposite defect and
the more expensive one: a ledger that can never shrink again.
Tier 1's `repo state` test filtered `!== 'unjudgeable'` to mean "could not
judge". That class has two verdicts now, so the filter names both — the
assertion that the unbuilt tree must still FAIL is unchanged — and its unbuilt
branch now asserts zero stale-excerpt and zero stale-marker rows by name, so
this regression cannot come back silently.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(scripts): pin documented interface blocks against the shipped type, both ways - #7306

Merged
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins
Sep 2, 2026
Merged

feat(scripts): pin documented interface blocks against the shipped type, both ways#7306
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6214

Tier 2 of #5043, and not the shape the card assumed. The card asked for two things — compile the README code blocks, and add bidirectional pins for documented interface blocks. The two measurements the unlock comment left owed were taken first, and the first of them says the compile half already has a gate. So this PR is the pin, and the compile half is reported rather than rebuilt.

Both measurements are below with their numbers, because both change what got built.


Measurement (a) — the compile half is already check:doc-snippets' job

scripts/check-doc-snippet-types.mjs does not scan content/docs only. Its collector walks that tree and every packages/PKG/README.md (the listDocuments walk, and the scan surface is stated in its header in the same breath as the coverage rule). Coverage there is declared, never assumed: a document is compiled unless it is named in UNGATED_DOCS with a reason, and that ledger is shrink-only — an entry naming a file that does not exist, or that holds no ts/tsx block, fails as stale.

Measured on this branch's base:

count
package READMEs in that gate's scan surface39
of them compiled today8 (cli, components, create-plugin, plugin-grid, plugin-timeline, runner, test-support, vscode-extension)
of them on the UNGATED_DOCS debt ledger, each with a reason31
total entries on that ledger43 (31 READMEs + 12 .mdx pages)

#5174's batches are burning that ledger down — its header records batch 1 clearing 90 diagnostics, batch 2 eighty-nine, batch 3 eighty-one, batch 4 one hundred and four, and batch 5 is live on content/docs right now. So a second README compiler here would duplicate a working gate and split its ledger in two, and every README it brought under itself would then have two places to declare a fragment. Not built, deliberately; the reasoning is in this gate's header under "Compiling the blocks" so the next reader does not re-derive it.

One honest gap that measurement exposes, recorded and not fixed here: the doc-snippets collector reads packages/NAME/README.md at the package root only, so the four nested READMEs this gate walks (packages/types/src/zod/README.md and friends) are outside its surface entirely — neither covered nor on its ledger. That is #5174's own class of finding, one directory deeper, and it belongs to that gate.

Measurement (b) — the tier-2 baseline, re-taken on the merged ref

The card was blocked on this: "Running all 15 compilable plugin-gantt blocks under strict, after #5012 landed, still exits rc=2: two TS7006."#5057 has since reached completed state, by way of merged PR #5900 and the README moved again in 231d1b93c, so the figure was re-taken with the existing instrumentcheck-doc-snippet-types.mjs's own analyze + compileSnippets, with plugin-gantt lifted out of UNGATED_DOCS — rather than a new compiler:

targets: packages/plugin-gantt/README.md
blocks to compile: 18 | declared fragments: 0
parse failures on targets: 2
semantic-failing blocks on targets: 5
total diagnostics on targets: 13 {"TS2304":7,"TS7006":1,"TS7031":2,"TS2552":1,"TS2322":2}

It does not compile clean, and the shape of the red moved. The filed rc=2 / two TS7006 is now one TS7006 plus two TS7031 (the same unannotated-callback class, redistributed), and the bulk is a different family: seven TS2304 from blocks that are deliberately-not-valid property sketches ({ type, tasks: … }, bare dependencies: [ … ]), which is precisely the case FRAGMENT_MARKER exists for over there. Two blocks do not even parse. One diagnostic is real drift a compile tier would catch — TS2322: Type '"gantt"' is not assignable to type '"object-gantt"'.

Under the card's clause — "decide explicitly whether to list it as excluded or add a class, don't let it fall out silently" — this is listed, not inherited: packages/plugin-gantt/README.md stays on UNGATED_DOCS (untouched by this PR, it is #5174's file) and the number above is on the record. No shrink-only exclusion entry is added here for it, because this PR adds no compile pass for it to be excluded from.


What this PR builds: the bidirectional interface pin

A README that writes out a type it also exports makes a claim tier 1's name check cannot reach. The card records why, and the pin is built on it:

A documented interface block compiles green no matter what it says. A standalone interface GanttTask { … } in a README is a local declaration unrelated to the real type; feeding it to tsc as-is proves nothing.

So for every fenced block that declares interface X { … } or type X = { … } where X is an export of that README's own package, the documented property names are compared against the shipped declaration's, in both directions:

verdictdirectionjudged against
fabricated-keydocumented, shipped lacks itthe type's full property set, inherited members included
stale-omissionshipped, documented never mentions itthe interface's own declared members only

The asymmetry is the design, not a shortcut. Documenting a key that arrives through extends is correct, so the doc side is judged leniently; an excerpt of a type with a large base is not stale for leaving the base's keys to the base's own page, so the shipped side is judged narrowly. Measured on the real tree: judging the shipped side against all properties instead would report 36 omissions on DetailViewSchema alone, most of them inherited BaseSchema members.

One direction pins only half, which is why both are here — and a rename is visible only as the pair, the new spelling as fabricated-key and the old one as stale-omission on the same declaration and the same line. No single direction reports it.

The bound, stated in the script header because it is narrow

The card's other measurement is written into the header verbatim in substance: the typed-example half cannot carry the key-rename class, because a property-level type error short-circuits the missing-property detail — const task: GanttTask = { name: …, start: '2024-01-01' } reports two TS2322 and never that title is gone. This pin is what covers renames on interface blocks, and the header says so and promises nothing more.

Where it deliberately stops (first cut, all four in the header)

  • Method and index signatures are counted and skipped, on both sides. packages/types/README.md's DataSource is entirely method signatures, so it resolves and is compared over zero keys — and the census says 1 compared over ZERO documented keys rather than letting it read as a verified declaration.
  • Property names only, never types. That half belongs to a compile tier.
  • X is resolved against the README's own package only. Widening to any workspace package pulls in packages/plugin-detail/README.md's DetailViewSchema (owned by @object-ui/types) at the 36 omissions above. That widening needs its own inheritance policy and its own card.
  • Nested declarations are not walked — a type declared inside a function body in an example is the example's scaffolding, not a claim about the surface.

Declaring a deliberate excerpt — two homes, two different claims

Mirroring the pair check-doc-snippet-types.mjs already runs (UNGATED_DOCS + FRAGMENT_MARKER), and for the same reason: they say different things.

  • PARTIAL_MARKER — an HTML comment in the README, above the fence, whose body reads readme-exports: partial GanttTask — reason. (Spelled with a placeholder here on purpose: an HTML comment written literally into a GitHub body does not survive to the reader. The exact literal is published as PARTIAL_MARKER_EXAMPLE in the script, a test asserts the regex accepts it, and the gate prints it in its own failure message — so the one place a reader meets it is the one place it is guaranteed correct.) It says this excerpt is deliberate. The grammar is deliberately the same family as FRAGMENT_MARKER (marker word, an em dash / double hyphen / colon, then a reason of at least 12 characters), so a reader who knows one knows the other. The verb differs because the claim doesdoc-snippet: fragment says a block cannot compile, and that must not double as permission to omit a key. Only the HTML spelling exists here: these are README.md files, not MDX.
    It names the interface, which FRAGMENT_MARKER has no need to do: packages/plugin-kanban/README.md declares two types in one block, and a marker that silenced a whole block would silence the neighbour nobody looked at. Pinned as a test.
  • PARTIAL_EXCERPTS — a ledger in the script. It says this is drift, owed to a content card.

Both suppress stale-omission only. Neither can hide a fabricated-key — an excerpt may leave a key out, it may not invent one, and there is no reading of "partial" under which a ledger should be able to hide that. Both are shrink-only and enforced as such: a marker or entry that suppressed nothing fails, so the lists cannot quietly stop shrinking.

An unbuilt package is a FAILURE on this side too

Tier 1's rule, kept, and it needed its own branch: with no export surface on disk every documented type resolves to nothing and would read as a serene local-declaration, so the pin would report green over blocks it never judged. Scoped exactly as the import side is — it fails only where the missing surface would have changed a verdict, i.e. where a block declares a type. Verdict unjudgeable-type, in the census, pinned by a test.

Non-vacuity

Three new FLOORS counters — typeDeclarations, typesResolved, keysCompared — so the pin walk can collapse and name itself instead of hiding behind a healthy import walk. Pinned by a three-leg independence test on its own fixture tree (a control leg proving nothing breaches while both walks are healthy, an exact-equality leg on the three pin counters alone, and a leg pinning that the package-side counters are unchanged) — the same shape, and the same exactness argument, as tier 1's.

Its own fixture tree, deliberately: tier 1's independence test asserts exportSymbolsexactly (toBe(4)), and adding exports to alpha to give this half something to compare would have turned that assertion into a number nobody chose.


The census, before and after

Before, at eb33a8d4c (this branch's base):

✅ check-readme-exports: OK (43 tracked README(s) under packages/ (0 outside any package), 407 fenced
block(s) (309 parsed as code, 6 untagged); 506 import binding(s), 386 of them self-imports judged (386
real, 0 wrong-path, 0 fabricated); 3292 export symbol(s) read from 37 of 40 tracked package(s) (39 carry
a README) (0 unbuilt, 3 declare no types); 17 side-effect import(s), 0 namespace, 11 deep self-path, 92
to other packages).

After, at 15a06cff3 (the head of this branch, after the merge of main) — the same line with the pin's half appended:

✅ check-readme-exports: OK (… 92 to other packages; 6 documented type(s) in 5 block(s) (4 resolve to a
shipped shape of their own package, 2 local, 0 not a property type, 0 unjudgeable), 52 key(s) compared
both ways (0 fabricated, 0 stale omission(s); 0 excerpt(s) declared by marker, 3 by ledger; 1 compared
over ZERO documented keys)).

Green at rest, with the counts that say what was skipped travelling beside the ones that say what was judged.

The exclusion ledger this opens with — three entries, two READMEs

Well under the ten-README ceiling the order set, so this ships rather than escalating. Every one is the stale-omission direction; zero fabricated keys anywhere in the tree.

entryomitsdisposition
packages/plugin-gantt/README.md::GanttTaskfields, hasOwnDatesgenuinely an excerpt — the prose immediately below the block already names both as populated by ObjectGantt itself. It wants the in-README marker, not this ledger.
packages/plugin-kanban/README.md::KanbanColumncollapsedstaleness — nothing on the page says the block is partial
packages/plugin-kanban/README.md::KanbanCardcardSubtitle, cardFieldCells, coverImagestaleness, same page, three keys behind

No README content is edited by this PR, including the marker GanttTask deserves: the order scoped this card to the gate, so all three are recorded here with the card number and filed as a content follow-up, #7302 (unassigned, finding). The kanban entry notes that #6155 — four disagreeing declarations of that pair — can move its omission set, because that set is derived from what the package exports, not copied into the entry.

scan takes an excerpts option so the ledger can be switched off, and a test asserts that with it off the repo reds with exactly these three and that every red is a stale-omission — so the ledger is provably not covering a fabricated key or anything else. That test is written to hold built and unbuilt, like tier 1's repo state block: on an unbuilt tree the same declarations are unjudgeable-type, which is the failure the rule requires and not a skip.


Ablation — seven legs, no tracked file ever mutated

Every leg runs against a scratch copy handed to the gate through tier 1's --readme REAL=SCRATCH override. Each plant is proved on disk by an anchored count before and after, never by an editor's exit code, and the runner carries a trap … EXIT INT TERM that deletes the scratch files and re-verifies the tracked README's blob.

The plant proof has its own negative control, run first: an anchor that is deliberately never written must be rejected. It was (PLANT NOT ON DISK … its reading is VOID, exit 9) — so the passes below mean something.

legplantedpredictedmeasured
controla correctly documented interface GanttMarkergreenEXIT=0, verdict matches
(i)fabricated key iconred, names itEXIT=1interface GanttMarker documents 'icon', not on the shipped type
(ii)renamelabel to captionred in both directions, same declarationEXIT=1documents 'caption'andomits 'label', both at :718
(iii)omitted shipped key colorredEXIT=1interface GanttMarker omits 'color'
(iv)the same omission + the partial markergreenEXIT=0, census 1 excerpt(s) declared by marker
(v)a fabricated key under a markerstill redEXIT=1 — the marker does not cover fabrication
(vi)a marker over a complete declarationred as staleEXIT=1declares an excerpt that omits nothing any more

Every leg names the README, the interface and the key. Re-run in full on the merged head e32824dae; the only commit after it (15a06cff3) edits three reason strings inside PARTIAL_EXCERPTS, none of which any leg above reads — every leg plants and judges GanttMarker, which the ledger does not name. The restore proof closes it:

restore-proof: packages/plugin-gantt/README.md blob db8293c38832626e497519279681184caf4c69cb
== HEAD blob db8293c38832626e497519279681184caf4c69cb

git status --porcelain was empty after the run.


Verification

All at the branch head 15a06cff3 unless a line says otherwise, gate verdicts quoted from the line each gate printed, every exit code captured by redirect before any pipe.

pnpm check:readme-exports EXIT=0 ✅ OK (census above)
pnpm type-check:scripts EXIT=0
pnpm check:control-bytes EXIT=0 ✅ OK (scanned 6008 tracked text file(s); skipped 85 binary)
pnpm check:entry-guard EXIT=0 ✓ 58 scripts/ file(s) — no entry guard outside the baseline
pnpm check:doc-fences EXIT=0 ✅ every TypeScript block in 224 document(s) …
node scripts/check-changeset-presence.mjs EXIT=0
node scripts/check-lint-coverage.mjs EXIT=0 ✅ 46/46 packages linted, 0 with outstanding errors
node scripts/check-type-check-coverage.mjs EXIT=0
pnpm lint:root EXIT=0 ✖ 29 problems (0 errors, 29 warnings) — all pre-existing
vitest run scripts/__tests__/check-readme-exports.test.ts EXIT=0 83 passed (83)

Root vitest only (#3378), never package-scoped. The suite grew 47 → 83 cases.

Authored by Claude Code in session session_01BGMDbrVa8JjZcCQ7DWYH1b (https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b) — written into the prose here because a footer does not survive a later edit of this body.

One gate went red and it was mine to fix.type-check:scripts reported five TS2353 on excerpts — the option existed at runtime and not in scan's JSDoc, which is what that gate reads as the declaration. Contract-first: the producer's declaration was the stale half, fixed there rather than loosened at the five call sites. The vitest suite was green through all of it, which is why the type gate is a separate reading and not a duplicate of it.

No changeset, and the gate says so in its own words:"2 file(s) changed, 0 of them published source of a package the release covers … no changeset is owed." Everything in this diff is under scripts/. Same disposition as #6212, which shipped scripts-only the same way; the skip-changeset label is deliberately not applied — in this repository that label is read by no workflow and exempts nothing, and the gate's verdict line is the authority.

Test scope, derived from disk not assumed.git grep -l 'check-readme-exports' across *.test.ts returns exactly one file: scripts/__tests__/check-readme-exports.test.ts. That is the whole set of suites that read anything this diff changes, and it was run in full on the final head.

Coverage of the type gate, measured rather than claimed.tsc -p tsconfig.scripts.json --listFiles reports 1 hit each for scripts/check-readme-exports.mjs and scripts/__tests__/check-readme-exports.test.ts — so "type-check is clean" is a statement about both edited files and not a true sentence about a population that excludes them.

Lint scope, stated rather than assumed — and this is the full run for this diff, not a narrowing.pnpm lint:root is eslint . with four ignore patterns; read from eslint's own config via --format json, its population is 226 files, 0 errors, 29 warnings, and both edited files are in it with zero messages. No type-aware linting is configured (no projectService, no parserOptions.project), so this diff cannot move the verdict of any untouched file. Every code file in the diff is under scripts/, i.e. entirely inside that population.

Declared narrowing

Two, both stated so a reader can price them:

  1. The repo-wide pnpm lint (turbo run lint, per package) was not run. Zero package source changed, and the three-part evidence above — population read from eslint's own config, file counts from --format json, and no type-aware linting — is what makes that a measurement rather than a skip.
  2. scripts/__tests__ was not run whole, only the one suite. Derived from disk, as above: no other test file reads any changed path. CI runs the directory regardless.

The authoritative reading is the CI job conclusions on this PR.

Merge, never a rebase

One merge of origin/main (eb33a8d4cec0a7b846), no conflicts. git diff --name-only across that range touches five files, all under scripts/, and zero under packages/ — so the built dist/ this gate reads is still exactly the merged tree's, and the census above is a reading about the tree being shipped rather than a stale one.

Follow-ups


Generated by Claude Code

…type, both ways
Tier 2 of objectui#5043, the half the card scoped as "bidirectional pins for
documented `interface` blocks". A README that writes out a type it also exports
is making a claim the tier-1 name check cannot reach: a standalone
`interface GanttTask { ... }` in a fenced block is a LOCAL declaration, so it
compiles green no matter what it says.
`scripts/check-readme-exports.mjs` now compares, for every fenced block that
declares `interface X { ... }` or `type X = { ... }` where `X` is an export of
that README's own package, the documented property names against the shipped
declaration's, in both directions:
fabricated-key a documented key the shipped type does not have, judged
against the FULL property set so an inherited key is not
reported as invented;
stale-omission a shipped key the block never mentions, judged against the
interface's OWN declared members so an excerpt of a type
with a large base is not stale for leaving the base alone.
One direction pins only half, and a RENAME is visible only as the pair -- the
new spelling as `fabricated-key`, the old one as `stale-omission` on the same
declaration. The header states the bound the card recorded: the typed-example
half cannot carry that class, because a property-level type error
short-circuits the missing-property detail.
Two ways to declare a deliberate excerpt, with different meanings: a
`PARTIAL_MARKER` HTML comment in the README (grammar deliberately in
`check-doc-snippet-types.mjs`'s `FRAGMENT_MARKER` family), and a
`PARTIAL_EXCERPTS` ledger here for drift owed to a content card. Both are
shrink-only and both suppress omissions ONLY -- neither can hide a fabricated
key. An unbuilt package is a FAILURE on this side too, never a serene
`local-declaration`, and three new floors let the pin walk collapse and name
itself independently of the import walk.
No new compiler: `check-doc-snippet-types.mjs` already collects every
`packages/<name>/README.md` and compiles the covered ones (39 in its surface, 8
compiled, 31 on its shrink-only ledger). Measured and written into the header.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
`type-check:scripts` reads the JSDoc as the declaration for this module, so the
new option existed at runtime and not in the contract — every test passing it
was a TS2353 while the suite itself was green. The producer's declaration was
the stale half; fixed there rather than loosened at the five call sites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
The ledger is a debt list, and a debt with no card is one nobody picks up.
Each entry now names objectui#7302 (the README fixes) beside objectui#6214
(the gate that recorded it), and the kanban pair notes that objectui#6155 can
move its omission set — that set is read from what the package exports, not
copied into the entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
…compared
`Test (shard 2/4)` went red on the interface pin's first CI run. Not a flake:
the test shards run `pnpm install` then `pnpm test` and NEVER build, so on CI
every documented type comes back `unjudgeable-type` — and the three real
`PARTIAL_EXCERPTS` entries then suppressed no omission, so the shrink-only
sweep reported all three as `stale-excerpt-entry`.
That is the wrong verdict, and wrong in the direction that costs most: it tells
a reader to DELETE a ledger entry that is still true. "This entry suppressed
nothing" has two causes that look identical from outside — the README caught up
(stale), or the declaration could not be judged at all (still true) — and they
must not share a verdict.
So a declaration that resolves to `unjudgeable-type` now SUSPENDS the rule for
its ledger entry and its marker rather than satisfying it. Both are counted as
`not judged` in the census, and the run still FAILS — under `unjudgeable-type`,
which names the real problem. The counter increments only where an excerpt was
actually declared, so the census means "claims this run could not check" and
not "declarations it could not judge", which `typesUnjudgeable` already says.
Tests: four fixture legs, on the fixture tree so they hold in both build states
— the ledger case, the marker case, the no-excerpt case pinning the counter's
meaning, and a MUST-FAIL CONTROL asserting the same entry on a built tree is
still reported stale. Without that control all three would also pass if the
suspension had swallowed the rule outright, which is the opposite defect and
the more expensive one: a ledger that can never shrink again.
Tier 1's `repo state` test filtered `!== 'unjudgeable'` to mean "could not
judge". That class has two verdicts now, so the filter names both — the
assertion that the unbuilt tree must still FAIL is unchanged — and its unbuilt
branch now asserts zero stale-excerpt and zero stale-marker rows by name, so
this regression cannot come back silently.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(scripts): pin documented interface blocks against the shipped type, both ways - #7306

Merged
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins
Sep 2, 2026
Merged

feat(scripts): pin documented interface blocks against the shipped type, both ways#7306
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6214

Tier 2 of #5043, and not the shape the card assumed. The card asked for two things — compile the README code blocks, and add bidirectional pins for documented interface blocks. The two measurements the unlock comment left owed were taken first, and the first of them says the compile half already has a gate. So this PR is the pin, and the compile half is reported rather than rebuilt.

Both measurements are below with their numbers, because both change what got built.


Measurement (a) — the compile half is already check:doc-snippets' job

scripts/check-doc-snippet-types.mjs does not scan content/docs only. Its collector walks that tree and every packages/PKG/README.md (the listDocuments walk, and the scan surface is stated in its header in the same breath as the coverage rule). Coverage there is declared, never assumed: a document is compiled unless it is named in UNGATED_DOCS with a reason, and that ledger is shrink-only — an entry naming a file that does not exist, or that holds no ts/tsx block, fails as stale.

Measured on this branch's base:

count
package READMEs in that gate's scan surface39
of them compiled today8 (cli, components, create-plugin, plugin-grid, plugin-timeline, runner, test-support, vscode-extension)
of them on the UNGATED_DOCS debt ledger, each with a reason31
total entries on that ledger43 (31 READMEs + 12 .mdx pages)

#5174's batches are burning that ledger down — its header records batch 1 clearing 90 diagnostics, batch 2 eighty-nine, batch 3 eighty-one, batch 4 one hundred and four, and batch 5 is live on content/docs right now. So a second README compiler here would duplicate a working gate and split its ledger in two, and every README it brought under itself would then have two places to declare a fragment. Not built, deliberately; the reasoning is in this gate's header under "Compiling the blocks" so the next reader does not re-derive it.

One honest gap that measurement exposes, recorded and not fixed here: the doc-snippets collector reads packages/NAME/README.md at the package root only, so the four nested READMEs this gate walks (packages/types/src/zod/README.md and friends) are outside its surface entirely — neither covered nor on its ledger. That is #5174's own class of finding, one directory deeper, and it belongs to that gate.

Measurement (b) — the tier-2 baseline, re-taken on the merged ref

The card was blocked on this: "Running all 15 compilable plugin-gantt blocks under strict, after #5012 landed, still exits rc=2: two TS7006."#5057 has since reached completed state, by way of merged PR #5900 and the README moved again in 231d1b93c, so the figure was re-taken with the existing instrumentcheck-doc-snippet-types.mjs's own analyze + compileSnippets, with plugin-gantt lifted out of UNGATED_DOCS — rather than a new compiler:

targets: packages/plugin-gantt/README.md
blocks to compile: 18 | declared fragments: 0
parse failures on targets: 2
semantic-failing blocks on targets: 5
total diagnostics on targets: 13 {"TS2304":7,"TS7006":1,"TS7031":2,"TS2552":1,"TS2322":2}

It does not compile clean, and the shape of the red moved. The filed rc=2 / two TS7006 is now one TS7006 plus two TS7031 (the same unannotated-callback class, redistributed), and the bulk is a different family: seven TS2304 from blocks that are deliberately-not-valid property sketches ({ type, tasks: … }, bare dependencies: [ … ]), which is precisely the case FRAGMENT_MARKER exists for over there. Two blocks do not even parse. One diagnostic is real drift a compile tier would catch — TS2322: Type '"gantt"' is not assignable to type '"object-gantt"'.

Under the card's clause — "decide explicitly whether to list it as excluded or add a class, don't let it fall out silently" — this is listed, not inherited: packages/plugin-gantt/README.md stays on UNGATED_DOCS (untouched by this PR, it is #5174's file) and the number above is on the record. No shrink-only exclusion entry is added here for it, because this PR adds no compile pass for it to be excluded from.


What this PR builds: the bidirectional interface pin

A README that writes out a type it also exports makes a claim tier 1's name check cannot reach. The card records why, and the pin is built on it:

A documented interface block compiles green no matter what it says. A standalone interface GanttTask { … } in a README is a local declaration unrelated to the real type; feeding it to tsc as-is proves nothing.

So for every fenced block that declares interface X { … } or type X = { … } where X is an export of that README's own package, the documented property names are compared against the shipped declaration's, in both directions:

verdictdirectionjudged against
fabricated-keydocumented, shipped lacks itthe type's full property set, inherited members included
stale-omissionshipped, documented never mentions itthe interface's own declared members only

The asymmetry is the design, not a shortcut. Documenting a key that arrives through extends is correct, so the doc side is judged leniently; an excerpt of a type with a large base is not stale for leaving the base's keys to the base's own page, so the shipped side is judged narrowly. Measured on the real tree: judging the shipped side against all properties instead would report 36 omissions on DetailViewSchema alone, most of them inherited BaseSchema members.

One direction pins only half, which is why both are here — and a rename is visible only as the pair, the new spelling as fabricated-key and the old one as stale-omission on the same declaration and the same line. No single direction reports it.

The bound, stated in the script header because it is narrow

The card's other measurement is written into the header verbatim in substance: the typed-example half cannot carry the key-rename class, because a property-level type error short-circuits the missing-property detail — const task: GanttTask = { name: …, start: '2024-01-01' } reports two TS2322 and never that title is gone. This pin is what covers renames on interface blocks, and the header says so and promises nothing more.

Where it deliberately stops (first cut, all four in the header)

  • Method and index signatures are counted and skipped, on both sides. packages/types/README.md's DataSource is entirely method signatures, so it resolves and is compared over zero keys — and the census says 1 compared over ZERO documented keys rather than letting it read as a verified declaration.
  • Property names only, never types. That half belongs to a compile tier.
  • X is resolved against the README's own package only. Widening to any workspace package pulls in packages/plugin-detail/README.md's DetailViewSchema (owned by @object-ui/types) at the 36 omissions above. That widening needs its own inheritance policy and its own card.
  • Nested declarations are not walked — a type declared inside a function body in an example is the example's scaffolding, not a claim about the surface.

Declaring a deliberate excerpt — two homes, two different claims

Mirroring the pair check-doc-snippet-types.mjs already runs (UNGATED_DOCS + FRAGMENT_MARKER), and for the same reason: they say different things.

  • PARTIAL_MARKER — an HTML comment in the README, above the fence, whose body reads readme-exports: partial GanttTask — reason. (Spelled with a placeholder here on purpose: an HTML comment written literally into a GitHub body does not survive to the reader. The exact literal is published as PARTIAL_MARKER_EXAMPLE in the script, a test asserts the regex accepts it, and the gate prints it in its own failure message — so the one place a reader meets it is the one place it is guaranteed correct.) It says this excerpt is deliberate. The grammar is deliberately the same family as FRAGMENT_MARKER (marker word, an em dash / double hyphen / colon, then a reason of at least 12 characters), so a reader who knows one knows the other. The verb differs because the claim doesdoc-snippet: fragment says a block cannot compile, and that must not double as permission to omit a key. Only the HTML spelling exists here: these are README.md files, not MDX.
    It names the interface, which FRAGMENT_MARKER has no need to do: packages/plugin-kanban/README.md declares two types in one block, and a marker that silenced a whole block would silence the neighbour nobody looked at. Pinned as a test.
  • PARTIAL_EXCERPTS — a ledger in the script. It says this is drift, owed to a content card.

Both suppress stale-omission only. Neither can hide a fabricated-key — an excerpt may leave a key out, it may not invent one, and there is no reading of "partial" under which a ledger should be able to hide that. Both are shrink-only and enforced as such: a marker or entry that suppressed nothing fails, so the lists cannot quietly stop shrinking.

An unbuilt package is a FAILURE on this side too

Tier 1's rule, kept, and it needed its own branch: with no export surface on disk every documented type resolves to nothing and would read as a serene local-declaration, so the pin would report green over blocks it never judged. Scoped exactly as the import side is — it fails only where the missing surface would have changed a verdict, i.e. where a block declares a type. Verdict unjudgeable-type, in the census, pinned by a test.

Non-vacuity

Three new FLOORS counters — typeDeclarations, typesResolved, keysCompared — so the pin walk can collapse and name itself instead of hiding behind a healthy import walk. Pinned by a three-leg independence test on its own fixture tree (a control leg proving nothing breaches while both walks are healthy, an exact-equality leg on the three pin counters alone, and a leg pinning that the package-side counters are unchanged) — the same shape, and the same exactness argument, as tier 1's.

Its own fixture tree, deliberately: tier 1's independence test asserts exportSymbolsexactly (toBe(4)), and adding exports to alpha to give this half something to compare would have turned that assertion into a number nobody chose.


The census, before and after

Before, at eb33a8d4c (this branch's base):

✅ check-readme-exports: OK (43 tracked README(s) under packages/ (0 outside any package), 407 fenced
block(s) (309 parsed as code, 6 untagged); 506 import binding(s), 386 of them self-imports judged (386
real, 0 wrong-path, 0 fabricated); 3292 export symbol(s) read from 37 of 40 tracked package(s) (39 carry
a README) (0 unbuilt, 3 declare no types); 17 side-effect import(s), 0 namespace, 11 deep self-path, 92
to other packages).

After, at 15a06cff3 (the head of this branch, after the merge of main) — the same line with the pin's half appended:

✅ check-readme-exports: OK (… 92 to other packages; 6 documented type(s) in 5 block(s) (4 resolve to a
shipped shape of their own package, 2 local, 0 not a property type, 0 unjudgeable), 52 key(s) compared
both ways (0 fabricated, 0 stale omission(s); 0 excerpt(s) declared by marker, 3 by ledger; 1 compared
over ZERO documented keys)).

Green at rest, with the counts that say what was skipped travelling beside the ones that say what was judged.

The exclusion ledger this opens with — three entries, two READMEs

Well under the ten-README ceiling the order set, so this ships rather than escalating. Every one is the stale-omission direction; zero fabricated keys anywhere in the tree.

entryomitsdisposition
packages/plugin-gantt/README.md::GanttTaskfields, hasOwnDatesgenuinely an excerpt — the prose immediately below the block already names both as populated by ObjectGantt itself. It wants the in-README marker, not this ledger.
packages/plugin-kanban/README.md::KanbanColumncollapsedstaleness — nothing on the page says the block is partial
packages/plugin-kanban/README.md::KanbanCardcardSubtitle, cardFieldCells, coverImagestaleness, same page, three keys behind

No README content is edited by this PR, including the marker GanttTask deserves: the order scoped this card to the gate, so all three are recorded here with the card number and filed as a content follow-up, #7302 (unassigned, finding). The kanban entry notes that #6155 — four disagreeing declarations of that pair — can move its omission set, because that set is derived from what the package exports, not copied into the entry.

scan takes an excerpts option so the ledger can be switched off, and a test asserts that with it off the repo reds with exactly these three and that every red is a stale-omission — so the ledger is provably not covering a fabricated key or anything else. That test is written to hold built and unbuilt, like tier 1's repo state block: on an unbuilt tree the same declarations are unjudgeable-type, which is the failure the rule requires and not a skip.


Ablation — seven legs, no tracked file ever mutated

Every leg runs against a scratch copy handed to the gate through tier 1's --readme REAL=SCRATCH override. Each plant is proved on disk by an anchored count before and after, never by an editor's exit code, and the runner carries a trap … EXIT INT TERM that deletes the scratch files and re-verifies the tracked README's blob.

The plant proof has its own negative control, run first: an anchor that is deliberately never written must be rejected. It was (PLANT NOT ON DISK … its reading is VOID, exit 9) — so the passes below mean something.

legplantedpredictedmeasured
controla correctly documented interface GanttMarkergreenEXIT=0, verdict matches
(i)fabricated key iconred, names itEXIT=1interface GanttMarker documents 'icon', not on the shipped type
(ii)renamelabel to captionred in both directions, same declarationEXIT=1documents 'caption'andomits 'label', both at :718
(iii)omitted shipped key colorredEXIT=1interface GanttMarker omits 'color'
(iv)the same omission + the partial markergreenEXIT=0, census 1 excerpt(s) declared by marker
(v)a fabricated key under a markerstill redEXIT=1 — the marker does not cover fabrication
(vi)a marker over a complete declarationred as staleEXIT=1declares an excerpt that omits nothing any more

Every leg names the README, the interface and the key. Re-run in full on the merged head e32824dae; the only commit after it (15a06cff3) edits three reason strings inside PARTIAL_EXCERPTS, none of which any leg above reads — every leg plants and judges GanttMarker, which the ledger does not name. The restore proof closes it:

restore-proof: packages/plugin-gantt/README.md blob db8293c38832626e497519279681184caf4c69cb
== HEAD blob db8293c38832626e497519279681184caf4c69cb

git status --porcelain was empty after the run.


Verification

All at the branch head 15a06cff3 unless a line says otherwise, gate verdicts quoted from the line each gate printed, every exit code captured by redirect before any pipe.

pnpm check:readme-exports EXIT=0 ✅ OK (census above)
pnpm type-check:scripts EXIT=0
pnpm check:control-bytes EXIT=0 ✅ OK (scanned 6008 tracked text file(s); skipped 85 binary)
pnpm check:entry-guard EXIT=0 ✓ 58 scripts/ file(s) — no entry guard outside the baseline
pnpm check:doc-fences EXIT=0 ✅ every TypeScript block in 224 document(s) …
node scripts/check-changeset-presence.mjs EXIT=0
node scripts/check-lint-coverage.mjs EXIT=0 ✅ 46/46 packages linted, 0 with outstanding errors
node scripts/check-type-check-coverage.mjs EXIT=0
pnpm lint:root EXIT=0 ✖ 29 problems (0 errors, 29 warnings) — all pre-existing
vitest run scripts/__tests__/check-readme-exports.test.ts EXIT=0 83 passed (83)

Root vitest only (#3378), never package-scoped. The suite grew 47 → 83 cases.

Authored by Claude Code in session session_01BGMDbrVa8JjZcCQ7DWYH1b (https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b) — written into the prose here because a footer does not survive a later edit of this body.

One gate went red and it was mine to fix.type-check:scripts reported five TS2353 on excerpts — the option existed at runtime and not in scan's JSDoc, which is what that gate reads as the declaration. Contract-first: the producer's declaration was the stale half, fixed there rather than loosened at the five call sites. The vitest suite was green through all of it, which is why the type gate is a separate reading and not a duplicate of it.

No changeset, and the gate says so in its own words:"2 file(s) changed, 0 of them published source of a package the release covers … no changeset is owed." Everything in this diff is under scripts/. Same disposition as #6212, which shipped scripts-only the same way; the skip-changeset label is deliberately not applied — in this repository that label is read by no workflow and exempts nothing, and the gate's verdict line is the authority.

Test scope, derived from disk not assumed.git grep -l 'check-readme-exports' across *.test.ts returns exactly one file: scripts/__tests__/check-readme-exports.test.ts. That is the whole set of suites that read anything this diff changes, and it was run in full on the final head.

Coverage of the type gate, measured rather than claimed.tsc -p tsconfig.scripts.json --listFiles reports 1 hit each for scripts/check-readme-exports.mjs and scripts/__tests__/check-readme-exports.test.ts — so "type-check is clean" is a statement about both edited files and not a true sentence about a population that excludes them.

Lint scope, stated rather than assumed — and this is the full run for this diff, not a narrowing.pnpm lint:root is eslint . with four ignore patterns; read from eslint's own config via --format json, its population is 226 files, 0 errors, 29 warnings, and both edited files are in it with zero messages. No type-aware linting is configured (no projectService, no parserOptions.project), so this diff cannot move the verdict of any untouched file. Every code file in the diff is under scripts/, i.e. entirely inside that population.

Declared narrowing

Two, both stated so a reader can price them:

  1. The repo-wide pnpm lint (turbo run lint, per package) was not run. Zero package source changed, and the three-part evidence above — population read from eslint's own config, file counts from --format json, and no type-aware linting — is what makes that a measurement rather than a skip.
  2. scripts/__tests__ was not run whole, only the one suite. Derived from disk, as above: no other test file reads any changed path. CI runs the directory regardless.

The authoritative reading is the CI job conclusions on this PR.

Merge, never a rebase

One merge of origin/main (eb33a8d4cec0a7b846), no conflicts. git diff --name-only across that range touches five files, all under scripts/, and zero under packages/ — so the built dist/ this gate reads is still exactly the merged tree's, and the census above is a reading about the tree being shipped rather than a stale one.

Follow-ups


Generated by Claude Code

…type, both ways
Tier 2 of objectui#5043, the half the card scoped as "bidirectional pins for
documented `interface` blocks". A README that writes out a type it also exports
is making a claim the tier-1 name check cannot reach: a standalone
`interface GanttTask { ... }` in a fenced block is a LOCAL declaration, so it
compiles green no matter what it says.
`scripts/check-readme-exports.mjs` now compares, for every fenced block that
declares `interface X { ... }` or `type X = { ... }` where `X` is an export of
that README's own package, the documented property names against the shipped
declaration's, in both directions:
fabricated-key a documented key the shipped type does not have, judged
against the FULL property set so an inherited key is not
reported as invented;
stale-omission a shipped key the block never mentions, judged against the
interface's OWN declared members so an excerpt of a type
with a large base is not stale for leaving the base alone.
One direction pins only half, and a RENAME is visible only as the pair -- the
new spelling as `fabricated-key`, the old one as `stale-omission` on the same
declaration. The header states the bound the card recorded: the typed-example
half cannot carry that class, because a property-level type error
short-circuits the missing-property detail.
Two ways to declare a deliberate excerpt, with different meanings: a
`PARTIAL_MARKER` HTML comment in the README (grammar deliberately in
`check-doc-snippet-types.mjs`'s `FRAGMENT_MARKER` family), and a
`PARTIAL_EXCERPTS` ledger here for drift owed to a content card. Both are
shrink-only and both suppress omissions ONLY -- neither can hide a fabricated
key. An unbuilt package is a FAILURE on this side too, never a serene
`local-declaration`, and three new floors let the pin walk collapse and name
itself independently of the import walk.
No new compiler: `check-doc-snippet-types.mjs` already collects every
`packages/<name>/README.md` and compiles the covered ones (39 in its surface, 8
compiled, 31 on its shrink-only ledger). Measured and written into the header.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
`type-check:scripts` reads the JSDoc as the declaration for this module, so the
new option existed at runtime and not in the contract — every test passing it
was a TS2353 while the suite itself was green. The producer's declaration was
the stale half; fixed there rather than loosened at the five call sites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
The ledger is a debt list, and a debt with no card is one nobody picks up.
Each entry now names objectui#7302 (the README fixes) beside objectui#6214
(the gate that recorded it), and the kanban pair notes that objectui#6155 can
move its omission set — that set is read from what the package exports, not
copied into the entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
…compared
`Test (shard 2/4)` went red on the interface pin's first CI run. Not a flake:
the test shards run `pnpm install` then `pnpm test` and NEVER build, so on CI
every documented type comes back `unjudgeable-type` — and the three real
`PARTIAL_EXCERPTS` entries then suppressed no omission, so the shrink-only
sweep reported all three as `stale-excerpt-entry`.
That is the wrong verdict, and wrong in the direction that costs most: it tells
a reader to DELETE a ledger entry that is still true. "This entry suppressed
nothing" has two causes that look identical from outside — the README caught up
(stale), or the declaration could not be judged at all (still true) — and they
must not share a verdict.
So a declaration that resolves to `unjudgeable-type` now SUSPENDS the rule for
its ledger entry and its marker rather than satisfying it. Both are counted as
`not judged` in the census, and the run still FAILS — under `unjudgeable-type`,
which names the real problem. The counter increments only where an excerpt was
actually declared, so the census means "claims this run could not check" and
not "declarations it could not judge", which `typesUnjudgeable` already says.
Tests: four fixture legs, on the fixture tree so they hold in both build states
— the ledger case, the marker case, the no-excerpt case pinning the counter's
meaning, and a MUST-FAIL CONTROL asserting the same entry on a built tree is
still reported stale. Without that control all three would also pass if the
suspension had swallowed the rule outright, which is the opposite defect and
the more expensive one: a ledger that can never shrink again.
Tier 1's `repo state` test filtered `!== 'unjudgeable'` to mean "could not
judge". That class has two verdicts now, so the filter names both — the
assertion that the unbuilt tree must still FAIL is unchanged — and its unbuilt
branch now asserts zero stale-excerpt and zero stale-marker rows by name, so
this regression cannot come back silently.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(scripts): pin documented interface blocks against the shipped type, both ways - #7306

Merged
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins
Sep 2, 2026
Merged

feat(scripts): pin documented interface blocks against the shipped type, both ways#7306
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6214

Tier 2 of #5043, and not the shape the card assumed. The card asked for two things — compile the README code blocks, and add bidirectional pins for documented interface blocks. The two measurements the unlock comment left owed were taken first, and the first of them says the compile half already has a gate. So this PR is the pin, and the compile half is reported rather than rebuilt.

Both measurements are below with their numbers, because both change what got built.


Measurement (a) — the compile half is already check:doc-snippets' job

scripts/check-doc-snippet-types.mjs does not scan content/docs only. Its collector walks that tree and every packages/PKG/README.md (the listDocuments walk, and the scan surface is stated in its header in the same breath as the coverage rule). Coverage there is declared, never assumed: a document is compiled unless it is named in UNGATED_DOCS with a reason, and that ledger is shrink-only — an entry naming a file that does not exist, or that holds no ts/tsx block, fails as stale.

Measured on this branch's base:

count
package READMEs in that gate's scan surface39
of them compiled today8 (cli, components, create-plugin, plugin-grid, plugin-timeline, runner, test-support, vscode-extension)
of them on the UNGATED_DOCS debt ledger, each with a reason31
total entries on that ledger43 (31 READMEs + 12 .mdx pages)

#5174's batches are burning that ledger down — its header records batch 1 clearing 90 diagnostics, batch 2 eighty-nine, batch 3 eighty-one, batch 4 one hundred and four, and batch 5 is live on content/docs right now. So a second README compiler here would duplicate a working gate and split its ledger in two, and every README it brought under itself would then have two places to declare a fragment. Not built, deliberately; the reasoning is in this gate's header under "Compiling the blocks" so the next reader does not re-derive it.

One honest gap that measurement exposes, recorded and not fixed here: the doc-snippets collector reads packages/NAME/README.md at the package root only, so the four nested READMEs this gate walks (packages/types/src/zod/README.md and friends) are outside its surface entirely — neither covered nor on its ledger. That is #5174's own class of finding, one directory deeper, and it belongs to that gate.

Measurement (b) — the tier-2 baseline, re-taken on the merged ref

The card was blocked on this: "Running all 15 compilable plugin-gantt blocks under strict, after #5012 landed, still exits rc=2: two TS7006."#5057 has since reached completed state, by way of merged PR #5900 and the README moved again in 231d1b93c, so the figure was re-taken with the existing instrumentcheck-doc-snippet-types.mjs's own analyze + compileSnippets, with plugin-gantt lifted out of UNGATED_DOCS — rather than a new compiler:

targets: packages/plugin-gantt/README.md
blocks to compile: 18 | declared fragments: 0
parse failures on targets: 2
semantic-failing blocks on targets: 5
total diagnostics on targets: 13 {"TS2304":7,"TS7006":1,"TS7031":2,"TS2552":1,"TS2322":2}

It does not compile clean, and the shape of the red moved. The filed rc=2 / two TS7006 is now one TS7006 plus two TS7031 (the same unannotated-callback class, redistributed), and the bulk is a different family: seven TS2304 from blocks that are deliberately-not-valid property sketches ({ type, tasks: … }, bare dependencies: [ … ]), which is precisely the case FRAGMENT_MARKER exists for over there. Two blocks do not even parse. One diagnostic is real drift a compile tier would catch — TS2322: Type '"gantt"' is not assignable to type '"object-gantt"'.

Under the card's clause — "decide explicitly whether to list it as excluded or add a class, don't let it fall out silently" — this is listed, not inherited: packages/plugin-gantt/README.md stays on UNGATED_DOCS (untouched by this PR, it is #5174's file) and the number above is on the record. No shrink-only exclusion entry is added here for it, because this PR adds no compile pass for it to be excluded from.


What this PR builds: the bidirectional interface pin

A README that writes out a type it also exports makes a claim tier 1's name check cannot reach. The card records why, and the pin is built on it:

A documented interface block compiles green no matter what it says. A standalone interface GanttTask { … } in a README is a local declaration unrelated to the real type; feeding it to tsc as-is proves nothing.

So for every fenced block that declares interface X { … } or type X = { … } where X is an export of that README's own package, the documented property names are compared against the shipped declaration's, in both directions:

verdictdirectionjudged against
fabricated-keydocumented, shipped lacks itthe type's full property set, inherited members included
stale-omissionshipped, documented never mentions itthe interface's own declared members only

The asymmetry is the design, not a shortcut. Documenting a key that arrives through extends is correct, so the doc side is judged leniently; an excerpt of a type with a large base is not stale for leaving the base's keys to the base's own page, so the shipped side is judged narrowly. Measured on the real tree: judging the shipped side against all properties instead would report 36 omissions on DetailViewSchema alone, most of them inherited BaseSchema members.

One direction pins only half, which is why both are here — and a rename is visible only as the pair, the new spelling as fabricated-key and the old one as stale-omission on the same declaration and the same line. No single direction reports it.

The bound, stated in the script header because it is narrow

The card's other measurement is written into the header verbatim in substance: the typed-example half cannot carry the key-rename class, because a property-level type error short-circuits the missing-property detail — const task: GanttTask = { name: …, start: '2024-01-01' } reports two TS2322 and never that title is gone. This pin is what covers renames on interface blocks, and the header says so and promises nothing more.

Where it deliberately stops (first cut, all four in the header)

  • Method and index signatures are counted and skipped, on both sides. packages/types/README.md's DataSource is entirely method signatures, so it resolves and is compared over zero keys — and the census says 1 compared over ZERO documented keys rather than letting it read as a verified declaration.
  • Property names only, never types. That half belongs to a compile tier.
  • X is resolved against the README's own package only. Widening to any workspace package pulls in packages/plugin-detail/README.md's DetailViewSchema (owned by @object-ui/types) at the 36 omissions above. That widening needs its own inheritance policy and its own card.
  • Nested declarations are not walked — a type declared inside a function body in an example is the example's scaffolding, not a claim about the surface.

Declaring a deliberate excerpt — two homes, two different claims

Mirroring the pair check-doc-snippet-types.mjs already runs (UNGATED_DOCS + FRAGMENT_MARKER), and for the same reason: they say different things.

  • PARTIAL_MARKER — an HTML comment in the README, above the fence, whose body reads readme-exports: partial GanttTask — reason. (Spelled with a placeholder here on purpose: an HTML comment written literally into a GitHub body does not survive to the reader. The exact literal is published as PARTIAL_MARKER_EXAMPLE in the script, a test asserts the regex accepts it, and the gate prints it in its own failure message — so the one place a reader meets it is the one place it is guaranteed correct.) It says this excerpt is deliberate. The grammar is deliberately the same family as FRAGMENT_MARKER (marker word, an em dash / double hyphen / colon, then a reason of at least 12 characters), so a reader who knows one knows the other. The verb differs because the claim doesdoc-snippet: fragment says a block cannot compile, and that must not double as permission to omit a key. Only the HTML spelling exists here: these are README.md files, not MDX.
    It names the interface, which FRAGMENT_MARKER has no need to do: packages/plugin-kanban/README.md declares two types in one block, and a marker that silenced a whole block would silence the neighbour nobody looked at. Pinned as a test.
  • PARTIAL_EXCERPTS — a ledger in the script. It says this is drift, owed to a content card.

Both suppress stale-omission only. Neither can hide a fabricated-key — an excerpt may leave a key out, it may not invent one, and there is no reading of "partial" under which a ledger should be able to hide that. Both are shrink-only and enforced as such: a marker or entry that suppressed nothing fails, so the lists cannot quietly stop shrinking.

An unbuilt package is a FAILURE on this side too

Tier 1's rule, kept, and it needed its own branch: with no export surface on disk every documented type resolves to nothing and would read as a serene local-declaration, so the pin would report green over blocks it never judged. Scoped exactly as the import side is — it fails only where the missing surface would have changed a verdict, i.e. where a block declares a type. Verdict unjudgeable-type, in the census, pinned by a test.

Non-vacuity

Three new FLOORS counters — typeDeclarations, typesResolved, keysCompared — so the pin walk can collapse and name itself instead of hiding behind a healthy import walk. Pinned by a three-leg independence test on its own fixture tree (a control leg proving nothing breaches while both walks are healthy, an exact-equality leg on the three pin counters alone, and a leg pinning that the package-side counters are unchanged) — the same shape, and the same exactness argument, as tier 1's.

Its own fixture tree, deliberately: tier 1's independence test asserts exportSymbolsexactly (toBe(4)), and adding exports to alpha to give this half something to compare would have turned that assertion into a number nobody chose.


The census, before and after

Before, at eb33a8d4c (this branch's base):

✅ check-readme-exports: OK (43 tracked README(s) under packages/ (0 outside any package), 407 fenced
block(s) (309 parsed as code, 6 untagged); 506 import binding(s), 386 of them self-imports judged (386
real, 0 wrong-path, 0 fabricated); 3292 export symbol(s) read from 37 of 40 tracked package(s) (39 carry
a README) (0 unbuilt, 3 declare no types); 17 side-effect import(s), 0 namespace, 11 deep self-path, 92
to other packages).

After, at 15a06cff3 (the head of this branch, after the merge of main) — the same line with the pin's half appended:

✅ check-readme-exports: OK (… 92 to other packages; 6 documented type(s) in 5 block(s) (4 resolve to a
shipped shape of their own package, 2 local, 0 not a property type, 0 unjudgeable), 52 key(s) compared
both ways (0 fabricated, 0 stale omission(s); 0 excerpt(s) declared by marker, 3 by ledger; 1 compared
over ZERO documented keys)).

Green at rest, with the counts that say what was skipped travelling beside the ones that say what was judged.

The exclusion ledger this opens with — three entries, two READMEs

Well under the ten-README ceiling the order set, so this ships rather than escalating. Every one is the stale-omission direction; zero fabricated keys anywhere in the tree.

entryomitsdisposition
packages/plugin-gantt/README.md::GanttTaskfields, hasOwnDatesgenuinely an excerpt — the prose immediately below the block already names both as populated by ObjectGantt itself. It wants the in-README marker, not this ledger.
packages/plugin-kanban/README.md::KanbanColumncollapsedstaleness — nothing on the page says the block is partial
packages/plugin-kanban/README.md::KanbanCardcardSubtitle, cardFieldCells, coverImagestaleness, same page, three keys behind

No README content is edited by this PR, including the marker GanttTask deserves: the order scoped this card to the gate, so all three are recorded here with the card number and filed as a content follow-up, #7302 (unassigned, finding). The kanban entry notes that #6155 — four disagreeing declarations of that pair — can move its omission set, because that set is derived from what the package exports, not copied into the entry.

scan takes an excerpts option so the ledger can be switched off, and a test asserts that with it off the repo reds with exactly these three and that every red is a stale-omission — so the ledger is provably not covering a fabricated key or anything else. That test is written to hold built and unbuilt, like tier 1's repo state block: on an unbuilt tree the same declarations are unjudgeable-type, which is the failure the rule requires and not a skip.


Ablation — seven legs, no tracked file ever mutated

Every leg runs against a scratch copy handed to the gate through tier 1's --readme REAL=SCRATCH override. Each plant is proved on disk by an anchored count before and after, never by an editor's exit code, and the runner carries a trap … EXIT INT TERM that deletes the scratch files and re-verifies the tracked README's blob.

The plant proof has its own negative control, run first: an anchor that is deliberately never written must be rejected. It was (PLANT NOT ON DISK … its reading is VOID, exit 9) — so the passes below mean something.

legplantedpredictedmeasured
controla correctly documented interface GanttMarkergreenEXIT=0, verdict matches
(i)fabricated key iconred, names itEXIT=1interface GanttMarker documents 'icon', not on the shipped type
(ii)renamelabel to captionred in both directions, same declarationEXIT=1documents 'caption'andomits 'label', both at :718
(iii)omitted shipped key colorredEXIT=1interface GanttMarker omits 'color'
(iv)the same omission + the partial markergreenEXIT=0, census 1 excerpt(s) declared by marker
(v)a fabricated key under a markerstill redEXIT=1 — the marker does not cover fabrication
(vi)a marker over a complete declarationred as staleEXIT=1declares an excerpt that omits nothing any more

Every leg names the README, the interface and the key. Re-run in full on the merged head e32824dae; the only commit after it (15a06cff3) edits three reason strings inside PARTIAL_EXCERPTS, none of which any leg above reads — every leg plants and judges GanttMarker, which the ledger does not name. The restore proof closes it:

restore-proof: packages/plugin-gantt/README.md blob db8293c38832626e497519279681184caf4c69cb
== HEAD blob db8293c38832626e497519279681184caf4c69cb

git status --porcelain was empty after the run.


Verification

All at the branch head 15a06cff3 unless a line says otherwise, gate verdicts quoted from the line each gate printed, every exit code captured by redirect before any pipe.

pnpm check:readme-exports EXIT=0 ✅ OK (census above)
pnpm type-check:scripts EXIT=0
pnpm check:control-bytes EXIT=0 ✅ OK (scanned 6008 tracked text file(s); skipped 85 binary)
pnpm check:entry-guard EXIT=0 ✓ 58 scripts/ file(s) — no entry guard outside the baseline
pnpm check:doc-fences EXIT=0 ✅ every TypeScript block in 224 document(s) …
node scripts/check-changeset-presence.mjs EXIT=0
node scripts/check-lint-coverage.mjs EXIT=0 ✅ 46/46 packages linted, 0 with outstanding errors
node scripts/check-type-check-coverage.mjs EXIT=0
pnpm lint:root EXIT=0 ✖ 29 problems (0 errors, 29 warnings) — all pre-existing
vitest run scripts/__tests__/check-readme-exports.test.ts EXIT=0 83 passed (83)

Root vitest only (#3378), never package-scoped. The suite grew 47 → 83 cases.

Authored by Claude Code in session session_01BGMDbrVa8JjZcCQ7DWYH1b (https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b) — written into the prose here because a footer does not survive a later edit of this body.

One gate went red and it was mine to fix.type-check:scripts reported five TS2353 on excerpts — the option existed at runtime and not in scan's JSDoc, which is what that gate reads as the declaration. Contract-first: the producer's declaration was the stale half, fixed there rather than loosened at the five call sites. The vitest suite was green through all of it, which is why the type gate is a separate reading and not a duplicate of it.

No changeset, and the gate says so in its own words:"2 file(s) changed, 0 of them published source of a package the release covers … no changeset is owed." Everything in this diff is under scripts/. Same disposition as #6212, which shipped scripts-only the same way; the skip-changeset label is deliberately not applied — in this repository that label is read by no workflow and exempts nothing, and the gate's verdict line is the authority.

Test scope, derived from disk not assumed.git grep -l 'check-readme-exports' across *.test.ts returns exactly one file: scripts/__tests__/check-readme-exports.test.ts. That is the whole set of suites that read anything this diff changes, and it was run in full on the final head.

Coverage of the type gate, measured rather than claimed.tsc -p tsconfig.scripts.json --listFiles reports 1 hit each for scripts/check-readme-exports.mjs and scripts/__tests__/check-readme-exports.test.ts — so "type-check is clean" is a statement about both edited files and not a true sentence about a population that excludes them.

Lint scope, stated rather than assumed — and this is the full run for this diff, not a narrowing.pnpm lint:root is eslint . with four ignore patterns; read from eslint's own config via --format json, its population is 226 files, 0 errors, 29 warnings, and both edited files are in it with zero messages. No type-aware linting is configured (no projectService, no parserOptions.project), so this diff cannot move the verdict of any untouched file. Every code file in the diff is under scripts/, i.e. entirely inside that population.

Declared narrowing

Two, both stated so a reader can price them:

  1. The repo-wide pnpm lint (turbo run lint, per package) was not run. Zero package source changed, and the three-part evidence above — population read from eslint's own config, file counts from --format json, and no type-aware linting — is what makes that a measurement rather than a skip.
  2. scripts/__tests__ was not run whole, only the one suite. Derived from disk, as above: no other test file reads any changed path. CI runs the directory regardless.

The authoritative reading is the CI job conclusions on this PR.

Merge, never a rebase

One merge of origin/main (eb33a8d4cec0a7b846), no conflicts. git diff --name-only across that range touches five files, all under scripts/, and zero under packages/ — so the built dist/ this gate reads is still exactly the merged tree's, and the census above is a reading about the tree being shipped rather than a stale one.

Follow-ups


Generated by Claude Code

…type, both ways
Tier 2 of objectui#5043, the half the card scoped as "bidirectional pins for
documented `interface` blocks". A README that writes out a type it also exports
is making a claim the tier-1 name check cannot reach: a standalone
`interface GanttTask { ... }` in a fenced block is a LOCAL declaration, so it
compiles green no matter what it says.
`scripts/check-readme-exports.mjs` now compares, for every fenced block that
declares `interface X { ... }` or `type X = { ... }` where `X` is an export of
that README's own package, the documented property names against the shipped
declaration's, in both directions:
fabricated-key a documented key the shipped type does not have, judged
against the FULL property set so an inherited key is not
reported as invented;
stale-omission a shipped key the block never mentions, judged against the
interface's OWN declared members so an excerpt of a type
with a large base is not stale for leaving the base alone.
One direction pins only half, and a RENAME is visible only as the pair -- the
new spelling as `fabricated-key`, the old one as `stale-omission` on the same
declaration. The header states the bound the card recorded: the typed-example
half cannot carry that class, because a property-level type error
short-circuits the missing-property detail.
Two ways to declare a deliberate excerpt, with different meanings: a
`PARTIAL_MARKER` HTML comment in the README (grammar deliberately in
`check-doc-snippet-types.mjs`'s `FRAGMENT_MARKER` family), and a
`PARTIAL_EXCERPTS` ledger here for drift owed to a content card. Both are
shrink-only and both suppress omissions ONLY -- neither can hide a fabricated
key. An unbuilt package is a FAILURE on this side too, never a serene
`local-declaration`, and three new floors let the pin walk collapse and name
itself independently of the import walk.
No new compiler: `check-doc-snippet-types.mjs` already collects every
`packages/<name>/README.md` and compiles the covered ones (39 in its surface, 8
compiled, 31 on its shrink-only ledger). Measured and written into the header.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
`type-check:scripts` reads the JSDoc as the declaration for this module, so the
new option existed at runtime and not in the contract — every test passing it
was a TS2353 while the suite itself was green. The producer's declaration was
the stale half; fixed there rather than loosened at the five call sites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
The ledger is a debt list, and a debt with no card is one nobody picks up.
Each entry now names objectui#7302 (the README fixes) beside objectui#6214
(the gate that recorded it), and the kanban pair notes that objectui#6155 can
move its omission set — that set is read from what the package exports, not
copied into the entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
…compared
`Test (shard 2/4)` went red on the interface pin's first CI run. Not a flake:
the test shards run `pnpm install` then `pnpm test` and NEVER build, so on CI
every documented type comes back `unjudgeable-type` — and the three real
`PARTIAL_EXCERPTS` entries then suppressed no omission, so the shrink-only
sweep reported all three as `stale-excerpt-entry`.
That is the wrong verdict, and wrong in the direction that costs most: it tells
a reader to DELETE a ledger entry that is still true. "This entry suppressed
nothing" has two causes that look identical from outside — the README caught up
(stale), or the declaration could not be judged at all (still true) — and they
must not share a verdict.
So a declaration that resolves to `unjudgeable-type` now SUSPENDS the rule for
its ledger entry and its marker rather than satisfying it. Both are counted as
`not judged` in the census, and the run still FAILS — under `unjudgeable-type`,
which names the real problem. The counter increments only where an excerpt was
actually declared, so the census means "claims this run could not check" and
not "declarations it could not judge", which `typesUnjudgeable` already says.
Tests: four fixture legs, on the fixture tree so they hold in both build states
— the ledger case, the marker case, the no-excerpt case pinning the counter's
meaning, and a MUST-FAIL CONTROL asserting the same entry on a built tree is
still reported stale. Without that control all three would also pass if the
suspension had swallowed the rule outright, which is the opposite defect and
the more expensive one: a ledger that can never shrink again.
Tier 1's `repo state` test filtered `!== 'unjudgeable'` to mean "could not
judge". That class has two verdicts now, so the filter names both — the
assertion that the unbuilt tree must still FAIL is unchanged — and its unbuilt
branch now asserts zero stale-excerpt and zero stale-marker rows by name, so
this regression cannot come back silently.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(scripts): pin documented interface blocks against the shipped type, both ways - #7306

Merged
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins
Sep 2, 2026
Merged

feat(scripts): pin documented interface blocks against the shipped type, both ways#7306
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6214

Tier 2 of #5043, and not the shape the card assumed. The card asked for two things — compile the README code blocks, and add bidirectional pins for documented interface blocks. The two measurements the unlock comment left owed were taken first, and the first of them says the compile half already has a gate. So this PR is the pin, and the compile half is reported rather than rebuilt.

Both measurements are below with their numbers, because both change what got built.


Measurement (a) — the compile half is already check:doc-snippets' job

scripts/check-doc-snippet-types.mjs does not scan content/docs only. Its collector walks that tree and every packages/PKG/README.md (the listDocuments walk, and the scan surface is stated in its header in the same breath as the coverage rule). Coverage there is declared, never assumed: a document is compiled unless it is named in UNGATED_DOCS with a reason, and that ledger is shrink-only — an entry naming a file that does not exist, or that holds no ts/tsx block, fails as stale.

Measured on this branch's base:

count
package READMEs in that gate's scan surface39
of them compiled today8 (cli, components, create-plugin, plugin-grid, plugin-timeline, runner, test-support, vscode-extension)
of them on the UNGATED_DOCS debt ledger, each with a reason31
total entries on that ledger43 (31 READMEs + 12 .mdx pages)

#5174's batches are burning that ledger down — its header records batch 1 clearing 90 diagnostics, batch 2 eighty-nine, batch 3 eighty-one, batch 4 one hundred and four, and batch 5 is live on content/docs right now. So a second README compiler here would duplicate a working gate and split its ledger in two, and every README it brought under itself would then have two places to declare a fragment. Not built, deliberately; the reasoning is in this gate's header under "Compiling the blocks" so the next reader does not re-derive it.

One honest gap that measurement exposes, recorded and not fixed here: the doc-snippets collector reads packages/NAME/README.md at the package root only, so the four nested READMEs this gate walks (packages/types/src/zod/README.md and friends) are outside its surface entirely — neither covered nor on its ledger. That is #5174's own class of finding, one directory deeper, and it belongs to that gate.

Measurement (b) — the tier-2 baseline, re-taken on the merged ref

The card was blocked on this: "Running all 15 compilable plugin-gantt blocks under strict, after #5012 landed, still exits rc=2: two TS7006."#5057 has since reached completed state, by way of merged PR #5900 and the README moved again in 231d1b93c, so the figure was re-taken with the existing instrumentcheck-doc-snippet-types.mjs's own analyze + compileSnippets, with plugin-gantt lifted out of UNGATED_DOCS — rather than a new compiler:

targets: packages/plugin-gantt/README.md
blocks to compile: 18 | declared fragments: 0
parse failures on targets: 2
semantic-failing blocks on targets: 5
total diagnostics on targets: 13 {"TS2304":7,"TS7006":1,"TS7031":2,"TS2552":1,"TS2322":2}

It does not compile clean, and the shape of the red moved. The filed rc=2 / two TS7006 is now one TS7006 plus two TS7031 (the same unannotated-callback class, redistributed), and the bulk is a different family: seven TS2304 from blocks that are deliberately-not-valid property sketches ({ type, tasks: … }, bare dependencies: [ … ]), which is precisely the case FRAGMENT_MARKER exists for over there. Two blocks do not even parse. One diagnostic is real drift a compile tier would catch — TS2322: Type '"gantt"' is not assignable to type '"object-gantt"'.

Under the card's clause — "decide explicitly whether to list it as excluded or add a class, don't let it fall out silently" — this is listed, not inherited: packages/plugin-gantt/README.md stays on UNGATED_DOCS (untouched by this PR, it is #5174's file) and the number above is on the record. No shrink-only exclusion entry is added here for it, because this PR adds no compile pass for it to be excluded from.


What this PR builds: the bidirectional interface pin

A README that writes out a type it also exports makes a claim tier 1's name check cannot reach. The card records why, and the pin is built on it:

A documented interface block compiles green no matter what it says. A standalone interface GanttTask { … } in a README is a local declaration unrelated to the real type; feeding it to tsc as-is proves nothing.

So for every fenced block that declares interface X { … } or type X = { … } where X is an export of that README's own package, the documented property names are compared against the shipped declaration's, in both directions:

verdictdirectionjudged against
fabricated-keydocumented, shipped lacks itthe type's full property set, inherited members included
stale-omissionshipped, documented never mentions itthe interface's own declared members only

The asymmetry is the design, not a shortcut. Documenting a key that arrives through extends is correct, so the doc side is judged leniently; an excerpt of a type with a large base is not stale for leaving the base's keys to the base's own page, so the shipped side is judged narrowly. Measured on the real tree: judging the shipped side against all properties instead would report 36 omissions on DetailViewSchema alone, most of them inherited BaseSchema members.

One direction pins only half, which is why both are here — and a rename is visible only as the pair, the new spelling as fabricated-key and the old one as stale-omission on the same declaration and the same line. No single direction reports it.

The bound, stated in the script header because it is narrow

The card's other measurement is written into the header verbatim in substance: the typed-example half cannot carry the key-rename class, because a property-level type error short-circuits the missing-property detail — const task: GanttTask = { name: …, start: '2024-01-01' } reports two TS2322 and never that title is gone. This pin is what covers renames on interface blocks, and the header says so and promises nothing more.

Where it deliberately stops (first cut, all four in the header)

  • Method and index signatures are counted and skipped, on both sides. packages/types/README.md's DataSource is entirely method signatures, so it resolves and is compared over zero keys — and the census says 1 compared over ZERO documented keys rather than letting it read as a verified declaration.
  • Property names only, never types. That half belongs to a compile tier.
  • X is resolved against the README's own package only. Widening to any workspace package pulls in packages/plugin-detail/README.md's DetailViewSchema (owned by @object-ui/types) at the 36 omissions above. That widening needs its own inheritance policy and its own card.
  • Nested declarations are not walked — a type declared inside a function body in an example is the example's scaffolding, not a claim about the surface.

Declaring a deliberate excerpt — two homes, two different claims

Mirroring the pair check-doc-snippet-types.mjs already runs (UNGATED_DOCS + FRAGMENT_MARKER), and for the same reason: they say different things.

  • PARTIAL_MARKER — an HTML comment in the README, above the fence, whose body reads readme-exports: partial GanttTask — reason. (Spelled with a placeholder here on purpose: an HTML comment written literally into a GitHub body does not survive to the reader. The exact literal is published as PARTIAL_MARKER_EXAMPLE in the script, a test asserts the regex accepts it, and the gate prints it in its own failure message — so the one place a reader meets it is the one place it is guaranteed correct.) It says this excerpt is deliberate. The grammar is deliberately the same family as FRAGMENT_MARKER (marker word, an em dash / double hyphen / colon, then a reason of at least 12 characters), so a reader who knows one knows the other. The verb differs because the claim doesdoc-snippet: fragment says a block cannot compile, and that must not double as permission to omit a key. Only the HTML spelling exists here: these are README.md files, not MDX.
    It names the interface, which FRAGMENT_MARKER has no need to do: packages/plugin-kanban/README.md declares two types in one block, and a marker that silenced a whole block would silence the neighbour nobody looked at. Pinned as a test.
  • PARTIAL_EXCERPTS — a ledger in the script. It says this is drift, owed to a content card.

Both suppress stale-omission only. Neither can hide a fabricated-key — an excerpt may leave a key out, it may not invent one, and there is no reading of "partial" under which a ledger should be able to hide that. Both are shrink-only and enforced as such: a marker or entry that suppressed nothing fails, so the lists cannot quietly stop shrinking.

An unbuilt package is a FAILURE on this side too

Tier 1's rule, kept, and it needed its own branch: with no export surface on disk every documented type resolves to nothing and would read as a serene local-declaration, so the pin would report green over blocks it never judged. Scoped exactly as the import side is — it fails only where the missing surface would have changed a verdict, i.e. where a block declares a type. Verdict unjudgeable-type, in the census, pinned by a test.

Non-vacuity

Three new FLOORS counters — typeDeclarations, typesResolved, keysCompared — so the pin walk can collapse and name itself instead of hiding behind a healthy import walk. Pinned by a three-leg independence test on its own fixture tree (a control leg proving nothing breaches while both walks are healthy, an exact-equality leg on the three pin counters alone, and a leg pinning that the package-side counters are unchanged) — the same shape, and the same exactness argument, as tier 1's.

Its own fixture tree, deliberately: tier 1's independence test asserts exportSymbolsexactly (toBe(4)), and adding exports to alpha to give this half something to compare would have turned that assertion into a number nobody chose.


The census, before and after

Before, at eb33a8d4c (this branch's base):

✅ check-readme-exports: OK (43 tracked README(s) under packages/ (0 outside any package), 407 fenced
block(s) (309 parsed as code, 6 untagged); 506 import binding(s), 386 of them self-imports judged (386
real, 0 wrong-path, 0 fabricated); 3292 export symbol(s) read from 37 of 40 tracked package(s) (39 carry
a README) (0 unbuilt, 3 declare no types); 17 side-effect import(s), 0 namespace, 11 deep self-path, 92
to other packages).

After, at 15a06cff3 (the head of this branch, after the merge of main) — the same line with the pin's half appended:

✅ check-readme-exports: OK (… 92 to other packages; 6 documented type(s) in 5 block(s) (4 resolve to a
shipped shape of their own package, 2 local, 0 not a property type, 0 unjudgeable), 52 key(s) compared
both ways (0 fabricated, 0 stale omission(s); 0 excerpt(s) declared by marker, 3 by ledger; 1 compared
over ZERO documented keys)).

Green at rest, with the counts that say what was skipped travelling beside the ones that say what was judged.

The exclusion ledger this opens with — three entries, two READMEs

Well under the ten-README ceiling the order set, so this ships rather than escalating. Every one is the stale-omission direction; zero fabricated keys anywhere in the tree.

entryomitsdisposition
packages/plugin-gantt/README.md::GanttTaskfields, hasOwnDatesgenuinely an excerpt — the prose immediately below the block already names both as populated by ObjectGantt itself. It wants the in-README marker, not this ledger.
packages/plugin-kanban/README.md::KanbanColumncollapsedstaleness — nothing on the page says the block is partial
packages/plugin-kanban/README.md::KanbanCardcardSubtitle, cardFieldCells, coverImagestaleness, same page, three keys behind

No README content is edited by this PR, including the marker GanttTask deserves: the order scoped this card to the gate, so all three are recorded here with the card number and filed as a content follow-up, #7302 (unassigned, finding). The kanban entry notes that #6155 — four disagreeing declarations of that pair — can move its omission set, because that set is derived from what the package exports, not copied into the entry.

scan takes an excerpts option so the ledger can be switched off, and a test asserts that with it off the repo reds with exactly these three and that every red is a stale-omission — so the ledger is provably not covering a fabricated key or anything else. That test is written to hold built and unbuilt, like tier 1's repo state block: on an unbuilt tree the same declarations are unjudgeable-type, which is the failure the rule requires and not a skip.


Ablation — seven legs, no tracked file ever mutated

Every leg runs against a scratch copy handed to the gate through tier 1's --readme REAL=SCRATCH override. Each plant is proved on disk by an anchored count before and after, never by an editor's exit code, and the runner carries a trap … EXIT INT TERM that deletes the scratch files and re-verifies the tracked README's blob.

The plant proof has its own negative control, run first: an anchor that is deliberately never written must be rejected. It was (PLANT NOT ON DISK … its reading is VOID, exit 9) — so the passes below mean something.

legplantedpredictedmeasured
controla correctly documented interface GanttMarkergreenEXIT=0, verdict matches
(i)fabricated key iconred, names itEXIT=1interface GanttMarker documents 'icon', not on the shipped type
(ii)renamelabel to captionred in both directions, same declarationEXIT=1documents 'caption'andomits 'label', both at :718
(iii)omitted shipped key colorredEXIT=1interface GanttMarker omits 'color'
(iv)the same omission + the partial markergreenEXIT=0, census 1 excerpt(s) declared by marker
(v)a fabricated key under a markerstill redEXIT=1 — the marker does not cover fabrication
(vi)a marker over a complete declarationred as staleEXIT=1declares an excerpt that omits nothing any more

Every leg names the README, the interface and the key. Re-run in full on the merged head e32824dae; the only commit after it (15a06cff3) edits three reason strings inside PARTIAL_EXCERPTS, none of which any leg above reads — every leg plants and judges GanttMarker, which the ledger does not name. The restore proof closes it:

restore-proof: packages/plugin-gantt/README.md blob db8293c38832626e497519279681184caf4c69cb
== HEAD blob db8293c38832626e497519279681184caf4c69cb

git status --porcelain was empty after the run.


Verification

All at the branch head 15a06cff3 unless a line says otherwise, gate verdicts quoted from the line each gate printed, every exit code captured by redirect before any pipe.

pnpm check:readme-exports EXIT=0 ✅ OK (census above)
pnpm type-check:scripts EXIT=0
pnpm check:control-bytes EXIT=0 ✅ OK (scanned 6008 tracked text file(s); skipped 85 binary)
pnpm check:entry-guard EXIT=0 ✓ 58 scripts/ file(s) — no entry guard outside the baseline
pnpm check:doc-fences EXIT=0 ✅ every TypeScript block in 224 document(s) …
node scripts/check-changeset-presence.mjs EXIT=0
node scripts/check-lint-coverage.mjs EXIT=0 ✅ 46/46 packages linted, 0 with outstanding errors
node scripts/check-type-check-coverage.mjs EXIT=0
pnpm lint:root EXIT=0 ✖ 29 problems (0 errors, 29 warnings) — all pre-existing
vitest run scripts/__tests__/check-readme-exports.test.ts EXIT=0 83 passed (83)

Root vitest only (#3378), never package-scoped. The suite grew 47 → 83 cases.

Authored by Claude Code in session session_01BGMDbrVa8JjZcCQ7DWYH1b (https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b) — written into the prose here because a footer does not survive a later edit of this body.

One gate went red and it was mine to fix.type-check:scripts reported five TS2353 on excerpts — the option existed at runtime and not in scan's JSDoc, which is what that gate reads as the declaration. Contract-first: the producer's declaration was the stale half, fixed there rather than loosened at the five call sites. The vitest suite was green through all of it, which is why the type gate is a separate reading and not a duplicate of it.

No changeset, and the gate says so in its own words:"2 file(s) changed, 0 of them published source of a package the release covers … no changeset is owed." Everything in this diff is under scripts/. Same disposition as #6212, which shipped scripts-only the same way; the skip-changeset label is deliberately not applied — in this repository that label is read by no workflow and exempts nothing, and the gate's verdict line is the authority.

Test scope, derived from disk not assumed.git grep -l 'check-readme-exports' across *.test.ts returns exactly one file: scripts/__tests__/check-readme-exports.test.ts. That is the whole set of suites that read anything this diff changes, and it was run in full on the final head.

Coverage of the type gate, measured rather than claimed.tsc -p tsconfig.scripts.json --listFiles reports 1 hit each for scripts/check-readme-exports.mjs and scripts/__tests__/check-readme-exports.test.ts — so "type-check is clean" is a statement about both edited files and not a true sentence about a population that excludes them.

Lint scope, stated rather than assumed — and this is the full run for this diff, not a narrowing.pnpm lint:root is eslint . with four ignore patterns; read from eslint's own config via --format json, its population is 226 files, 0 errors, 29 warnings, and both edited files are in it with zero messages. No type-aware linting is configured (no projectService, no parserOptions.project), so this diff cannot move the verdict of any untouched file. Every code file in the diff is under scripts/, i.e. entirely inside that population.

Declared narrowing

Two, both stated so a reader can price them:

  1. The repo-wide pnpm lint (turbo run lint, per package) was not run. Zero package source changed, and the three-part evidence above — population read from eslint's own config, file counts from --format json, and no type-aware linting — is what makes that a measurement rather than a skip.
  2. scripts/__tests__ was not run whole, only the one suite. Derived from disk, as above: no other test file reads any changed path. CI runs the directory regardless.

The authoritative reading is the CI job conclusions on this PR.

Merge, never a rebase

One merge of origin/main (eb33a8d4cec0a7b846), no conflicts. git diff --name-only across that range touches five files, all under scripts/, and zero under packages/ — so the built dist/ this gate reads is still exactly the merged tree's, and the census above is a reading about the tree being shipped rather than a stale one.

Follow-ups


Generated by Claude Code

…type, both ways
Tier 2 of objectui#5043, the half the card scoped as "bidirectional pins for
documented `interface` blocks". A README that writes out a type it also exports
is making a claim the tier-1 name check cannot reach: a standalone
`interface GanttTask { ... }` in a fenced block is a LOCAL declaration, so it
compiles green no matter what it says.
`scripts/check-readme-exports.mjs` now compares, for every fenced block that
declares `interface X { ... }` or `type X = { ... }` where `X` is an export of
that README's own package, the documented property names against the shipped
declaration's, in both directions:
fabricated-key a documented key the shipped type does not have, judged
against the FULL property set so an inherited key is not
reported as invented;
stale-omission a shipped key the block never mentions, judged against the
interface's OWN declared members so an excerpt of a type
with a large base is not stale for leaving the base alone.
One direction pins only half, and a RENAME is visible only as the pair -- the
new spelling as `fabricated-key`, the old one as `stale-omission` on the same
declaration. The header states the bound the card recorded: the typed-example
half cannot carry that class, because a property-level type error
short-circuits the missing-property detail.
Two ways to declare a deliberate excerpt, with different meanings: a
`PARTIAL_MARKER` HTML comment in the README (grammar deliberately in
`check-doc-snippet-types.mjs`'s `FRAGMENT_MARKER` family), and a
`PARTIAL_EXCERPTS` ledger here for drift owed to a content card. Both are
shrink-only and both suppress omissions ONLY -- neither can hide a fabricated
key. An unbuilt package is a FAILURE on this side too, never a serene
`local-declaration`, and three new floors let the pin walk collapse and name
itself independently of the import walk.
No new compiler: `check-doc-snippet-types.mjs` already collects every
`packages/<name>/README.md` and compiles the covered ones (39 in its surface, 8
compiled, 31 on its shrink-only ledger). Measured and written into the header.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
`type-check:scripts` reads the JSDoc as the declaration for this module, so the
new option existed at runtime and not in the contract — every test passing it
was a TS2353 while the suite itself was green. The producer's declaration was
the stale half; fixed there rather than loosened at the five call sites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
The ledger is a debt list, and a debt with no card is one nobody picks up.
Each entry now names objectui#7302 (the README fixes) beside objectui#6214
(the gate that recorded it), and the kanban pair notes that objectui#6155 can
move its omission set — that set is read from what the package exports, not
copied into the entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
…compared
`Test (shard 2/4)` went red on the interface pin's first CI run. Not a flake:
the test shards run `pnpm install` then `pnpm test` and NEVER build, so on CI
every documented type comes back `unjudgeable-type` — and the three real
`PARTIAL_EXCERPTS` entries then suppressed no omission, so the shrink-only
sweep reported all three as `stale-excerpt-entry`.
That is the wrong verdict, and wrong in the direction that costs most: it tells
a reader to DELETE a ledger entry that is still true. "This entry suppressed
nothing" has two causes that look identical from outside — the README caught up
(stale), or the declaration could not be judged at all (still true) — and they
must not share a verdict.
So a declaration that resolves to `unjudgeable-type` now SUSPENDS the rule for
its ledger entry and its marker rather than satisfying it. Both are counted as
`not judged` in the census, and the run still FAILS — under `unjudgeable-type`,
which names the real problem. The counter increments only where an excerpt was
actually declared, so the census means "claims this run could not check" and
not "declarations it could not judge", which `typesUnjudgeable` already says.
Tests: four fixture legs, on the fixture tree so they hold in both build states
— the ledger case, the marker case, the no-excerpt case pinning the counter's
meaning, and a MUST-FAIL CONTROL asserting the same entry on a built tree is
still reported stale. Without that control all three would also pass if the
suspension had swallowed the rule outright, which is the opposite defect and
the more expensive one: a ledger that can never shrink again.
Tier 1's `repo state` test filtered `!== 'unjudgeable'` to mean "could not
judge". That class has two verdicts now, so the filter names both — the
assertion that the unbuilt tree must still FAIL is unchanged — and its unbuilt
branch now asserts zero stale-excerpt and zero stale-marker rows by name, so
this regression cannot come back silently.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(scripts): pin documented interface blocks against the shipped type, both ways - #7306

Merged
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins
Sep 2, 2026
Merged

feat(scripts): pin documented interface blocks against the shipped type, both ways#7306
yinlianghui merged 6 commits into
mainfrom
claude/issue-6214-readme-interface-pins

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#6214

Tier 2 of #5043, and not the shape the card assumed. The card asked for two things — compile the README code blocks, and add bidirectional pins for documented interface blocks. The two measurements the unlock comment left owed were taken first, and the first of them says the compile half already has a gate. So this PR is the pin, and the compile half is reported rather than rebuilt.

Both measurements are below with their numbers, because both change what got built.


Measurement (a) — the compile half is already check:doc-snippets' job

scripts/check-doc-snippet-types.mjs does not scan content/docs only. Its collector walks that tree and every packages/PKG/README.md (the listDocuments walk, and the scan surface is stated in its header in the same breath as the coverage rule). Coverage there is declared, never assumed: a document is compiled unless it is named in UNGATED_DOCS with a reason, and that ledger is shrink-only — an entry naming a file that does not exist, or that holds no ts/tsx block, fails as stale.

Measured on this branch's base:

count
package READMEs in that gate's scan surface39
of them compiled today8 (cli, components, create-plugin, plugin-grid, plugin-timeline, runner, test-support, vscode-extension)
of them on the UNGATED_DOCS debt ledger, each with a reason31
total entries on that ledger43 (31 READMEs + 12 .mdx pages)

#5174's batches are burning that ledger down — its header records batch 1 clearing 90 diagnostics, batch 2 eighty-nine, batch 3 eighty-one, batch 4 one hundred and four, and batch 5 is live on content/docs right now. So a second README compiler here would duplicate a working gate and split its ledger in two, and every README it brought under itself would then have two places to declare a fragment. Not built, deliberately; the reasoning is in this gate's header under "Compiling the blocks" so the next reader does not re-derive it.

One honest gap that measurement exposes, recorded and not fixed here: the doc-snippets collector reads packages/NAME/README.md at the package root only, so the four nested READMEs this gate walks (packages/types/src/zod/README.md and friends) are outside its surface entirely — neither covered nor on its ledger. That is #5174's own class of finding, one directory deeper, and it belongs to that gate.

Measurement (b) — the tier-2 baseline, re-taken on the merged ref

The card was blocked on this: "Running all 15 compilable plugin-gantt blocks under strict, after #5012 landed, still exits rc=2: two TS7006."#5057 has since reached completed state, by way of merged PR #5900 and the README moved again in 231d1b93c, so the figure was re-taken with the existing instrumentcheck-doc-snippet-types.mjs's own analyze + compileSnippets, with plugin-gantt lifted out of UNGATED_DOCS — rather than a new compiler:

targets: packages/plugin-gantt/README.md
blocks to compile: 18 | declared fragments: 0
parse failures on targets: 2
semantic-failing blocks on targets: 5
total diagnostics on targets: 13 {"TS2304":7,"TS7006":1,"TS7031":2,"TS2552":1,"TS2322":2}

It does not compile clean, and the shape of the red moved. The filed rc=2 / two TS7006 is now one TS7006 plus two TS7031 (the same unannotated-callback class, redistributed), and the bulk is a different family: seven TS2304 from blocks that are deliberately-not-valid property sketches ({ type, tasks: … }, bare dependencies: [ … ]), which is precisely the case FRAGMENT_MARKER exists for over there. Two blocks do not even parse. One diagnostic is real drift a compile tier would catch — TS2322: Type '"gantt"' is not assignable to type '"object-gantt"'.

Under the card's clause — "decide explicitly whether to list it as excluded or add a class, don't let it fall out silently" — this is listed, not inherited: packages/plugin-gantt/README.md stays on UNGATED_DOCS (untouched by this PR, it is #5174's file) and the number above is on the record. No shrink-only exclusion entry is added here for it, because this PR adds no compile pass for it to be excluded from.


What this PR builds: the bidirectional interface pin

A README that writes out a type it also exports makes a claim tier 1's name check cannot reach. The card records why, and the pin is built on it:

A documented interface block compiles green no matter what it says. A standalone interface GanttTask { … } in a README is a local declaration unrelated to the real type; feeding it to tsc as-is proves nothing.

So for every fenced block that declares interface X { … } or type X = { … } where X is an export of that README's own package, the documented property names are compared against the shipped declaration's, in both directions:

verdictdirectionjudged against
fabricated-keydocumented, shipped lacks itthe type's full property set, inherited members included
stale-omissionshipped, documented never mentions itthe interface's own declared members only

The asymmetry is the design, not a shortcut. Documenting a key that arrives through extends is correct, so the doc side is judged leniently; an excerpt of a type with a large base is not stale for leaving the base's keys to the base's own page, so the shipped side is judged narrowly. Measured on the real tree: judging the shipped side against all properties instead would report 36 omissions on DetailViewSchema alone, most of them inherited BaseSchema members.

One direction pins only half, which is why both are here — and a rename is visible only as the pair, the new spelling as fabricated-key and the old one as stale-omission on the same declaration and the same line. No single direction reports it.

The bound, stated in the script header because it is narrow

The card's other measurement is written into the header verbatim in substance: the typed-example half cannot carry the key-rename class, because a property-level type error short-circuits the missing-property detail — const task: GanttTask = { name: …, start: '2024-01-01' } reports two TS2322 and never that title is gone. This pin is what covers renames on interface blocks, and the header says so and promises nothing more.

Where it deliberately stops (first cut, all four in the header)

  • Method and index signatures are counted and skipped, on both sides. packages/types/README.md's DataSource is entirely method signatures, so it resolves and is compared over zero keys — and the census says 1 compared over ZERO documented keys rather than letting it read as a verified declaration.
  • Property names only, never types. That half belongs to a compile tier.
  • X is resolved against the README's own package only. Widening to any workspace package pulls in packages/plugin-detail/README.md's DetailViewSchema (owned by @object-ui/types) at the 36 omissions above. That widening needs its own inheritance policy and its own card.
  • Nested declarations are not walked — a type declared inside a function body in an example is the example's scaffolding, not a claim about the surface.

Declaring a deliberate excerpt — two homes, two different claims

Mirroring the pair check-doc-snippet-types.mjs already runs (UNGATED_DOCS + FRAGMENT_MARKER), and for the same reason: they say different things.

  • PARTIAL_MARKER — an HTML comment in the README, above the fence, whose body reads readme-exports: partial GanttTask — reason. (Spelled with a placeholder here on purpose: an HTML comment written literally into a GitHub body does not survive to the reader. The exact literal is published as PARTIAL_MARKER_EXAMPLE in the script, a test asserts the regex accepts it, and the gate prints it in its own failure message — so the one place a reader meets it is the one place it is guaranteed correct.) It says this excerpt is deliberate. The grammar is deliberately the same family as FRAGMENT_MARKER (marker word, an em dash / double hyphen / colon, then a reason of at least 12 characters), so a reader who knows one knows the other. The verb differs because the claim doesdoc-snippet: fragment says a block cannot compile, and that must not double as permission to omit a key. Only the HTML spelling exists here: these are README.md files, not MDX.
    It names the interface, which FRAGMENT_MARKER has no need to do: packages/plugin-kanban/README.md declares two types in one block, and a marker that silenced a whole block would silence the neighbour nobody looked at. Pinned as a test.
  • PARTIAL_EXCERPTS — a ledger in the script. It says this is drift, owed to a content card.

Both suppress stale-omission only. Neither can hide a fabricated-key — an excerpt may leave a key out, it may not invent one, and there is no reading of "partial" under which a ledger should be able to hide that. Both are shrink-only and enforced as such: a marker or entry that suppressed nothing fails, so the lists cannot quietly stop shrinking.

An unbuilt package is a FAILURE on this side too

Tier 1's rule, kept, and it needed its own branch: with no export surface on disk every documented type resolves to nothing and would read as a serene local-declaration, so the pin would report green over blocks it never judged. Scoped exactly as the import side is — it fails only where the missing surface would have changed a verdict, i.e. where a block declares a type. Verdict unjudgeable-type, in the census, pinned by a test.

Non-vacuity

Three new FLOORS counters — typeDeclarations, typesResolved, keysCompared — so the pin walk can collapse and name itself instead of hiding behind a healthy import walk. Pinned by a three-leg independence test on its own fixture tree (a control leg proving nothing breaches while both walks are healthy, an exact-equality leg on the three pin counters alone, and a leg pinning that the package-side counters are unchanged) — the same shape, and the same exactness argument, as tier 1's.

Its own fixture tree, deliberately: tier 1's independence test asserts exportSymbolsexactly (toBe(4)), and adding exports to alpha to give this half something to compare would have turned that assertion into a number nobody chose.


The census, before and after

Before, at eb33a8d4c (this branch's base):

✅ check-readme-exports: OK (43 tracked README(s) under packages/ (0 outside any package), 407 fenced
block(s) (309 parsed as code, 6 untagged); 506 import binding(s), 386 of them self-imports judged (386
real, 0 wrong-path, 0 fabricated); 3292 export symbol(s) read from 37 of 40 tracked package(s) (39 carry
a README) (0 unbuilt, 3 declare no types); 17 side-effect import(s), 0 namespace, 11 deep self-path, 92
to other packages).

After, at 15a06cff3 (the head of this branch, after the merge of main) — the same line with the pin's half appended:

✅ check-readme-exports: OK (… 92 to other packages; 6 documented type(s) in 5 block(s) (4 resolve to a
shipped shape of their own package, 2 local, 0 not a property type, 0 unjudgeable), 52 key(s) compared
both ways (0 fabricated, 0 stale omission(s); 0 excerpt(s) declared by marker, 3 by ledger; 1 compared
over ZERO documented keys)).

Green at rest, with the counts that say what was skipped travelling beside the ones that say what was judged.

The exclusion ledger this opens with — three entries, two READMEs

Well under the ten-README ceiling the order set, so this ships rather than escalating. Every one is the stale-omission direction; zero fabricated keys anywhere in the tree.

entryomitsdisposition
packages/plugin-gantt/README.md::GanttTaskfields, hasOwnDatesgenuinely an excerpt — the prose immediately below the block already names both as populated by ObjectGantt itself. It wants the in-README marker, not this ledger.
packages/plugin-kanban/README.md::KanbanColumncollapsedstaleness — nothing on the page says the block is partial
packages/plugin-kanban/README.md::KanbanCardcardSubtitle, cardFieldCells, coverImagestaleness, same page, three keys behind

No README content is edited by this PR, including the marker GanttTask deserves: the order scoped this card to the gate, so all three are recorded here with the card number and filed as a content follow-up, #7302 (unassigned, finding). The kanban entry notes that #6155 — four disagreeing declarations of that pair — can move its omission set, because that set is derived from what the package exports, not copied into the entry.

scan takes an excerpts option so the ledger can be switched off, and a test asserts that with it off the repo reds with exactly these three and that every red is a stale-omission — so the ledger is provably not covering a fabricated key or anything else. That test is written to hold built and unbuilt, like tier 1's repo state block: on an unbuilt tree the same declarations are unjudgeable-type, which is the failure the rule requires and not a skip.


Ablation — seven legs, no tracked file ever mutated

Every leg runs against a scratch copy handed to the gate through tier 1's --readme REAL=SCRATCH override. Each plant is proved on disk by an anchored count before and after, never by an editor's exit code, and the runner carries a trap … EXIT INT TERM that deletes the scratch files and re-verifies the tracked README's blob.

The plant proof has its own negative control, run first: an anchor that is deliberately never written must be rejected. It was (PLANT NOT ON DISK … its reading is VOID, exit 9) — so the passes below mean something.

legplantedpredictedmeasured
controla correctly documented interface GanttMarkergreenEXIT=0, verdict matches
(i)fabricated key iconred, names itEXIT=1interface GanttMarker documents 'icon', not on the shipped type
(ii)renamelabel to captionred in both directions, same declarationEXIT=1documents 'caption'andomits 'label', both at :718
(iii)omitted shipped key colorredEXIT=1interface GanttMarker omits 'color'
(iv)the same omission + the partial markergreenEXIT=0, census 1 excerpt(s) declared by marker
(v)a fabricated key under a markerstill redEXIT=1 — the marker does not cover fabrication
(vi)a marker over a complete declarationred as staleEXIT=1declares an excerpt that omits nothing any more

Every leg names the README, the interface and the key. Re-run in full on the merged head e32824dae; the only commit after it (15a06cff3) edits three reason strings inside PARTIAL_EXCERPTS, none of which any leg above reads — every leg plants and judges GanttMarker, which the ledger does not name. The restore proof closes it:

restore-proof: packages/plugin-gantt/README.md blob db8293c38832626e497519279681184caf4c69cb
== HEAD blob db8293c38832626e497519279681184caf4c69cb

git status --porcelain was empty after the run.


Verification

All at the branch head 15a06cff3 unless a line says otherwise, gate verdicts quoted from the line each gate printed, every exit code captured by redirect before any pipe.

pnpm check:readme-exports EXIT=0 ✅ OK (census above)
pnpm type-check:scripts EXIT=0
pnpm check:control-bytes EXIT=0 ✅ OK (scanned 6008 tracked text file(s); skipped 85 binary)
pnpm check:entry-guard EXIT=0 ✓ 58 scripts/ file(s) — no entry guard outside the baseline
pnpm check:doc-fences EXIT=0 ✅ every TypeScript block in 224 document(s) …
node scripts/check-changeset-presence.mjs EXIT=0
node scripts/check-lint-coverage.mjs EXIT=0 ✅ 46/46 packages linted, 0 with outstanding errors
node scripts/check-type-check-coverage.mjs EXIT=0
pnpm lint:root EXIT=0 ✖ 29 problems (0 errors, 29 warnings) — all pre-existing
vitest run scripts/__tests__/check-readme-exports.test.ts EXIT=0 83 passed (83)

Root vitest only (#3378), never package-scoped. The suite grew 47 → 83 cases.

Authored by Claude Code in session session_01BGMDbrVa8JjZcCQ7DWYH1b (https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b) — written into the prose here because a footer does not survive a later edit of this body.

One gate went red and it was mine to fix.type-check:scripts reported five TS2353 on excerpts — the option existed at runtime and not in scan's JSDoc, which is what that gate reads as the declaration. Contract-first: the producer's declaration was the stale half, fixed there rather than loosened at the five call sites. The vitest suite was green through all of it, which is why the type gate is a separate reading and not a duplicate of it.

No changeset, and the gate says so in its own words:"2 file(s) changed, 0 of them published source of a package the release covers … no changeset is owed." Everything in this diff is under scripts/. Same disposition as #6212, which shipped scripts-only the same way; the skip-changeset label is deliberately not applied — in this repository that label is read by no workflow and exempts nothing, and the gate's verdict line is the authority.

Test scope, derived from disk not assumed.git grep -l 'check-readme-exports' across *.test.ts returns exactly one file: scripts/__tests__/check-readme-exports.test.ts. That is the whole set of suites that read anything this diff changes, and it was run in full on the final head.

Coverage of the type gate, measured rather than claimed.tsc -p tsconfig.scripts.json --listFiles reports 1 hit each for scripts/check-readme-exports.mjs and scripts/__tests__/check-readme-exports.test.ts — so "type-check is clean" is a statement about both edited files and not a true sentence about a population that excludes them.

Lint scope, stated rather than assumed — and this is the full run for this diff, not a narrowing.pnpm lint:root is eslint . with four ignore patterns; read from eslint's own config via --format json, its population is 226 files, 0 errors, 29 warnings, and both edited files are in it with zero messages. No type-aware linting is configured (no projectService, no parserOptions.project), so this diff cannot move the verdict of any untouched file. Every code file in the diff is under scripts/, i.e. entirely inside that population.

Declared narrowing

Two, both stated so a reader can price them:

  1. The repo-wide pnpm lint (turbo run lint, per package) was not run. Zero package source changed, and the three-part evidence above — population read from eslint's own config, file counts from --format json, and no type-aware linting — is what makes that a measurement rather than a skip.
  2. scripts/__tests__ was not run whole, only the one suite. Derived from disk, as above: no other test file reads any changed path. CI runs the directory regardless.

The authoritative reading is the CI job conclusions on this PR.

Merge, never a rebase

One merge of origin/main (eb33a8d4cec0a7b846), no conflicts. git diff --name-only across that range touches five files, all under scripts/, and zero under packages/ — so the built dist/ this gate reads is still exactly the merged tree's, and the census above is a reading about the tree being shipped rather than a stale one.

Follow-ups


Generated by Claude Code

…type, both ways
Tier 2 of objectui#5043, the half the card scoped as "bidirectional pins for
documented `interface` blocks". A README that writes out a type it also exports
is making a claim the tier-1 name check cannot reach: a standalone
`interface GanttTask { ... }` in a fenced block is a LOCAL declaration, so it
compiles green no matter what it says.
`scripts/check-readme-exports.mjs` now compares, for every fenced block that
declares `interface X { ... }` or `type X = { ... }` where `X` is an export of
that README's own package, the documented property names against the shipped
declaration's, in both directions:
fabricated-key a documented key the shipped type does not have, judged
against the FULL property set so an inherited key is not
reported as invented;
stale-omission a shipped key the block never mentions, judged against the
interface's OWN declared members so an excerpt of a type
with a large base is not stale for leaving the base alone.
One direction pins only half, and a RENAME is visible only as the pair -- the
new spelling as `fabricated-key`, the old one as `stale-omission` on the same
declaration. The header states the bound the card recorded: the typed-example
half cannot carry that class, because a property-level type error
short-circuits the missing-property detail.
Two ways to declare a deliberate excerpt, with different meanings: a
`PARTIAL_MARKER` HTML comment in the README (grammar deliberately in
`check-doc-snippet-types.mjs`'s `FRAGMENT_MARKER` family), and a
`PARTIAL_EXCERPTS` ledger here for drift owed to a content card. Both are
shrink-only and both suppress omissions ONLY -- neither can hide a fabricated
key. An unbuilt package is a FAILURE on this side too, never a serene
`local-declaration`, and three new floors let the pin walk collapse and name
itself independently of the import walk.
No new compiler: `check-doc-snippet-types.mjs` already collects every
`packages/<name>/README.md` and compiles the covered ones (39 in its surface, 8
compiled, 31 on its shrink-only ledger). Measured and written into the header.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
`type-check:scripts` reads the JSDoc as the declaration for this module, so the
new option existed at runtime and not in the contract — every test passing it
was a TS2353 while the suite itself was green. The producer's declaration was
the stale half; fixed there rather than loosened at the five call sites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
The ledger is a debt list, and a debt with no card is one nobody picks up.
Each entry now names objectui#7302 (the README fixes) beside objectui#6214
(the gate that recorded it), and the kanban pair notes that objectui#6155 can
move its omission set — that set is read from what the package exports, not
copied into the entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
…compared
`Test (shard 2/4)` went red on the interface pin's first CI run. Not a flake:
the test shards run `pnpm install` then `pnpm test` and NEVER build, so on CI
every documented type comes back `unjudgeable-type` — and the three real
`PARTIAL_EXCERPTS` entries then suppressed no omission, so the shrink-only
sweep reported all three as `stale-excerpt-entry`.
That is the wrong verdict, and wrong in the direction that costs most: it tells
a reader to DELETE a ledger entry that is still true. "This entry suppressed
nothing" has two causes that look identical from outside — the README caught up
(stale), or the declaration could not be judged at all (still true) — and they
must not share a verdict.
So a declaration that resolves to `unjudgeable-type` now SUSPENDS the rule for
its ledger entry and its marker rather than satisfying it. Both are counted as
`not judged` in the census, and the run still FAILS — under `unjudgeable-type`,
which names the real problem. The counter increments only where an excerpt was
actually declared, so the census means "claims this run could not check" and
not "declarations it could not judge", which `typesUnjudgeable` already says.
Tests: four fixture legs, on the fixture tree so they hold in both build states
— the ledger case, the marker case, the no-excerpt case pinning the counter's
meaning, and a MUST-FAIL CONTROL asserting the same entry on a built tree is
still reported stale. Without that control all three would also pass if the
suspension had swallowed the rule outright, which is the opposite defect and
the more expensive one: a ledger that can never shrink again.
Tier 1's `repo state` test filtered `!== 'unjudgeable'` to mean "could not
judge". That class has two verdicts now, so the filter names both — the
assertion that the unbuilt tree must still FAIL is unchanged — and its unbuilt
branch now asserts zero stale-excerpt and zero stale-marker rows by name, so
this regression cannot come back silently.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BGMDbrVa8JjZcCQ7DWYH1b
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude