Skip to content

feat(scripts): hold the hand-written spec subpath and namespace enumerations equal to the exports map - #17435

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-17388-docs-subpath-enumerations-gated
Sep 10, 2026
Merged

feat(scripts): hold the hand-written spec subpath and namespace enumerations equal to the exports map#17435
hotlong merged 3 commits into
mainfrom
claude/issue-17388-docs-subpath-enumerations-gated

Conversation

@hotlong

@hotlong hotlong commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #17388

This is not a docs fix

The three pages are correct todayef1fda259 corrected them by hand in
#17372's patch round, and a grep for stale cloud mentions finds nothing. This
card 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 ./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 via check:llms-txt, quick-reference.mdx via
check:quick-reference-counts, around 25 in all — because each of them went red
and 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 --list prints both, and I did
not take either number from the card:

packages/spec/package.json -- 16 subpath(s): data, system, kernel, ai, automation,
  api, ui, contracts, integration, security, studio, marketplace, qa, identity,
  shared, meta-spelling

packages/spec/scripts/lib/category-title.ts -- 15 protocol namespace(s):
  data / system / kernel / ai / automation / api / ui / contracts / integration /
  security / studio / marketplace / qa / identity / shared

  subpath(s) that are NOT namespaces: meta-spelling

The split is derived, not typed into the gate. CATEGORY_TITLES in
packages/spec/scripts/lib/category-title.ts declares one display title per
packages/spec/src/ module directory and is held total over that directory
listing in both directions by categoryTitleCoverage, which stops gen:docs.
Everything with a schema closure is titled ... Protocol; meta-spelling is
titled Meta-Spelling Vocabulary for exactly this reason (recorded there and in
CATEGORIES_WITHOUT_SCHEMA_CLOSURE). So:

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

