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)), + }; +}