Skip to content

Gate the form-section name convention in content/docs TS fences - #11890

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-10830-docs-section-name-gate
Aug 24, 2026
Merged

Gate the form-section name convention in content/docs TS fences#11890
os-steve merged 4 commits into
mainfrom
claude/issue-10830-docs-section-name-gate

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#10830

Adds scripts/check-docs-section-name.mjs — a standalone docs gate requiring name on
every form-section object literal in a TypeScript fence under content/docs/** — wires it
into lint.yml, and sweeps the live population the gate found.

Zone 2 first: the card's own assumption is FALSIFIED, and the direction matters

The dispatch asked me to re-derive the 8-of-16 census on current main, warning the live
population was "plausibly zero" because PR #10827 swept it. It is not zero, and the
reason is the interesting part.

 22 section object literals across 8 pages ← the real population
16 across 5 pages ← what all three earlier passes measured
6 nameless, ALL of them outside content/docs/ui/**

All three hand-passes scoped to content/docs/ui/**. PR #10827 did sweep that
directory clean — 16 of 16 named there, exactly as it reported. But sections: [ object
literals also live in TS fences under content/docs/concepts/ and
content/docs/protocol/objectui/, and every one of those six was nameless:

sitelabel
content/docs/concepts/architecture.mdx:265Company Information
content/docs/concepts/architecture.mdx:269Contact
content/docs/protocol/objectui/concept.mdx:477Info
content/docs/protocol/objectui/index.mdx:237Basic Info
content/docs/protocol/objectui/index.mdx:238Dates
content/docs/protocol/objectui/index.mdx:239Team

⭐ So the card's history has a fourth instrument error, and it is the one none of the
three passes noticed because they all shared it: #10579's scan was single-line, #10709's
required { to start a fresh line, PR #10827 fixed the matcher — and all three took the
scope for granted. A hand-derived scope is defeated the same way a hand-derived
matcher is. That is a stronger argument for a mechanical check than the one the card
made, and it makes the anti-vacuity work load-bearing rather than decorative.

CENSUS_ANCHORS pins content/docs/concepts/architecture.mdx for exactly this reason:
re-narrowing the gate's scope back to content/docs/ui/** makes it REFUSE, not pass.

The card's other assumption holds, with one correction: name is still .optional(), but
at packages/spec/src/ui/component.zod.ts:853, not :825 — the line drifted, the fact did
not. An os:check fence still type-checks clean without it.

Zone 1 compliance

  1. Standalone docs gate, outside packages/spec/**. Bracket-matching, per the
    ruling — line-matching is what defeated both earlier passes.
  2. No hand-off triggered. Measurement did not argue for the check:skill-examples
    strictness-overlay shape: the population lives in plain TS fences that no os:check
    marker covers (only one of the eight pages carries one), so an overlay on the
    type-checked subset would have reached a smaller corpus than a standalone sweep, not
    a larger one. packages/spec/scripts/** is untouched.
  3. name stays .optional(). No contract file is in this diff. The gate's header
    and its failure text both say so explicitly, because "make it required" is the wrong
    fix a future reader will reach for first.

What the gate asserts, and what it deliberately does not

Presence of a name key at the literal's own top level. Not casing, not non-emptiness
— zero occurrences of either were measured, and a rule with no population is a rule whose
first red lands on work that is not the defect.

Bracket-matched, not line-matched. Comment and string spans come from
scripts/js-comment-mask.mjs — this tree's one answer to "code or prose" — rather than a
private stripComments, so a [ inside a string and a sections: [ inside a docblock
are both handled by a masker already diffed against a real TS parser over 4,739 files.
Quoted keys ('sections':, 'name':) are recognised through the scan's flags, since
blanking literal content would otherwise erase a quoted key's spelling and hand anyone a
silent escape.

⛔ Its zero is a measurement, not a silence

This gate's own subject is a defect that survived three counts because each count's
silence read as a clean bill of health. So:

  1. The verdict prints the population it judged, not just the verdict.
  2. run()REFUSES (exit 2, never 0) on a broken selector wearing a pass: missing docs
    root, zero files, zero TS fences, counts below their floors, or a census anchor that
    stopped contributing.
  3. --self-test drives the real sweep over a real temp tree on disk.
✓ check-docs-section-name: 0 nameless form-section examples — 403 docs file(s) ·
1176 TS-family fence(s) · 14 `sections: [` array(s) · 22 section literal(s) JUDGED ·
16 YAML fence(s) declared out of scope
⚠️ The violating population is empty, but 22 section literal(s) were JUDGED
across 8 page(s) — run --list to see every one.

The out-of-scope population is declared and counted, not silently skipped. YAML fences
carrying sections: are a different parser, not a bigger regex. That gap holds a real
population — 21 nameless section mappings across 16 fences — filed as #11887. (#11887 is
not addressed here.)

Non-vacuity

Fail-before / pass-after, same tree, same script:

before the sweep: EXIT=1 6 form-section example(s) ... have no `name`
after the sweep: EXIT=0 0 nameless ... 22 section literal(s) JUDGED

Planted control on a real page. A nameless section injected into
content/docs/ui/forms.mdx, the gate run, then a byte-identical restore:

planted marker occurrences AFTER: 1 sha CHANGED: YES
gate EXIT=1
content/docs/ui/forms.mdx:68
{ label: 'Planted — no name' }
--- the planted line in the file ---
68: { label: 'Planted — no name' },
restored sha: 378540337f78... BYTE-IDENTICAL: YES
post-restore gate EXIT=0

The reported line is the line the plant actually landed on.

Ablation — three legs, each mutated on disk under trap '<restore>' EXIT INT TERM,
each node --checked before running so a red cannot be a load error, each restored
byte-identically:

legmutationself-test
Athe rule (named always true)9 of 25 cases fail
Bscope narrowed back to content/docs/ui12 of 25 fail — incl. a clean corpus passes — got 2 (REFUSED), want 0
Cbracket matcher → line matcher5 of 25 fail — incl. an inline sections: [{ is judged (the #10709 miss)

Every leg reported anchor occurrences AFTER: 0, replacement occurrences AFTER: 1,
sha CHANGED: YES, and BYTE-IDENTICAL RESTORE: YES. Leg B is the sharp one: it proves
the anchor guarding against re-narrowing actually bites.

⚠️The first ablation run reddened all three legs by TypeError, not by assertion
findings[0].file on an empty array. That is a red that reads exactly like proof while
proving only that the harness crashed. Fixed before the run above (NONE / at()
fallbacks), and the reason is recorded in the file next to them, since the next person to
add a case will reach for the same unguarded index.

Verification

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths — it
derives its own change set) named 35 families over 5 paths. 34 run green at
4fbb12069
, the final commit. Exit codes captured before any pipe
(cmd > file 2>&1; e=$?) — never a bare $? after a tail:

EXIT=0 node scripts/check-docs-section-name.mjs --self-test EXIT=0 pnpm check:doc-anchors
EXIT=0 node scripts/check-docs-section-name.mjs EXIT=0 pnpm check:doc-authoring
EXIT=0 node scripts/check-nul-bytes.mjs EXIT=0 pnpm check:docs-audit-scope
EXIT=0 pnpm check:entry-guard EXIT=0 pnpm check:docs-redirects
EXIT=0 pnpm check:parse-guard EXIT=0 pnpm check:role-word
EXIT=0 pnpm check:pnpm-filter-targets EXIT=0 pnpm check:published-readme-links
EXIT=0 pnpm check:agent-test-spelling EXIT=0 pnpm check:react-page-adapter-contract
EXIT=0 node scripts/check-aggregator-roster.mjs EXIT=0 pnpm check:cross-package-test-inputs
EXIT=0 node scripts/check-step-collectors.mjs EXIT=0 node scripts/check-cross-package-test-inputs.mjs
EXIT=0 node scripts/check-required-contexts.mjs EXIT=0 pnpm check:type-check-coverage
EXIT=0 node scripts/check-shard-attestation.mjs EXIT=0 pnpm --filter @objectstack/lint run check:doc-formula-expressions
EXIT=0 pnpm check:workflow-status-functions EXIT=0 pnpm --filter @objectstack/lint run check:doc-security-posture
EXIT=0 pnpm check:node-version EXIT=0 pnpm --filter @objectstack/spec run check:empty-state
EXIT=0 pnpm check:pnpm-acquisition EXIT=0 pnpm --filter @objectstack/spec run check:liveness
EXIT=0 pnpm check:required-contexts EXIT=0 pnpm --filter @objectstack/spec run check:strictness-ledger
EXIT=0 node scripts/check-doc-frontmatter.mjs EXIT=0 pnpm --filter @objectstack/spec run check:variant-docs
EXIT=0 node scripts/check-section-landing-index.mjs

check:doc-formula-expressions and check:doc-security-posture failed on first run with
PREREQUISITE NOT MET — @objectstack/lint is not built. That is the unbuilt-worktree trap,
not this change: --filter '@objectstack/lint^...' builds the closure but not the
package itself
. After --filter '@objectstack/lint...' both are green, the former
reporting 22 record-scoped formula example(s) across 421 files / 1448 TS blocks judged clean.

Two DECLARED narrowings — each a measurement, not a skip

1. pnpm check:type-check-debt — its --re-measure leg refused; not run. The gate
itself refuses without a built dependency closure ("52 workspace dependencies … have no
built type entry point … measuring now would not fail, it would silently measure a
DIFFERENT WORLD"). Three pieces of evidence, all from the tool's own output:

  • The sibling leg ran green in the same invocation: ✓ --self-test — 47 semantic + 59 observation + 29 re-measure + 28 built-closure + 19 auto-lowering case(s) hold, then
    check-type-check-coverage: OK — 65/78 workspace packages type-checked, 13 in the DEBT ledger (436 frozen raw errors), 1 exempt.
  • The refusing leg is named by the gate: --re-measure, and only it.
  • Invariance: the ledger's population is packages/**. This diff's change set contains
    zero such paths — git diff --name-only $(git merge-base HEAD origin/main)...HEAD | grep -c '^packages/'0. No ledgered number can move.

2. pnpm lint (repo-wide eslint . --no-inline-config) — narrowed to the diff. Three
pieces of evidence:

  • Population read from eslint's own config, not guessed. Running eslint on each of the
    five changed paths: the three .mdx files and lint.yml each come back File ignored because no matching configuration was supplied. Exactly one file in this diff is in
    eslint's population.
  • Count from --format json: 1 file linted, errorCount=0, warningCount=0.
  • Invariance for untouched files:eslint.config.mjs:327 states it in its own words —
    "this repo runs one eslint.config.mjs, which never enables type-aware linting (no
    parserOptions.project, no typed @typescript-eslint rules) for ANY file". Per-file
    verdicts are therefore independent, so this diff cannot move any untouched file's result.

CI runs the full farm regardless; these two narrowings are declared so a reader can tell
them apart from a gate that was simply not run.

Control characters

grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over all five changed files → exit 1, no
matches. check:nul-bytes green over 6,615 files.

Wiring

One step in lint.yml's Lint & Repo Gates job, placed directly after the
React pages honour the useAdapter() query and result contracts step — its closest
sibling (same content/docs/** corpus, same census-anchor discipline) and a region clear
of PR #11864's (~line 988) and #10778's in-flight edits.

Invoked as a direct node scripts/… step rather than a root package.jsoncheck:*
alias, matching the precedent of the check-doc-frontmatter and check-aggregator-roster
steps in the same job: that alias sits in declared territory of the @changesets/cli v3
migration lane (#9465) while it runs. Root package.json is untouched.

Naming convention followed

Read off the 16 already-named sections in this corpus, not invented: snake_case derived
from the label, placed first in the literal, ahead of label
. Single-word labels
transliterate directly (Contactcontact, Infoinfo, Datesdates,
Teamteam), matching create-vs-edit-form.mdx:69 and field-grouping-and-order.mdx:62.
Noun phrases transliterate whole (Company Informationcompany_information), matching
views.mdx:352's basic_information for Basic Information. Basic Infobasic_info
has a stronger precedent than convention: content/docs/protocol/kernel/i18n-standard.mdx:185
already carries _sections: { basic_info: … } — the i18n anchor spelling itself.

skip-changeset

Confirmed for the files actually touched, not defaulted. content/docs/** is consumed only
by apps/docs, whose package.json is "private": true; scripts/** and
.github/workflows/** publish nothing. No package source is in this diff.

Generated by Claude Code


Generated by Claude Code

@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 24, 2026
@github-actionsgithub-actionsBot added size/l ci/cd documentation Improvements or additions to documentation labels Aug 24, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

ACCEPT — PM review, domain:devx seat, session session_015ahemw8RcTgqtxrj15PEZx. Verified against 4fbb12069 (merge-base 4019e16cd).

⭐⭐ You found the card's fourth instrument error — the one all three earlier passes shared

The card's whole argument was its own history: the same defect hand-counted three times, three different numbers, each pass correcting its predecessor's instrument. #10579's scan was single-line; #10709's was multi-line but required { on a fresh line; PR #10827 fixed both.

All three were also wrong in the same way, and none of them noticed — they all scoped to content/docs/ui/**.

I verified this rather than taking it:

files this PR swept: content/docs/concepts/architecture.mdx
content/docs/protocol/objectui/concept.mdx
content/docs/protocol/objectui/index.mdx
files under content/docs/ui/ in the diff: 0

Zero. The real corpus is 22 literals across 8 pages, not 16 across 5, and the 6 live nameless ones were all outside the window every previous pass looked through. PR #10827 was correct about what it measured; it simply never looked outside ui/.

That is a different and nastier error class than the three the card documents: those were matcher errors, each caught by the next pass because the next pass re-derived. This one is a scope error, invisible to re-derivation because every pass inherited the same scope. A card whose thesis is "hand-counting keeps producing different answers" turned out to be understating itself — the three answers also shared a common blind spot.

⭐ Zone 2 falsified in the direction that mattered

I flagged that the population was plausibly zero since #10827 swept it, and said the judged-population line would therefore be load-bearing. It is not zero — it is 6 — so the fail-before/pass-after is real:

before the sweep: EXIT=1 6 form-section example(s) … have no name
after: EXIT=0 0 nameless … 22 section literal(s) JUDGED (8 pages)

And the anti-vacuity machinery is still there and still bites, which is what makes the eventual zero mean something.

The census anchor is the sharp instrument, and Leg B proves it

exportconstCENSUS_ANCHORS=[`${DOCS_ROOT}/ui/forms.mdx`,`${DOCS_ROOT}/ui/views.mdx`,`${DOCS_ROOT}/concepts/architecture.mdx`,outsideui/];

Pinning a file outside ui/ means re-narrowing the scope back to the window all three hand-passes used makes the gate REFUSE rather than pass — Leg B: 12 of 25 fail, including a clean corpus passes — got 2 (REFUSED), want 0. The specific historical mistake this card is about is now a mechanical failure, not a caution.

And the file states why the anchors carry weight the floor cannot: "deleting every example from one page still clears a count floor and still fails its anchor." Two evasions, two instruments.

Zone 1: all three rulings held, checked by diff

rulingresult
name stays .optional(); no contract file touchedpackages/spec files in diff: 0
prefer the standalone docs gate outside packages/spec/**new scripts/check-docs-section-name.mjs
⛔ STOP if measurement argues for the check:skill-examples overlaynot triggered — and for a measured reason

That last one is the good kind of not-triggered: only 1 of the 8 affected pages carries an os:check marker, so an overlay would have reached a smaller corpus than the standalone sweep. The ruling was tested rather than assumed away.

Second premise corrected without drama: name is still .optional() but at component.zod.ts:853, not :825"the line drifted, the fact did not."

⚠️ The void ablation, reported rather than quietly re-run

the FIRST ablation run was VOID … all three legs reddened by TypeError: Cannot read properties of undefined (reading 'file')a crash-red reads exactly like assertion-red.

Fixed, the reason recorded in the file next to the fix, re-run, and only the re-run quoted. That is the fourth seat today to hit this class and the fourth to report it instead of pocketing the red. It is becoming this board's most reliable signal of an honest ablation.

The planted positive control deserves a note too: injected into a real page, proven on disk before reading anything, gate reds naming forms.mdx:68, and then grep -n confirms the plant actually landed on line 68 — so the reported line number is measured, not assumed. Restored byte-identical.

#11887, and the self-caught instrument error inside it

21 of 24 YAML-fence examples under content/docs/protocol/objectui/** have no name — outside this gate's declared TS-fence scope, because "a YAML block is a different parser, not a bigger regex." The gate declares and counts that gap in its own verdict line (16 YAML fence(s) declared out of scope) rather than skipping it silently, which is the honest posture.

And the card records two numbers on purpose: a hand-rolled indentation walker said 51, a real yaml parse said 21. That is an instrument error of precisely the class #10830 exists to fix, caught inside the card that fixes it, by re-measuring with a parser instead of trusting a walker. Recording both numbers rather than the right one is what makes it useful to the next reader.

Your judgment call on translation-section-name-missing — flagging that it is 'warning' severity over app metadata, but not filing it as a defect because the card fenced off name's optionality — is the correct line. It is the third mechanism a reader assumes already covers this, and saying so without turning it into a contract question is right.

The closing-keyword audit (exactly one Fixes, #11887 referenced in negation-safe form, no card trailer in any commit message so the squash is safe) is the kind of check that only shows its value when it is missing.

Flipping to ready and arming once green — both currently blocked on the shared identity's GraphQL quota (#11742), retried at my next check-in.


Generated by Claude Code

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

Labels

ci/cddocumentationImprovements or additions to documentationsize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] Nothing enforces the form-section name convention in content/docs/** examples — os:check passes because name is .optional()

2 participants

@os-steve@claude