page enumeration held against
content/docs/deployment/troubleshooting.mdx the Available subpaths sentence SUBPATHS, ordered — the sentence itself claims "in its order"
content/docs/plugins/packages.mdx the Protocol namespaces (...) list NAMESPACES, as a set
content/docs/getting-started/glossary.mdx the protocol namespaces: sentence NAMESPACES
content/docs/getting-started/glossary.mdx the layers table's Namespaces it includes column NAMESPACES, plus its own no-duplicate rule (the table is a partition)
content/docs/getting-started/glossary.mdx the per-namespace ### X Protocol sections the declared TITLES
content/docs/** every numeric namespace-count claim the namespace total

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 TERM trap and
absolute paths, restore by git checkout HEAD -- PATH.

Leg 1 — the SUBPATH set. Dropped , `marketplace` from the
troubleshooting sentence.

on-disk proof  anchor [, `marketplace`]  before=1 after=0
--- gate output ---
x the hand-written spec enumerations disagree with packages/spec/package.json:

  content/docs/deployment/troubleshooting.mdx -- 1 divergence(s)
    content/docs/deployment/troubleshooting.mdx:347  [missing] the `Available subpaths`
      sentence does not list `marketplace`, which the exports map publishes.

VERDICT: 1 DIVERGENCE(S)
--- gate exit: 1 ---
RESTORE PROVEN  content/docs/deployment/troubleshooting.mdx
  2055cfb07539e7e9b38a882dd08234c5422a21f4 == 2055cfb07539e7e9b38a882dd08234c5422a21f4

Leg 2 — the NAMESPACE set, covered independently. Added a fictitious Cloud
to the glossary's layers table.

on-disk proof  anchor [Marketplace, Integration]     before=1 after=0
on-disk proof  mutation [Marketplace, Cloud, Integration]  before=0 after=1
--- gate output ---
  content/docs/getting-started/glossary.mdx -- 1 divergence(s)
    content/docs/getting-started/glossary.mdx:22  [unknown] the layers table's
      `Namespaces it includes` column lists `Cloud`, which the exports map does
      not publish (retired, misspelt, or never there).
--- gate exit: 1 ---
RESTORE PROVEN  content/docs/getting-started/glossary.mdx
  18de8baba1ca5870fd6f0da51d7d08ef98cbae16 == 18de8baba1ca5870fd6f0da51d7d08ef98cbae16

Final restore proof: git diff HEAD --quiet exit 0 (working tree
identical 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=438 because the replacement string is empty and grep -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-enumerations runs --self-test first, 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

  • Prose that describes a namespace in a sentence rather than listing it. The
    glossary's own Tenant entry says the tenant schema left the package "with
    the ./cloud subpath" — true, deliberate, and invisible to this gate. That is
    the limit working as intended, not tolerated.
  • A page that starts enumerating somewhere new. Only the anchors above are
    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 notes
    are 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.
  • Display spelling outside these enumerations, and whether a subpath
    actually resolves
    (that is the alias-coverage pin's job — this gate reads
    text and imports nothing).
  • The one coupling: the derivation reads a declaration inside
    packages/spec/. If it is renamed or moved, the gate REFUSES and names the
    file 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.mjs
over content/docs/**, which stays cleanly in domain:devx. Nothing was
written 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-enumerations script in the
root package.json, and an unconditional step in lint.yml's Lint & Repo Gates
job beside check:quick-reference-counts, check:doc-anchors,
check:overlay-whitelist-table and check:runtime-services-index — the job the
ledger records as carrying the check:* gate family, and the one whose other
docs guards deliberately avoid a packages/** paths filter that would blind
them to their own failure mode. node scripts/pm/dispatch-gates.mjs --commands
already lists pnpm check:docs-spec-enumerations for this change set, so the
dispatch tool can see it.

A second commit, and what it caught

check:pm-dispatch-gates failed on the first commit, and the finding is worth
recording 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 qa subpath -- written as a markdown code span inside the fixture MDX
sentence, 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, a
root package.json script entry (the root package is private: true) and a CI
step. An empty-frontmatter changeset is the wrong instrument here and
check:empty-changeset refuses new ones.

Patch round 1 — review items 1 and 2

1. The count sweep reads PROSE; fenced code is a transcript

COUNT_CLAIM_STRICT matched a number before protocol namespaces anywhere
under content/docs/**, fenced code included. Measured on this tree: 350 of
the 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 and readCountClaims()
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) still
reds. 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 namespaces already passes. No heuristic added.

2. Battery 8's floor was 4 short

It declared 12 while registering 16 — 12 refused(...) calls, four of them
carrying expectIn, which registers two cases each. Slack in a floor is space a
battery 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 page
lists a nonsense entry. A subpath is [a-z][a-z0-9-]* and a display name comes
from 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):

leg mutation gate exit
A OK spec: 16 protocol namespaces exported inside a fence patched 0 — passes
A-prime the same page pre-patch (65cde5f) 1 — reds
B The spec package publishes 16 protocol namespaces today. as bare prose patched 1 — reds

A-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
c0d9a0dbce1c98395475efc5a6fe1eb174ede079 equals the on-disk blob after
restore, and git diff HEAD is empty.

Item 2, ablation. Deleting the whole refused('the count claims drained away', ...) block (2 cases):

  • patched: exit 1, naming it precisely — battery 8 registered 18 case(s), below its pinned floor of 20.
  • pre-patch, same deletion: exit 0, printing 64 cases over 9 batteries
    and 12 structural cases are refused — the PM's observation reproduced.
  • restored: exit 0, blob hash identical to HEAD, git diff HEAD empty.

The gate runs from source (a plain .mjs, no build, no dist/), so there is no
rebuild leg to prove — the on-disk edit is what runs.

Verification

  • pnpm check:docs-spec-enumerations — exit 0; same 4 count claims found as
    before the change, so the exclusion is a no-op on today's tree.
  • self-test — exit 0, 79 cases over 10 batteries.
  • 63 derived gate families, reconciled with recorded exit codes:
    dispatch-gates --ran exits 0 — 0 unrun, 55 green, 5 NOT MEASURED
    (exit 3, PREREQUISITE NOT MET — they read dist/, which this diff cannot
    affect), 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-test and check:bash32-floor both fail because
    /bin/bash here is 3.2.57 and has no mapfile; check:merge-driver fails on
    a runner it cannot find. All three are green on CI's Linux.
  • pnpm lint (repo-wide eslint . --no-inline-config, the full population, no
    narrowing) — exit 0 in 27s at ec579915a.
  • turbo ls --affected against the merge base — 0 packages, so no package test
    or typecheck is owed.
  • control-character sweep over the diff — clean.

Generated by Claude Code

…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>
@hotlong hotlong added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 10, 2026
@github-actions github-actions Bot added size/xl ci/cd dependencies Pull requests that update a dependency file labels Sep 10, 2026
…/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
hotlong marked this pull request as ready for review September 10, 2026 15:48
@hotlong
hotlong enabled auto-merge September 10, 2026 15:48
@hotlong
hotlong added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 0f38407 Sep 10, 2026
41 checks passed
@hotlong
hotlong deleted the claude/issue-17388-docs-subpath-enumerations-gated branch September 10, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No gate holds the hand-written docs enumerations of @objectstack/spec subpaths and protocol namespaces equal to the exports map

1 participant