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({
-
+
+
+
+
+
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();