From f5a52e13ba15055aeb0a059c44a0bc8c4eb577ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:36:31 +0000 Subject: [PATCH 1/2] feat(scripts): hold every section landing index block to its meta.json (#10738) Adds check-section-landing-index.mjs. A section's meta.json is the routing source fumadocs builds the sidebar from; the landing page's "## What's in this module" block is hand-kept beside it and nothing read the two against each other, so it drifted in both directions one edit at a time with every gate green (#9604 runtime-services, #10339 permissions, #10737 ai). The heading is the opt-in, not the rendering shape: 8 sections declare it, 4 as bullet lists and 4 as Card grids, and the gate reads both identically. 26 landing pages that declare no index block are not held. Committed before the docs fixes so the red is reproducible at this commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SPBxLsqQGCVL5z5UXvgipH --- scripts/check-section-landing-index.mjs | 608 ++++++++++++++++++++++++ 1 file changed, 608 insertions(+) create mode 100644 scripts/check-section-landing-index.mjs diff --git a/scripts/check-section-landing-index.mjs b/scripts/check-section-landing-index.mjs new file mode 100644 index 0000000000..2d630cf010 --- /dev/null +++ b/scripts/check-section-landing-index.mjs @@ -0,0 +1,608 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-section-landing-index (#10738) -- a section landing page's +// "## What's in this module" block must enumerate its directory's meta.json +// pages, in meta.json order, in both directions. +// +// node scripts/check-section-landing-index.mjs # the gate +// node scripts/check-section-landing-index.mjs --list # the census, then the verdict +// node scripts/check-section-landing-index.mjs --self-test # verify the checker itself +// +// ## The defect +// +// A section's `meta.json` is the real routing source -- fumadocs builds the +// sidebar and the route order from it. The landing page's index block is +// hand-kept beside it and nothing reads the two against each other, so it +// drifts in BOTH directions, one edit at a time, and every gate stays green. +// +// Measured instances, each found by a human noticing rather than by a check: +// `runtime-services` omitted `services.sms` (#9604); `permissions` named 14 of +// 21 pages (#10339); `ai` omitted `connect-mcp`, which is meta.json's FIRST +// content page and was linked nowhere else on that page (#10737). +// +// Nothing breaks at runtime. The cost is that an index block's whole job is to +// be a trustworthy map: a reader who does not find React Pages in the list +// concludes the module has no React Pages page. `content/docs/` is also the +// corpus humans and AIs copy from, so a short list is read as a fact about the +// platform's surface. Declared = enforced. +// +// ## Check, don't generate (the #9604 precedent, re-measured here) +// +// #9604 did not derive its list -- it topped the list up by hand AND added a +// checker. That is the repo's answer to this class, and the measurement behind +// it still holds: `meta.json` carries SLUGS ONLY, so a generated block would +// have to synthesize link text from frontmatter, and doing that regresses a row +// that exists today -- `permissions/access-recipes.mdx` is titled "Who can see +// data / automation / interface" and would render as that instead of the +// curated "Access Recipes". The editorial glosses exist in no source file at +// all. +// +// So this gate reads HREFS and nothing else. Link text, glosses, bolding, +// grouping headings and `` descriptions are the page's own business -- +// which is what lets one rule hold eight pages that gloss four different ways +// (`permissions` glosses 3 of 20, `ai` 8 of 8, `plugins` bolds its link text, +// the `` grids carry a `description` attribute instead). +// +// ## What opts a page in: the heading, not the rendering shape +// +// #10738 left open whether the ``-grid sections were "a different +// object", and its own correction noted that `api/index.mdx` has BOTH a bullet +// list and a `` block, so shape "cannot be treated as disjoint". +// Measured on this tree, shape is simply the wrong discriminator. The right one +// is the heading itself: writing `## What's in this module` is the author +// declaring "this block is the index of this module", and it is opt-in, so a +// page that means something else never acquires an obligation it did not ask +// for. +// +// Eight sections declare it -- four bullet lists (`ai`, `api`, `permissions`, +// `plugins`) and four `` grids (`automation`, `data-modeling`, `kernel`, +// `ui`) -- and the gate reads both spellings identically. The nine landing +// pages that DON'T declare it are exactly the ones a set-and-order rule would +// have been wrong about: `protocol/objectui` is a curated "For Implementers" +// reading list that mixes in `/docs/references/` links, `getting-started`'s +// "Next Steps" deliberately points OUT of its module, and `concepts`, +// `protocol`, `protocol/kernel`, `protocol/objectql`, `capabilities`, +// `deployment` and `releases` are narrative pages, not indexes. None of them is +// touched here, and none of them can be caught by accident: they would have to +// write the heading first. +// +// ## The rule, in one strength +// +// Inside the block, for the section's OWN pages: +// +// 1. every `meta.json` content page is linked at least once; +// 2. every in-section link resolves to a page `meta.json` declares; +// 3. those links, in first-appearance order, follow `meta.json` order. +// +// Order is held for all eight rather than only for the bullet lists, because it +// costs nothing to hold: seven of the eight already satisfy it, and the eighth +// (`ui`) is the drifted one this gate was written for. A weaker +// coverage-only strength for grids was considered and dropped -- it would have +// bought nothing and left the two shapes reading differently for no measured +// reason. +// +// Links pointing OUTSIDE `/docs/
/` are ignored entirely, never +// counted and never ordered. That is the clause that lets an editorially +// curated block stay curated: `ai`, `api`, `permissions` and `plugins` each end +// their block with cross-references into `/docs/protocol/` and +// `/docs/references/`, and all four are green. +// +// ## Refusing rather than passing +// +// This gate computes its own population, so a parse that stopped matching would +// print a confident green over a tree it never read (#4690's family). Three +// refusals close that: an empty census, a section whose block contains no +// in-section link at all, and a census smaller than `EXPECTED_MIN_SECTIONS`. +// A `meta.json` page with no file on disk is also refused -- the rule would +// otherwise demand that an author link a page that does not exist, so that is +// a meta.json defect reported as one, not an index defect. +// +import { readFileSync, readdirSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join, dirname, relative, sep } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const repoRoot = () => join(HERE, '..'); + +/** The docs tree this gate sweeps. */ +const DOCS_ROOT = 'content/docs'; + +/** + * The heading that opts a landing page in. One spelling, ASCII apostrophe -- + * swept over this tree when the gate was written: eight `index.mdx` files carry + * it, all with U+0027, no variant wording ("in this chapter"/"in this section") + * exists anywhere under `content/docs`, and no NON-index page carries it. + * A variant is therefore not a spelling to tolerate here; it is a page opting + * out, and it would go unread. Widening this is a deliberate act. + */ +export const INDEX_HEADING = "What's in this module"; + +/** + * The census floor. Eight sections declare the heading today. This may go UP + * freely -- a new section that writes the heading is simply covered -- but it + * may only go DOWN in a change that says which section stopped being an index + * and why. Its job is to catch the silent direction: a block parser that + * stopped matching reports every remaining section green, and without a floor + * a census collapsing from 8 to 1 is indistinguishable from a clean run. + */ +export const EXPECTED_MIN_SECTIONS = 8; + +/** Directories that are never a docs section. */ +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.turbo', '.next']); + +// --------------------------------------------------------------------------- +// Reading + +/** + * Every directory under `docsRoot` holding BOTH a `meta.json` and an + * `index.mdx`, as section ids (`ui`, `kernel/runtime-services`). + */ +export function findSections(docsRoot) { + const out = []; + const walk = (dir) => { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue; + const p = join(dir, e.name); + if (existsSync(join(p, 'meta.json')) && existsSync(join(p, 'index.mdx'))) { + out.push(relative(docsRoot, p).split(sep).join('/')); + } + walk(p); + } + }; + walk(docsRoot); + return out.sort(); +} + +/** + * The content pages a `meta.json` declares, in order. + * + * `index` is the landing page itself and `---Group---` entries are sidebar + * group labels, not pages -- `ui/meta.json` carries a `---Recipes---` label, so + * its 17-entry array is 15 content pages. Both are dropped here rather than at + * the call sites, so a caller cannot forget one. + */ +export function readMetaPages(metaText) { + const meta = JSON.parse(metaText); + const raw = Array.isArray(meta.pages) ? meta.pages : null; + if (!raw) return { pages: null, reason: 'meta.json has no "pages" array' }; + const pages = raw.filter((p) => typeof p === 'string' && p !== 'index' && !p.startsWith('---')); + return { pages, reason: null }; +} + +/** + * Blank out fenced code and MDX expression comments before the block is read. + * + * Both directions matter. A fence containing a `## ` line would end the block + * early and hide every row after it; a link inside a fence or inside a + * commented-out row is text a reader never sees, and counting it would let a + * commented-out row satisfy the gate. Replaced with same-length blanks rather + * than removed, so line numbers still address the real file. + */ +export function maskNonProse(src) { + const blank = (m) => m.replace(/[^\n]/g, ' '); + return src + .replace(/^([ \t]*)(```+|~~~+)[^\n]*\n[\s\S]*?^\1\2[^\n]*$/gm, blank) + .replace(/\{\/\*[\s\S]*?\*\/\}/g, blank); +} + +/** + * The lines of the index block: everything after the heading up to the next + * heading of the SAME OR HIGHER level, or a thematic break, or end of file. + * + * Same-or-higher on purpose. `ui/index.mdx` splits its block with a `### Recipes` + * sub-heading and four more rows under it, matching the `---Recipes---` group + * label in its meta.json; stopping at the next heading of ANY level would drop + * those four rows and report them missing. + * + * @returns {{ start: number, end: number, text: string } | null} 1-based line span + */ +export function readIndexBlock(src) { + const masked = maskNonProse(src); + const lines = masked.split('\n'); + const heading = new RegExp(`^##\\s+${INDEX_HEADING.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`); + const start = lines.findIndex((l) => heading.test(l)); + if (start === -1) return null; + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (/^#{1,2}\s+\S/.test(lines[i]) || /^(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i])) { + end = i; + break; + } + } + return { start: start + 1, end, text: lines.slice(start + 1, end).join('\n') }; +} + +/** + * Doc links in the block, split into the section's own pages and everything + * else. Reads both spellings -- markdown `](/docs/…)` and the `href="/docs/…"` + * of a `` -- because the two shapes are the same object here. + * + * In-section slugs are deduped on FIRST appearance: a page linked twice is not + * a defect, and first appearance is what the order rule reads. + */ +export function readBlockLinks(blockText, section) { + const prefix = `/docs/${section}/`; + const own = []; + const foreign = []; + for (const m of blockText.matchAll(/(?:\]\(|href=["'])(\/docs\/[^)"'\s#]+)/g)) { + const url = m[1].replace(/\/+$/, ''); + if (url.startsWith(prefix)) { + const slug = url.slice(prefix.length); + if (!own.includes(slug)) own.push(slug); + } else { + foreign.push(url); + } + } + return { own, foreign }; +} + +// --------------------------------------------------------------------------- +// The verdict, as a pure function + +/** + * Judge every section handed in. + * + * Pure: every input is an argument, so `--self-test` drives this exact decision + * over mutated inputs instead of a parallel imitation of it. + * + * @param {{ sections: Array<{ id: string, metaText?: string, indexText?: string, + * filesOnDisk?: string[], error?: string }>, minSections?: number }} input + * @returns {{ problems: string[], covered: string[], skipped: string[] }} + */ +export function judge({ sections, minSections = EXPECTED_MIN_SECTIONS }) { + const problems = []; + const covered = []; + const skipped = []; + + for (const s of sections) { + if (s.error) { + problems.push(`${s.id}: ${s.error} -- nothing about it was verified (see #4690).`); + continue; + } + + const block = readIndexBlock(s.indexText ?? ''); + if (!block) { + skipped.push(s.id); + continue; + } + covered.push(s.id); + + let pages; + try { + const read = readMetaPages(s.metaText ?? ''); + if (!read.pages) { + problems.push(`${s.id}: ${read.reason} -- its index block was not verified (see #4690).`); + continue; + } + pages = read.pages; + } catch (err) { + problems.push(`${s.id}: meta.json did not parse (${err.message}) -- its index block was not verified (see #4690).`); + continue; + } + + // A meta.json page with no file is a meta.json defect, reported as one: the + // set rule below would otherwise order an author to link a dead route. + if (Array.isArray(s.filesOnDisk)) { + const onDisk = new Set(s.filesOnDisk); + const ghosts = pages.filter((p) => !onDisk.has(p)); + if (ghosts.length) { + problems.push( + `${s.id}/meta.json declares ${ghosts.length} page(s) with no file: ${ghosts.join(', ')}. ` + + `Fix meta.json (or add the page) -- until then the index block cannot be held to it.` + ); + continue; + } + } + + const { own } = readBlockLinks(block.text, s.id); + + if (own.length === 0) { + problems.push( + `${s.id}/index.mdx: the "## ${INDEX_HEADING}" block (lines ${block.start}-${block.end}) ` + + `links to no /docs/${s.id}/ page at all -- an empty index is refused, never reported OK (see #4690).` + ); + continue; + } + + const missing = pages.filter((p) => !own.includes(p)); + if (missing.length) { + problems.push( + `${s.id}/index.mdx: the "## ${INDEX_HEADING}" block omits ${missing.length} page(s) ` + + `declared in ${s.id}/meta.json: ${missing.join(', ')}. ` + + `Add a row for each (link text is yours to write -- meta.json stores slugs only).` + ); + } + + const undeclared = own.filter((p) => !pages.includes(p)); + if (undeclared.length) { + problems.push( + `${s.id}/index.mdx: the "## ${INDEX_HEADING}" block links ${undeclared.length} /docs/${s.id}/ page(s) ` + + `that ${s.id}/meta.json does not declare: ${undeclared.join(', ')}. ` + + `Either add them to meta.json (they are unreachable from the sidebar) or drop the rows.` + ); + } + + if (!missing.length && !undeclared.length) { + const expected = pages.join(' → '); + const actual = own.join(' → '); + if (expected !== actual) { + problems.push( + `${s.id}/index.mdx: the "## ${INDEX_HEADING}" block lists the right pages in the wrong order.\n` + + ` meta.json: ${expected}\n` + + ` the block: ${actual}` + ); + } + } + } + + if (minSections > 0 && covered.length === 0) { + problems.push( + `No section landing page carries a "## ${INDEX_HEADING}" heading. ` + + `Either the docs tree moved or this gate's block parser stopped matching -- ` + + `an empty census is refused, never reported OK (see #4690).` + ); + } else if (covered.length < minSections) { + problems.push( + `Only ${covered.length} section(s) carry a "## ${INDEX_HEADING}" heading; ${minSections} are expected ` + + `(${covered.join(', ')}). If a section deliberately stopped being an index, lower ` + + `EXPECTED_MIN_SECTIONS in this file in the same change and say which one and why.` + ); + } + + return { problems, covered, skipped }; +} + +// --------------------------------------------------------------------------- +// Running against a real tree + +/** Read one section's inputs off disk. */ +export function readSection(docsRoot, id) { + const dir = join(docsRoot, ...id.split('/')); + try { + const metaText = readFileSync(join(dir, 'meta.json'), 'utf8'); + const indexText = readFileSync(join(dir, 'index.mdx'), 'utf8'); + const filesOnDisk = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.isFile() && e.name.endsWith('.mdx') && e.name !== 'index.mdx') { + filesOnDisk.push(e.name.slice(0, -'.mdx'.length)); + } else if (e.isDirectory() && existsSync(join(dir, e.name, 'index.mdx'))) { + filesOnDisk.push(e.name); + } + } + return { id, metaText, indexText, filesOnDisk }; + } catch (err) { + return { id, error: `could not be read (${err.message})` }; + } +} + +/** The gate over a real docs tree. */ +export function run(docsRoot) { + const sections = findSections(docsRoot).map((id) => readSection(docsRoot, id)); + return { ...judge({ sections }), scanned: sections.length }; +} + +function main() { + const docsRoot = join(repoRoot(), DOCS_ROOT); + if (!existsSync(docsRoot)) { + console.error(`✗ check-section-landing-index: ${DOCS_ROOT} does not exist -- nothing was verified (see #4690).`); + process.exit(1); + } + + const { problems, covered, skipped, scanned } = run(docsRoot); + + if (process.argv.includes('--list')) { + console.log(`Sections with a meta.json + index.mdx: ${scanned}\n`); + console.log(` held to meta.json (carry "## ${INDEX_HEADING}"): ${covered.length}`); + for (const id of covered) console.log(` • ${id}`); + console.log(`\n not an index, not held: ${skipped.length}`); + for (const id of skipped) console.log(` · ${id}`); + console.log(''); + } + + if (problems.length) { + console.error(`✗ check-section-landing-index -- ${problems.length} problem(s)\n`); + for (const p of problems) console.error(` • ${p}`); + console.error( + `\n A section's meta.json is the routing source: fumadocs builds the sidebar and the\n` + + ` route order from it. The "## ${INDEX_HEADING}" block is the reader's map of the\n` + + ` same set, hand-kept beside it. Link text and glosses stay hand-written on purpose\n` + + ` (meta.json stores slugs only); only the hrefs and their order are held.\n` + ); + process.exit(1); + } + + console.log( + `✓ check-section-landing-index: ${covered.length} section index block(s) enumerate their meta.json ` + + `pages, in order, both directions (${covered.join(', ')}); ` + + `${skipped.length} landing page(s) of ${scanned} declare no index block and are not held.` + ); +} + +// --------------------------------------------------------------------------- +// Self-test + +function selfTest() { + const failures = []; + let checked = 0; + const assert = (cond, what) => { + checked++; + if (!cond) failures.push(what); + }; + + /** Build a one-section input for `judge`, so the REAL decision path runs. */ + const section = (id, pages, indexText, filesOnDisk) => ({ + id, + metaText: JSON.stringify({ pages: ['index', ...pages] }), + indexText, + filesOnDisk: filesOnDisk ?? pages, + }); + const one = (s) => judge({ sections: [s], minSections: 1 }).problems; + const bullets = (section_, slugs) => + `# T\n\n## ${INDEX_HEADING}\n\n` + slugs.map((s) => `- [${s}](/docs/${section_}/${s})\n`).join('') + `\n## Related\n\n- x\n`; + const cards = (section_, slugs) => + `# T\n\n## ${INDEX_HEADING}\n\n\n` + + slugs.map((s) => ` \n`).join('') + + `\n\n## Related\n\n- x\n`; + + // ── Both shapes, in sync, are silent ────────────────────────────────────── + assert(one(section('m', ['a', 'b', 'c'], bullets('m', ['a', 'b', 'c']))).length === 0, 'a synced BULLET list is reported OK'); + assert(one(section('m', ['a', 'b', 'c'], cards('m', ['a', 'b', 'c']))).length === 0, 'a synced CARD grid is reported OK'); + + // ── Set, direction A: a page in meta.json with no row ───────────────────── + for (const shape of [bullets, cards]) { + const p = one(section('m', ['a', 'b', 'c'], shape('m', ['a', 'c']))); + assert(p.length === 1 && /omits 1 page\(s\)/.test(p[0]) && /: b\./.test(p[0]), `a MISSING page is named (${shape === bullets ? 'bullets' : 'cards'})`); + } + + // ── Set, direction B: a row for a page meta.json does not declare ───────── + { + const p = one(section('m', ['a', 'b'], bullets('m', ['a', 'b', 'gone']), ['a', 'b', 'gone'])); + assert(p.length === 1 && /does not declare/.test(p[0]) && /gone/.test(p[0]), 'an UNDECLARED row is named'); + } + + // ── Order ───────────────────────────────────────────────────────────────── + { + const p = one(section('m', ['a', 'b', 'c'], bullets('m', ['b', 'a', 'c']))); + assert(p.length === 1 && /wrong order/.test(p[0]), 'a REORDERED block fails'); + assert(/a → b → c/.test(p[0]) && /b → a → c/.test(p[0]), 'the order failure prints both orders'); + } + + // ── The real `ui` defect, reproduced: the shape this gate was written for ── + { + const uiMeta = ['apps', 'pages', 'react-pages', 'views', 'actions', 'dashboards', 'reports', 'translations', 'forms', 'doc-pages', 'setup-app']; + const uiHas = ['apps', 'views', 'pages', 'dashboards', 'forms', 'doc-pages', 'setup-app']; + const p = one(section('ui', uiMeta, cards('ui', uiHas))); + assert(p.length === 1 && /react-pages/.test(p[0]) && /actions/.test(p[0]) && /reports/.test(p[0]) && /translations/.test(p[0]), + "the pre-fix `ui` grid fails, naming all four omitted pages"); + } + + // ── Curation survives: out-of-section links are ignored, not counted ────── + { + const text = `# T\n\n## ${INDEX_HEADING}\n\n- [a](/docs/m/a)\n- [b](/docs/m/b)\n- Spec: [x](/docs/protocol/kernel/plugin-spec)\n- Ref: [y](/docs/references/kernel)\n\n## Related\n`; + assert(one(section('m', ['a', 'b'], text)).length === 0, 'FOREIGN links are ignored, never counted or ordered'); + } + + // ── A sub-heading does not end the block (the `ui` "### Recipes" shape) ─── + { + const text = `# T\n\n## ${INDEX_HEADING}\n\n\n \n\n\n### Recipes\n\n\n \n\n\n## Related\n`; + assert(one(section('m', ['a', 'b'], text)).length === 0, 'a `###` sub-heading does NOT truncate the block'); + const truncated = `# T\n\n## ${INDEX_HEADING}\n\n- [a](/docs/m/a)\n\n## Related\n\n- [b](/docs/m/b)\n`; + assert(one(section('m', ['a', 'b'], truncated)).length === 1, 'a `##` heading DOES end the block, so a row after it does not count'); + } + + // ── Masking: a fence cannot end the block, and neither fences nor MDX ───── + // comments may satisfy a row. + { + const fenced = `# T\n\n## ${INDEX_HEADING}\n\n- [a](/docs/m/a)\n\n\`\`\`md\n## Not a heading\n\`\`\`\n\n- [b](/docs/m/b)\n\n## Related\n`; + assert(one(section('m', ['a', 'b'], fenced)).length === 0, 'a `## ` line INSIDE a fence does not end the block'); + const inFence = `# T\n\n## ${INDEX_HEADING}\n\n- [a](/docs/m/a)\n\n\`\`\`md\n- [b](/docs/m/b)\n\`\`\`\n\n## Related\n`; + const p = one(section('m', ['a', 'b'], inFence)); + assert(p.length === 1 && /: b\./.test(p[0]), 'a link inside a FENCE does not satisfy a row'); + const commented = `# T\n\n## ${INDEX_HEADING}\n\n- [a](/docs/m/a)\n{/* - [b](/docs/m/b) */}\n\n## Related\n`; + const q = one(section('m', ['a', 'b'], commented)); + assert(q.length === 1 && /: b\./.test(q[0]), 'a COMMENTED-OUT row does not satisfy a row'); + } + + // ── meta.json shapes: index and group labels are not pages ──────────────── + { + const s = { + id: 'm', + metaText: JSON.stringify({ pages: ['index', 'a', '---Recipes---', 'b'] }), + indexText: bullets('m', ['a', 'b']), + filesOnDisk: ['a', 'b'], + }; + assert(judge({ sections: [s], minSections: 1 }).problems.length === 0, '`index` and `---Group---` entries are not pages'); + } + + // ── Opt-in: a landing page with no heading is skipped, not judged ───────── + { + const r = judge({ sections: [{ id: 'curated', metaText: JSON.stringify({ pages: ['index', 'a', 'b'] }), indexText: `# T\n\n## For Implementers\n\n- [a](/docs/curated/a)\n`, filesOnDisk: ['a', 'b'] }], minSections: 0 }); + assert(r.problems.length === 0 && r.skipped.includes('curated') && !r.covered.includes('curated'), + 'a landing page WITHOUT the heading is skipped, not judged'); + } + + // ── Refusals: none of these may be reported OK ──────────────────────────── + { + const empty = one(section('m', ['a'], `# T\n\n## ${INDEX_HEADING}\n\nProse, no links.\n\n## Related\n`)); + assert(empty.length === 1 && /links to no/.test(empty[0]) && /#4690/.test(empty[0]), 'an EMPTY index block is refused'); + + const ghost = judge({ sections: [{ id: 'm', metaText: JSON.stringify({ pages: ['index', 'a', 'ghost'] }), indexText: bullets('m', ['a']), filesOnDisk: ['a'] }], minSections: 1 }).problems; + assert(ghost.length === 1 && /no file/.test(ghost[0]) && /ghost/.test(ghost[0]), 'a meta.json page with NO FILE is refused as a meta.json defect'); + assert(!/omits/.test(ghost[0]), 'and it is NOT reported as a missing index row'); + + const broken = judge({ sections: [{ id: 'm', metaText: '{ not json', indexText: bullets('m', ['a']), filesOnDisk: ['a'] }], minSections: 1 }).problems; + assert(broken.length === 1 && /did not parse/.test(broken[0]) && /#4690/.test(broken[0]), 'an UNPARSEABLE meta.json is refused, never silent'); + + const noPages = judge({ sections: [{ id: 'm', metaText: '{"title":"t"}', indexText: bullets('m', ['a']), filesOnDisk: ['a'] }], minSections: 1 }).problems; + assert(noPages.length === 1 && /no "pages" array/.test(noPages[0]), 'a meta.json with no `pages` array is refused'); + + const unreadable = judge({ sections: [{ id: 'm', error: 'could not be read (EACCES)' }], minSections: 0 }).problems; + assert(unreadable.length === 1 && /#4690/.test(unreadable[0]), 'an UNREADABLE section is refused, never silent'); + + const censusEmpty = judge({ sections: [{ id: 'x', metaText: '{"pages":["index","a"]}', indexText: '# T\n\nno heading\n', filesOnDisk: ['a'] }] }).problems; + assert(censusEmpty.length === 1 && /No section landing page carries/.test(censusEmpty[0]), 'an EMPTY census is refused'); + + const censusShort = judge({ sections: [section('m', ['a'], bullets('m', ['a']))] }).problems; + assert(censusShort.length === 1 && /are expected/.test(censusShort[0]), `a census below EXPECTED_MIN_SECTIONS (${EXPECTED_MIN_SECTIONS}) is refused`); + } + + // ── The real run() path, over a temp fixture on disk ────────────────────── + const dir = mkdtempSync(join(tmpdir(), 'section-landing-')); + try { + const mk = (id, pages, indexText) => { + const d = join(dir, ...id.split('/')); + mkdirSync(d, { recursive: true }); + writeFileSync(join(d, 'meta.json'), JSON.stringify({ pages: ['index', ...pages] })); + writeFileSync(join(d, 'index.mdx'), indexText); + for (const p of pages) writeFileSync(join(d, `${p}.mdx`), `---\ntitle: ${p}\n---\n`); + }; + mk('good', ['a', 'b'], bullets('good', ['a', 'b'])); + mk('bad', ['a', 'b'], cards('bad', ['a'])); + mk('curated', ['a', 'b'], `# T\n\n## For Implementers\n\n- [a](/docs/curated/a)\n`); + + const r = run(dir); + assert(r.scanned === 3, 'run() finds every directory with meta.json + index.mdx'); + assert(r.covered.join(',') === 'bad,good', 'run() holds only the sections that declare the heading'); + assert(r.skipped.join(',') === 'curated', 'run() skips the curated landing page'); + assert(r.problems.some((p) => /^bad\/index\.mdx/.test(p) && /: b\./.test(p)), 'run() names the drifted fixture and the omitted page'); + assert(!r.problems.some((p) => /^good\//.test(p)), 'run() is silent about the synced fixture'); + assert(r.problems.some((p) => /are expected/.test(p)), 'run() applies the census floor to a real tree'); + + // Nested sections are found too (`kernel/runtime-services` is real). + mk('nest/deep', ['a'], bullets('nest/deep', ['a'])); + assert(run(dir).covered.includes('nest/deep'), 'run() finds a NESTED section'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + + if (failures.length) { + console.error(`✗ check-section-landing-index --self-test -- ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` • ${f}`); + process.exit(1); + } + console.log( + `✓ check-section-landing-index --self-test: ${checked} assertions over synthetic inputs and a temp fixture ` + + `(real judge()/run() path); every limb -- both shapes in sync, missing page, undeclared row, wrong order, the ` + + `pre-fix \`ui\` grid, foreign-link curation, sub-heading vs same-level heading, fence and MDX-comment masking, ` + + `\`index\`/\`---Group---\` filtering, opt-in skipping, and all seven refusals (empty block, page with no file, ` + + `unparseable meta.json, no \`pages\` array, unreadable section, empty census, short census) -- observed FAILING and observed silent.` + ); +} + +// Exports bindings, so an import for those exports alone must run nothing (#10667). +const invokedDirectly = isEntrypoint(import.meta.url); + +if (!invokedDirectly) { + // imported as a module — expose the exports and do nothing else +} else if (process.argv.includes('--self-test')) selfTest(); +else main(); From 28d591a6854bf7c5382a4e325c8ac90b1b08c3eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 06:39:10 +0000 Subject: [PATCH 2/2] fix(docs): top up the ui and data-modeling landing indexes, wire the gate into lint.yml (#10738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content/docs/ui/index.mdx omitted react-pages, actions, reports and translations, and listed views before pages. The react-pages omission is the residual half of the P0 #10638 symptom: the deploy fix restored the page and the sidebar, but the hand-kept grid never got its row, so objectstack.ai/docs/ui still does not show React Pages. content/docs/data-modeling/index.mdx omitted field-type-decision-tree and validation-rules — a second real drift, found by the gate, outside the population the card had measured. permissions/index.mdx's drift note said nothing checks the two against each other; that is no longer true, so it now names the gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SPBxLsqQGCVL5z5UXvgipH --- .github/workflows/lint.yml | 51 ++++++++++++++++++++++++++++ content/docs/data-modeling/index.mdx | 2 ++ content/docs/permissions/index.mdx | 2 +- content/docs/ui/index.mdx | 6 +++- 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 934124548e..7445f25689 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1096,6 +1096,57 @@ jobs: - name: Runtime-services indexes enumerate the chapter's real pages run: pnpm check:runtime-services-index + # #10738 generalises the gate above to every section landing page. The same + # defect shape kept recurring one directory over: a section's meta.json is + # what fumadocs builds the sidebar and route order from, the landing page's + # "## What's in this module" block is the reader's map of the same set, and + # nothing read the two against each other — #9604 (runtime-services, sms), + # #10339 (permissions named 14 of 21), #10737 (ai omitted connect-mcp, its + # meta.json's FIRST content page, linked nowhere else on the page). Three + # instances, three humans noticing, three cards. + # + # The heading is the opt-in, NOT the rendering shape. #10738 had set the + # `Cards`-grid sections aside as "a different object" and then had to + # correct itself when api/index.mdx turned out to carry both shapes. Shape + # is simply the wrong discriminator: writing the heading is the author + # declaring "this block is the index of this module". Eight sections + # declare it — four bullet lists (ai, api, permissions, plugins), four + # `Card` grids (automation, data-modeling, kernel, ui) — and the gate reads + # both spellings identically. The other 26 landing pages under content/docs + # are untouched and cannot be caught by accident; they would have to write + # the heading first. That is what keeps the deliberately curated ones + # curated: protocol/objectui is a "For Implementers" reading list mixing in + # /docs/references/ links, and getting-started's "Next Steps" points OUT of + # its module on purpose. + # + # Hrefs and their order only. Link text, glosses, bolding and `Card` + # descriptions are the page's business — meta.json stores SLUGS ONLY, so a + # generated block would have to synthesize link text from frontmatter, and + # that regresses a row that exists today (permissions/access-recipes.mdx is + # titled "Who can see data / automation / interface" and would render as + # that instead of the curated "Access Recipes"). Check them, don't generate + # them — the #9604 precedent, re-measured. + # + # Invoked as `node scripts/…` rather than through a `pnpm check:*` alias, + # same as the other direct steps in this lane: that alias is a line in root + # package.json, inside the @changesets/cli v3 fence (#9465), and the direct + # form loses no discovery — dispatch-gates.mjs derives gate families from + # either spelling. Precedent: PR #10698. + # + # Runs its own --self-test first, and that leg is load-bearing: this gate + # computes its own population, so a block parser that stopped matching + # would print a confident green over a tree it never read (#4690's family). + # The self-test drives the real judge()/run() path and pins all seven + # refusals alongside the positive cases, so "refuses unconditionally" + # cannot satisfy it. It lives in this job with the other docs guards: the + # edit that breaks it is a docs edit, so a packages/** paths filter would + # blind it to its own failure mode. Reads ~34 meta.json + index.mdx pairs, + # no spawns, no build; ~0.1s. + - name: Section landing indexes enumerate their meta.json pages + run: | + node scripts/check-section-landing-index.mjs --self-test + node scripts/check-section-landing-index.mjs + # #3723 ADR anchors: code an accepted ADR governs must keep naming it. # That incident reversed three accepted ADRs with a patch-level changeset, # and the mechanism was simply that the edited file never mentioned them — diff --git a/content/docs/data-modeling/index.mdx b/content/docs/data-modeling/index.mdx index 65309177e9..fc69a519cb 100644 --- a/content/docs/data-modeling/index.mdx +++ b/content/docs/data-modeling/index.mdx @@ -49,9 +49,11 @@ That one definition is enough to get a persisted table, CRUD + query endpoints, + + diff --git a/content/docs/permissions/index.mdx b/content/docs/permissions/index.mdx index 2068351786..f0d51548e8 100644 --- a/content/docs/permissions/index.mdx +++ b/content/docs/permissions/index.mdx @@ -62,7 +62,7 @@ alike — is [Declaring Capabilities](/docs/permissions/capabilities). {/* Source of truth for this module's page set AND its order is meta.json in this directory — routing and the sidebar are built from it, and the list below mirrors it exactly, minus this page. */} {/* The list is hand-kept on purpose: meta.json stores slugs only, so the link text and the glosses below exist nowhere else, and a generated list would lose them. */} -{/* Drift note: nothing checks the two against each other in either direction, so a page added to or removed from meta.json leaves this list silently stale — edit both in the same change. */} +{/* Checked since #10738: `node scripts/check-section-landing-index.mjs` holds this list's set AND order to meta.json in both directions, for every landing page carrying this heading. Link text and the glosses stay hand-written — the gate reads hrefs only. */} - [Administrator Guide](/docs/permissions/administrator-guide) — the task-first manual for customer system administrators - [Authentication](/docs/permissions/authentication) diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx index 769d1128c7..d9e11ec51b 100644 --- a/content/docs/ui/index.mdx +++ b/content/docs/ui/index.mdx @@ -40,9 +40,13 @@ export const CrmApp = App.create({ - + + + + +