feat(scripts): hold the hand-written spec subpath and namespace enumerations equal to the exports map - #17435
Merged
hotlong merged 3 commits intoSep 10, 2026
Conversation
…xports map Three pages under `content/docs/` enumerate `@objectstack/spec`'s importable subpaths or its protocol namespaces, and nothing read any of them. When step 3 of the #16325 chain deleted the `./cloud` exports entry and added `./marketplace`, every mechanical counter of that set moved with it -- the anti-vacuity floor pin, the alias-coverage pin, `export-origins`, `root-meta`, `llms.txt`, the quick-reference counts, around 25 in all -- and all three prose enumerations kept advertising `cloud` and never listed `marketplace` until a human swept for them. The gate derives TWO sets rather than one: the exports map's 16 bare `./<name>` entries, and the 15 of those whose declared `CATEGORY_TITLES` title ends in " Protocol" (`meta-spelling` is an importable subpath titled "Vocabulary", not a protocol). Display names come from that same declaration, so the #5853 "Qa Protocol" class of wrong-but-stable spelling is a finding here too. Five enumerations are held, each independently: the ordered subpath sentence in troubleshooting.mdx, the parenthesised namespace list in packages.mdx, and the glossary's namespace sentence, layers table and per-namespace sections. Numeric namespace-count claims are swept across `content/docs/**`, excluding the release notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…/qa` watch hint The dispatch-gates tool re-anchors a bare single-segment literal in a gate's source against the gate's own directory when it resolves to a tracked one. The fixture's `qa` subpath, spelled as a code span inside the fixture MDX sentence, therefore converted to `scripts/qa` -- a real tracked directory this gate never reads, which would have named it on every card touching that directory, and which failed `check:pm-dispatch-gates`'s pairing assertion (the anchor rule must REPLACE a hint, never add one beside it). The fixture's subpath names are now picked against the tracked listing of this gate's own directory, and the assertion that enumerated them is one joined literal rather than an array of bare names. Display names still come from the declared titles, so the abbreviation coverage is unchanged. Measured after the fix: the gate's hint set is identical with and without a tree, so the anchor rule adds nothing for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nal output The count sweep matched `<number> protocol namespaces` anywhere under `content/docs/**`, fenced code included. 350 of the 387 swept pages carry fences, and the documentation for this gate will quote the gate's own output -- a transcript of a run last quarter is not a claim about today's package, and a repo-wide gate that reds on quoted output teaches people to route around it. Fenced blocks are now skipped by the count sweep alone; the five enumerations read specific anchored lines and keep their scope. Indented blocks are deliberately not skipped: measured zero outside a fence across the corpus, and a line-based reader cannot tell four-space list continuation from code without block context, so the error would fall on the expensive side. A fence left open would hide the rest of a page, so on the three governed pages that is a structural refusal. Battery 8 of the self-test declared a floor of 12 while registering 16 cases, so a whole `refused(...)` block could be deleted with the self-test still exiting 0 and still printing that its structural cases held -- the defect this gate exists to prevent, one level up. Floors now equal their real counts (79 cases, 79 declared) and the verdict's structural-case number is read from the run instead of typed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hotlong
marked this pull request as ready for review
September 10, 2026 15:48
hotlong
enabled auto-merge
September 10, 2026 15:48
hotlong
deleted the
claude/issue-17388-docs-subpath-enumerations-gated
branch
September 10, 2026 16:21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17388
This is not a docs fix
The three pages are correct today —
ef1fda259corrected them by hand in#17372's patch round, and a grep for stale
cloudmentions finds nothing. Thiscard is the gate that keeps them correct, so the deliverable is a mechanical
check whose acceptance test is that it FAILS on a deliberately stale
enumeration. That evidence is below.
Why it exists
When step 3 of the #16325 chain deleted the
./cloudexports entry and added./marketplace, every mechanical counter of that set moved with it — theanti-vacuity floor pin, the alias-coverage pin,
export-origins,root-meta,llms.txtviacheck:llms-txt,quick-reference.mdxviacheck:quick-reference-counts, around 25 in all — because each of them went redand pointed at itself. The three prose enumerations moved only because a
human swept for them. Step 2 of that same chain was FAILed in review by exactly
this class of unlisted counter.
Two sets, not one — re-derived on this tree
node scripts/check-docs-spec-enumerations.mjs --listprints both, and I didnot take either number from the card:
The split is derived, not typed into the gate.
CATEGORY_TITLESinpackages/spec/scripts/lib/category-title.tsdeclares one display title perpackages/spec/src/module directory and is held total over that directorylisting in both directions by
categoryTitleCoverage, which stopsgen:docs.Everything with a schema closure is titled
... Protocol;meta-spellingistitled
Meta-Spelling Vocabularyfor exactly this reason (recorded there and inCATEGORIES_WITHOUT_SCHEMA_CLOSURE). So:./NAMEkeys of the exports map (16)" Protocol"(15)" Protocol"— which is whereAI,API,QAandUIcome from, with no second abbreviation list to rot. AQainthe prose is a finding; that is the docs-gen:
getCategoryTitle()只给 UI/AI/API 大写,qa渲染成 "Qa Protocol"(参考页标题 + meta.json + 根索引导航三处) #5853 defect class, caught here for free.A gate that conflated the two sets would be red on this tree on its first run,
because the namespace lists hold 15 and the subpath sentence holds 16. The
self-test pins that property so it cannot be lost later.
What is held
content/docs/deployment/troubleshooting.mdxAvailable subpathssentencecontent/docs/plugins/packages.mdxProtocol namespaces (...)listcontent/docs/getting-started/glossary.mdxprotocol namespaces:sentencecontent/docs/getting-started/glossary.mdxNamespaces it includescolumncontent/docs/getting-started/glossary.mdx### X Protocolsectionscontent/docs/**Each of the five enumerations is independently load-bearing; the self-test
mutates them one at a time and asserts that exactly one goes red.
Acceptance evidence — mutation, red, restore, proven by bytes
Run from the committed tree, both legs with an
EXIT INT TERMtrap andabsolute paths, restore by
git checkout HEAD -- PATH.Leg 1 — the SUBPATH set. Dropped
, `marketplace`from thetroubleshooting sentence.
Leg 2 — the NAMESPACE set, covered independently. Added a fictitious
Cloudto the glossary's layers table.
Final restore proof:
git diff HEAD --quietexit 0 (working treeidentical to HEAD), and the gate is exit 0 again on the restored tree. Each
message names the page, the line, the kind, and the specific name that differs.
One honest note on the leg-1 numbers: the mutation counter reads
before=438 after=438because the replacement string is empty andgrep -c -F ''matches every line. The load-bearing half is the anchor counter,
1 -> 0,which is decisive on its own.
Self-test
pnpm check:docs-spec-enumerationsruns--self-testfirst, then the live scan.66 cases over 9 batteries, each battery floored by name so a section that stops
running names itself rather than going quiet. The positive control reproduces
the #17372 defect on a fixture as 10 divergences across all three pages; 12
structural cases are refused rather than read as clean (the constant renamed, a
subpath with no title, an anchor gone, an anchor duplicated, an enumeration
drained to nothing, the count claims drained away).
What the gate does NOT cover — a stated limit is worth more than an implied guarantee
glossary's own
Tenantentry says thetenantschema left the package "withthe
./cloudsubpath" — true, deliberate, and invisible to this gate. That isthe limit working as intended, not tolerated.
read. The count sweep is the only part that widens with the corpus, and it
sees a number, never a list.
content/docs/releases/is excluded from the count sweep. Release notesare a historical record and AGENTS.md forbids editing them in a code PR, so a
gate able to demand an edit there would be a trap.
actually resolves (that is the alias-coverage pin's job — this gate reads
text and imports nothing).
packages/spec/. If it is renamed or moved, the gate REFUSES and names thefile and symbol it expected, rather than deriving an empty set and printing
the cleanest green it has. That refusal is observed in the self-test.
Lane and wiring
Route 1 of the card, landed as a root gate:
scripts/check-docs-spec-enumerations.mjsover
content/docs/**, which stays cleanly indomain:devx. Nothing waswritten under
packages/spec/scripts/(where #17383 is queued behind this card)or
packages/runtime/src/(another lane); both are read-only inputs here.Wired the way its neighbours are: a
check:docs-spec-enumerationsscript in theroot
package.json, and an unconditional step inlint.yml'sLint & Repo Gatesjob beside
check:quick-reference-counts,check:doc-anchors,check:overlay-whitelist-tableandcheck:runtime-services-index— the job theledger records as carrying the
check:*gate family, and the one whose otherdocs guards deliberately avoid a
packages/**paths filter that would blindthem to their own failure mode.
node scripts/pm/dispatch-gates.mjs --commandsalready lists
pnpm check:docs-spec-enumerationsfor this change set, so thedispatch tool can see it.
A second commit, and what it caught
check:pm-dispatch-gatesfailed on the first commit, and the finding is worthrecording because it is the same species as the one this card is about. The
dispatch-gates tool re-anchors a bare single-segment literal in a gate's source
against the gate's own directory when it resolves to a tracked one. The
fixture's
qasubpath -- written as a markdown code span inside the fixture MDXsentence, where it has to be -- therefore converted to a watch hint on a real
tracked directory this gate never reads, and that gate's pairing assertion (the
anchor rule must REPLACE a hint, never add one beside it) went red and named
both the gate and the hint.
Fixed by picking the fixture's subpath names against the tracked listing of this
gate's own directory, and by spelling the assertion that enumerated them as one
joined literal. Display names come from the declared titles, so the abbreviation
coverage is unchanged. Measured after the fix: the gate's hint set is byte-identical
with and without a tree, so the anchor rule now adds nothing for it. Nothing was
filed for the mechanism itself -- the guard worked, went red, and named the gate
and the hint; only the diagnostic distance is long.
Changeset
skip-changeset: this PR publishes nothing. It adds one root gate script, aroot
package.jsonscript entry (the root package isprivate: true) and a CIstep. An empty-frontmatter changeset is the wrong instrument here and
check:empty-changesetrefuses new ones.Patch round 1 — review items 1 and 2
1. The count sweep reads PROSE; fenced code is a transcript
COUNT_CLAIM_STRICTmatched a number beforeprotocol namespacesanywhereunder
content/docs/**, fenced code included. Measured on this tree: 350 ofthe 387 swept pages carry a fence. The concrete cost is not hypothetical — the
documentation for this gate will quote the gate's own output, and so will any
troubleshooting page showing a real terminal session. A gate that reds on quoted
output teaches people to route around it, and a repo-wide gate people route
around is worse than no gate.
readFences()now marks the fenced lines of a page andreadCountClaims()skips them. This is the count sweep only: the five enumerations read
specific anchored lines and keep their scope, so a fence cannot hide one of
them. CommonMark's two rules that matter are both honoured — a backtick
opener's info string may not contain a backtick, and a closer must be the same
character, at least as long as its opener, and carry no info string. The second
is what lets this gate's own docs quote a fenced session inside a longer fence.
Indented code blocks are deliberately NOT skipped, and this is a reasoned
decline rather than an omission. Measured: zero indented code blocks outside a
fence across all 387 swept pages, so skipping them buys nothing today. And it
cannot be done correctly by a line-based reader — four leading spaces under a
list item is list CONTINUATION, not code, and CommonMark separates the two with
block context this gate does not build. The error would fall on the expensive
side: a real stale total silently unread.
The blinding vector the change itself opens is closed. A fence left open
would hide every claim below it. On the three governed pages that is now a
structural REFUSAL naming the fence's line. Elsewhere in the corpus it is not
policed — this gate is not a markdown linter for 387 pages, and every live count
claim is on a governed page. Measured: zero unterminated fences in the corpus
today, so the refusal is not currently load-bearing on any real page.
On the subset half — no change, as instructed, and I agree after looking.
The CRM example touches 3 protocol namespaces (Data, UI, and Automation)stillreds. Narrowing the pattern to demand a totality qualifier risks the opposite
and more expensive error: missing a real stale total. The error names file and
line, and
three of the protocol namespacesalready passes. No heuristic added.2. Battery 8's floor was 4 short
It declared 12 while registering 16 — 12
refused(...)calls, four of themcarrying
expectIn, which registers two cases each. Slack in a floor is space abattery can lose cases into silently, which is this gate's own failure mode one
level up.
Floors now equal their real counts exactly, across every battery: 79 cases,
79 declared (9+8+6+10+4+3+15+20+2+2). The verdict line's structural-case
number is no longer typed at all — it is read from the run, so a second
hardcoded number cannot drift the way this one already had.
3. The wording note — taken
The run-on message previously read
lists (the run-on text), implying the pagelists a nonsense entry. A subpath is
[a-z][a-z0-9-]*and a display name comesfrom a declared title, so neither can contain a sentence break: an item carrying
one is never a misspelt entry, it is the last real entry with the following
sentence stuck to it. That is a sound discriminator, not a threshold, and the
message now names the real shape. Battery 10 pins it.
Evidence
Item 1, both legs, on the real tree (
content/docs/deployment/validating-metadata.mdx,restore proven by blob hash, not by an exit code):
OK spec: 16 protocol namespaces exportedinside a fenceThe spec package publishes 16 protocol namespaces today.as bare proseA-prime is what makes A meaningful: the fence moved the verdict, the page is not
merely harmless. On-disk landing was observed per leg by counting the injected
marker before running, never by an editor's exit code. Restore: HEAD blob
c0d9a0dbce1c98395475efc5a6fe1eb174ede079equals the on-disk blob afterrestore, and
git diff HEADis empty.Item 2, ablation. Deleting the whole
refused('the count claims drained away', ...)block (2 cases):registered 18 case(s), below its pinned floor of 20.64 cases over 9 batteriesand
12 structural cases are refused— the PM's observation reproduced.git diff HEADempty.The gate runs from source (a plain
.mjs, no build, nodist/), so there is norebuild leg to prove — the on-disk edit is what runs.
Verification
pnpm check:docs-spec-enumerations— exit 0; same 4 count claims found asbefore the change, so the exclusion is a no-op on today's tree.
dispatch-gates --ranexits 0 — 0 unrun, 55 green, 5 NOT MEASURED(exit 3,
PREREQUISITE NOT MET— they readdist/, which this diff cannotaffect), 3 pre-existing macOS-environment reds that reproduce identically on
the merge base 9224204, a tree carrying none of this work:
check-step-collectors --self-testandcheck:bash32-floorboth fail because/bin/bashhere is 3.2.57 and has nomapfile;check:merge-driverfails ona runner it cannot find. All three are green on CI's Linux.
pnpm lint(repo-wideeslint . --no-inline-config, the full population, nonarrowing) — exit 0 in 27s at
ec579915a.turbo ls --affectedagainst the merge base — 0 packages, so no package testor typecheck is owed.
Generated by Claude Code