From 5f28297c1515e69350c71ceae23137b69f9fab56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 22:08:40 +0000 Subject: [PATCH] fix(spec): docs category index cards the pages meta.json declares (#11260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated `content/docs/references/security/index.mdx` carded four of the five pages its own `meta.json` declares. The fifth, `misc`, was generated and routed in the sidebar but unreachable from the category overview. Both files come out of one `gen:docs` run from two enumerations of "the pages of this category": `meta.json` from the pages the run emitted, the card grid from the `.zod.ts` files on disk. `misc` is the catch-all for a published schema no `.zod.ts` accounts for, so it has no source file by definition and was structurally absent from the second enumeration — the card loop never considered it, and the `wasEmitted` guard its comment leaned on never ran for it. The grid now iterates the list `meta.json` was built from and keeps the `wasEmitted` guard, so a page that produced no reference file still cannot be carded into a dangling 404 — but a declared page the run did not emit now stops the build instead of silently thinning the grid. The invariant the comment claimed is true by construction rather than by coincidence, which also closes the all-`misc` category edge: it can no longer render an empty grid, and its overview is emitted exactly when its `meta.json` is. Regenerated output is one line: security/index.mdx gains its `misc` card, with no "Source:" line since there is no file to point at. The other 13 category grids are byte-identical. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2 --- ...ocs-category-index-cards-declared-pages.md | 46 +++++++ content/docs/references/security/index.mdx | 1 + packages/spec/scripts/build-docs.ts | 74 ++++++++--- packages/spec/scripts/category-index.test.ts | 115 ++++++++++++++++++ packages/spec/scripts/lib/category-index.ts | 97 +++++++++++++++ 5 files changed, 318 insertions(+), 15 deletions(-) create mode 100644 .changeset/docs-category-index-cards-declared-pages.md create mode 100644 packages/spec/scripts/category-index.test.ts create mode 100644 packages/spec/scripts/lib/category-index.ts diff --git a/.changeset/docs-category-index-cards-declared-pages.md b/.changeset/docs-category-index-cards-declared-pages.md new file mode 100644 index 0000000000..f2ba123598 --- /dev/null +++ b/.changeset/docs-category-index-cards-declared-pages.md @@ -0,0 +1,46 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): the generated docs category index cards the pages its `meta.json` declares (#11260) + +`content/docs/references/security/index.mdx` carded four of the five pages the +`meta.json` beside it declares. The fifth, `misc`, was generated and routed in +the sidebar — and unreachable from the one page whose job is to reach it. + +Both files come out of one `gen:docs` run, from two enumerations of "the pages +of this category" that disagreed about exactly one bucket: + +- `meta.json` was built from the pages the run **emitted**, which is where a + published schema that no `.zod.ts` accounts for lands (`security/` declares + two in plain `.ts` files, so they fall to the `misc` catch-all); +- the card grid was built from the `.zod.ts` files **on disk**. + +`misc` has no `.zod.ts` behind it by definition — the generator says so twice, +and `sourcePathFor` returns nothing for it precisely so the page prints no +invented "Source:" line — so it was *structurally* absent from the second +enumeration. The card loop never considered it, which also means the +`wasEmitted` guard that the loop's own comment leaned on ("This aligns the +index with `meta.json`") never ran for it. The comment was wrong in the shape +that reads as verified: it named the invariant while the code held it by +coincidence, for the 13 categories where two independent enumerations happen to +agree. + +The grid now iterates the list `meta.json` was built from, and keeps the +`wasEmitted` guard — a `.zod.ts` whose schemas are all unrepresentable in JSON +Schema still cannot be carded into a dangling 404. Because both files now read +one list, that guard can no longer thin the grid silently: a declared page the +run did not emit stops the build naming it. The stated invariant is true by +construction rather than by coincidence, which closes the class instead of +special-casing `misc`. + +The regenerated output is one line: `security/index.mdx` gains its `misc` card +(with no "Source:" line, correctly). The other 13 category grids are +byte-identical. + +An all-`misc` category — every published schema in the catch-all — would have +rendered an **empty** `` grid under the old loop, with every zod-derived +slug filtered out and `misc` never considered. No such category exists in the +repo, so no emitted file can pin it; the rule moved into +`scripts/lib/category-index.ts` so it can be asserted directly, and an empty +grid is now unreachable from a non-empty declaration. diff --git a/content/docs/references/security/index.mdx b/content/docs/references/security/index.mdx index 16562cf020..1aedff8c55 100644 --- a/content/docs/references/security/index.mdx +++ b/content/docs/references/security/index.mdx @@ -7,6 +7,7 @@ This section contains all protocol schemas for the security layer of ObjectStack + diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index 4bd11a52b0..f39c10f63b 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -26,6 +26,7 @@ import path from 'path'; // a confident page from a tree nobody rebuilt (#4675, #4723). import { schemaTreeIsStale } from '../../../scripts/check-regen-pending.mjs'; +import { categoryGrid } from './lib/category-index'; import { resolveCategoryTitles } from './lib/category-title'; import { evaluateBaseline, @@ -154,6 +155,23 @@ const categoryZodFiles = new Map>(); * grand total — cannot disagree with the pages themselves (#4759). */ const categoryPageSchemas = new Map>(); +/** + * `category` -> the `pages` array written to that category's `meta.json`, + * separators included. + * + * Filled by §2 as it emits each `meta.json`, and read back by §2.5 so the card + * grid enumerates the pages the category DECLARES instead of re-deriving a + * second list from the `.zod.ts` files on disk. Those two enumerations + * disagreed for exactly one bucket — the `misc` catch-all, which by definition + * has no `.zod.ts` behind it — and the grid was the side that lost it (#11260). + * See `lib/category-index.ts` for the defect and why the fix is by + * construction rather than a `misc` special case. + * + * Absent for a category that published no page: §2 writes no `meta.json` for + * one, and §2.5 correspondingly writes no `index.mdx`, so the overview exists + * exactly when the thing it is an overview OF does. + */ +const categoryMetaPages = new Map(); /** * Page slug -> its real path under `packages/spec/src//`. * @@ -788,12 +806,41 @@ PAGES_BY_CATEGORY.forEach((zodFileSchemas, category) => { pages }; emit(path.join(categoryDir, 'meta.json'), JSON.stringify(meta, null, 2)); + + // Hand the declared list to §2.5 — never a second enumeration, the same + // discipline `categoryPageSchemas` applies two statements up for the root + // index. The grid used to re-derive its own list and lost `misc` (#11260). + categoryMetaPages.set(category, pages); }); // 2.5 Generate Category Overviews (index.mdx in each folder) Object.entries(CATEGORIES).forEach(([category, title]) => { - const zodFiles = categoryZodFiles.get(category) || new Set(); - if (zodFiles.size === 0) return; + // The pages §2 DECLARED for this category, not a second list derived from the + // `.zod.ts` files: `misc` is a real page with no `.zod.ts` behind it, so a + // source-derived list cannot contain it and the grid dropped it (#11260). + // Keyed off the same map that decides `meta.json`, so "no meta.json ⇒ no + // index.mdx" needs no second guard to agree with §2's. + const declared = categoryMetaPages.get(category) || []; + if (declared.length === 0) return; + + const { cards, undelivered } = categoryGrid(declared, page => + wasEmitted(path.join(DOCS_ROOT, category, `${page}.mdx`)), + ); + + // Both lists come from the page map §2 emitted from, so this cannot fire on + // any content state — only on a future edit that reintroduces the split the + // `misc` omission came from. Loud, because the failure mode it replaces was + // a grid quietly one card short of the pages it claimed to align with. + if (undelivered.length > 0) { + console.error( + `\n✗ ${path.relative(REPO_ROOT, path.join(DOCS_ROOT, category, 'index.mdx'))} would card ` + + `${cards.length} of the ${cards.length + undelivered.length} pages its meta.json declares.\n` + + ` Undelivered: ${undelivered.join(', ')}\n\n` + + ` meta.json and this grid are built from the same page map, so a declared page this\n` + + ` run did not emit is a generator bug, not a content state (#11260).`, + ); + process.exit(1); + } let mdx = `---\n`; mdx += `title: ${title}\n`; @@ -803,19 +850,16 @@ Object.entries(CATEGORIES).forEach(([category, title]) => { mdx += `This section contains all protocol schemas for the ${category} layer of ObjectStack.\n\n`; mdx += `\n`; - Array.from(zodFiles).sort().forEach(zodFile => { - // Only card zod files that actually produced a reference page. A - // `.zod.ts` whose schemas are all unrepresentable in JSON Schema — e.g. - // they embed a transform (the ADR-0031 control-flow constructs and the - // Flow edge schema carry CEL-expression transforms) — generates no page, - // so carding it would be a dangling 404 link. This aligns the index with - // `meta.json`, which already lists only generated pages. - // - // Asks the sink, not the disk: this run's own output is the authority on - // what pages exist. (Equivalent on disk, since the folder was just wiped - // and rewritten — but it stays correct under --check, where nothing is - // written and the stale files are still lying around.) - if (!wasEmitted(path.join(DOCS_ROOT, category, `${zodFile}.mdx`))) return; + // `cards` is the declared pages that this run emitted — the `wasEmitted` + // guard is kept and applied in `categoryGrid`, so a `.zod.ts` whose schemas + // are all unrepresentable in JSON Schema (they embed a transform — the + // ADR-0031 control-flow constructs and the Flow edge schema carry + // CEL-expression transforms) still cannot be carded into a dangling 404. + // What changed is that the guard now runs over the SAME list `meta.json` was + // built from, so "this aligns the index with meta.json" is true by + // construction instead of true for the categories where two independent + // enumerations happened to agree. + cards.forEach(zodFile => { const fileTitle = zodFile.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); const cardSource = sourcePathFor(category, zodFile); // Link relative to the category folder (where index.mdx lives) diff --git a/packages/spec/scripts/category-index.test.ts b/packages/spec/scripts/category-index.test.ts new file mode 100644 index 0000000000..3423044f64 --- /dev/null +++ b/packages/spec/scripts/category-index.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin for the category-overview card grid — WHICH pages + * `content/docs/references//index.mdx` cards (#11260). + * + * THE DEFECT THIS PINS. `build-docs.ts` wrote a category's `meta.json` from the + * pages it had just emitted, and that category's card grid from a second, + * independently derived list: the `.zod.ts` files on disk. The `misc` catch-all + * — where a published schema that no `.zod.ts` accounts for lands — has no + * `.zod.ts` behind it by definition, so it was structurally absent from the + * second list. Measured on `origin/main`: `security/meta.json` declared five + * pages, the grid carded four, and `misc.mdx` was generated, routed in the + * sidebar, and unreachable from the category overview. The card loop's own + * comment claimed "This aligns the index with `meta.json`" — true only for the + * 13 categories where the two enumerations happened to agree. + * + * WHY A UNIT TEST, rather than the regenerated `.mdx` alone. Two reasons, and + * the second is the load-bearing one: + * + * - the defect's output is an ABSENT card. `check:docs` compares generated + * output to committed output, so a card that was never emitted is green + * forever — which is how this survived until someone diffed the grid against + * the `meta.json` beside it; + * - the edge the card asked to verify — a category whose pages are ALL `misc` + * — HAS NO INSTANCE in the repo, so no emitted file can pin it in either + * direction. Under the old loop it would have rendered an EMPTY `` + * grid: every zod-derived slug filtered out by `wasEmitted`, and `misc` + * never considered. Asserting on a category that does not exist requires the + * rule to be out of the side-effecting script (the move #7658 and #4912 made + * for `schema-section.ts` and `format-type.ts`). + */ + +import { describe, expect, it } from 'vitest'; + +import { categoryGrid } from './lib/category-index'; + +/** Every page emitted — the healthy case, where `meta.json` cannot over-declare. */ +const allEmitted = () => true; + +describe('categoryGrid — the pages a category overview cards (#11260)', () => { + it('cards `misc`, the page with no `.zod.ts` behind it', () => { + // The exact shape measured on `origin/main`: security/ declares five pages, + // one of which (`misc`) no source-derived enumeration can contain. + const declared = ['explain', 'misc', 'permission', 'rls', 'sharing']; + + const { cards, undelivered } = categoryGrid(declared, allEmitted); + + expect(cards).toEqual(['explain', 'misc', 'permission', 'rls', 'sharing']); + expect(undelivered).toEqual([]); + }); + + it('cards every page `meta.json` declares, so the grid and the sidebar cannot disagree', () => { + const declared = ['---Section One---', 'beta', 'alpha', '---Section Two---', 'gamma']; + + const { cards } = categoryGrid(declared, allEmitted); + + // Separators are sidebar headings, not pages — never carded. + expect(cards).toEqual(['alpha', 'beta', 'gamma']); + }); + + it('orders cards alphabetically, not in `meta.json` section order', () => { + // The grid's order is the one the pre-fix `Array.from(zodFiles).sort()` + // produced. Pinned because taking `meta.json`'s order instead would reshuffle + // all 13 grids that were already correct — a fix to WHICH pages are carded + // must not also churn the ones it is not fixing. + const declared = ['---Grouped---', 'zebra', 'alpha', '---More---', 'middle']; + + expect(categoryGrid(declared, allEmitted).cards).toEqual(['alpha', 'middle', 'zebra']); + }); + + it('does not card a page this run did not emit — it reports it', () => { + // The `wasEmitted` guard is KEPT: a `.zod.ts` whose schemas are all + // unrepresentable in JSON Schema generates no page, and carding it would be + // a dangling 404. What changed is that dropping a declared page is now + // reported rather than silent — silence is what shipped the `misc` bug. + const declared = ['delivered', 'unrepresentable']; + + const { cards, undelivered } = categoryGrid(declared, page => page !== 'unrepresentable'); + + expect(cards).toEqual(['delivered']); + expect(undelivered).toEqual(['unrepresentable']); + }); + + describe('the all-`misc` category — no instance in the repo, so only assertable here', () => { + it('cards `misc` rather than rendering an empty grid', () => { + // A category whose every published schema falls to the catch-all. The old + // loop iterated the `.zod.ts` slugs, all of which produced no page, and + // emitted `\n` — an overview linking nowhere, for a folder + // whose one page is routed in the sidebar. + const { cards, undelivered } = categoryGrid(['misc'], allEmitted); + + expect(cards).toEqual(['misc']); + expect(cards).not.toHaveLength(0); + expect(undelivered).toEqual([]); + }); + + it('cannot produce an empty grid from a non-empty declaration without saying so', () => { + // The invariant the caller enforces: declared pages in, at least one card + // out — or `undelivered` names what went missing and the build stops. + // Every way of reaching an empty grid passes through this list. + const { cards, undelivered } = categoryGrid(['misc'], () => false); + + expect(cards).toEqual([]); + expect(undelivered).toEqual(['misc']); + }); + }); + + it('declares nothing for a category with no pages', () => { + // §2 writes no `meta.json` for a category that published no page, so §2.5 + // writes no `index.mdx` — the overview exists exactly when the pages do. + expect(categoryGrid([], allEmitted)).toEqual({ cards: [], undelivered: [] }); + expect(categoryGrid(['---Empty Section---'], allEmitted).cards).toEqual([]); + }); +}); diff --git a/packages/spec/scripts/lib/category-index.ts b/packages/spec/scripts/lib/category-index.ts new file mode 100644 index 0000000000..1417c57dd9 --- /dev/null +++ b/packages/spec/scripts/lib/category-index.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which pages a category overview (`content/docs/references//index.mdx`) + * cards — the pages that category's `meta.json` DECLARES (#11260). + * + * ## The defect this replaces + * + * `build-docs.ts` writes both files in one run, and they used to be built from + * two different enumerations of "the pages of this category": + * + * - `meta.json` from `PAGES_BY_CATEGORY` — the pages the run actually + * emitted, which is where a published schema that no `.zod.ts` accounts for + * lands (the `misc` catch-all bucket); + * - the `index.mdx` card grid from `categoryZodFiles` — page slugs derived + * from the `.zod.ts` files on disk. + * + * `misc` has no file behind it — the generator says so twice, and + * `sourcePathFor` returns `undefined` for it precisely so the page prints no + * invented "Source:" line — so it is STRUCTURALLY absent from the second + * enumeration. The card loop never considered it, and the `wasEmitted` guard + * the loop's own comment leaned on ("This aligns the index with `meta.json`") + * never ran for it. Measured on `origin/main`: `security/` shipped five pages, + * its `meta.json` declared five, its grid carded four. `misc.mdx` was + * generated and routed in the sidebar, and unreachable from the one page whose + * job is to reach it. + * + * The comment was not merely wrong, it was wrong in the shape that reads as + * verified — it named the invariant while the code held it by coincidence, for + * the 13 categories where the two enumerations happen to agree. + * + * ## Why a function, and why here + * + * Same move `schema-section.ts` (#7658) and `format-type.ts` (#4912) made, for + * the same reason: the generator is a top-level script with side effects, so + * the only way to assert on the grid was to run the whole thing and grep the + * emitted `.mdx`. This defect's output is an ABSENT card, and the edge that + * cannot be caught that way at all is a category whose pages are ALL `misc`: + * no such category exists today, so no emitted file can pin it, and under the + * old loop it would have rendered an EMPTY `` grid (every zod slug + * filtered by `wasEmitted`, `misc` never considered). Asserting an absence + * needs the rule out of the script. + * + * ## The invariant, now held by construction + * + * Card the declared pages, keep the `wasEmitted` guard. Both files then read + * the same list, so the guard can no longer silently drop a page: a declared + * page this run did not emit is reported as `undelivered` and stops the build, + * rather than thinning the grid the way `misc` was thinned. That also makes an + * empty grid unreachable — a category with declared pages cards at least one, + * or the run fails naming the pages it could not deliver. + */ + +/** + * A fumadocs section separator in a `meta.json` `pages` array (`---Section---`) + * — a heading in the sidebar, not a page. Same predicate + * `scripts/check-section-landing-index.mjs` applies to the same arrays. + */ +function isSectionSeparator(page: string): boolean { + return page.startsWith('---'); +} + +/** What `categoryGrid` decided about one category's overview. */ +export interface CategoryGrid { + /** + * Page slugs to card, in grid order (alphabetical — the order the old + * `Array.from(zodFiles).sort()` produced, so a fix to WHICH pages are carded + * does not also reshuffle the 13 grids that were already right). + */ + cards: string[]; + /** + * Declared pages this run did not emit. Always empty in a healthy run: + * `meta.json` is built from the emitted page map, so this is a generator bug + * — the caller stops the build rather than publishing a thinned grid. + */ + undelivered: string[]; +} + +/** + * Split a category's declared pages into the ones its grid cards and the ones + * that would be silently dropped. + * + * `declaredPages` is the `pages` array written to that category's `meta.json`, + * separators included. `wasEmitted` answers whether a page slug got a reference + * page out of THIS run — the sink, never the disk, because under `--check` + * nothing is written and the stale tree is still lying around. + */ +export function categoryGrid( + declaredPages: readonly string[], + wasEmitted: (page: string) => boolean, +): CategoryGrid { + const declared = [...new Set(declaredPages.filter(page => !isSectionSeparator(page)))].sort(); + return { + cards: declared.filter(page => wasEmitted(page)), + undelivered: declared.filter(page => !wasEmitted(page)), + }; +}