diff --git a/.changeset/docs-root-meta-declared-pages.md b/.changeset/docs-root-meta-declared-pages.md new file mode 100644 index 0000000000..a79adecf96 --- /dev/null +++ b/.changeset/docs-root-meta-declared-pages.md @@ -0,0 +1,35 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): the generated docs root sidebar lists categories from the same declared page list `meta.json` and the category index already agree on (#11482) + +`build-docs.ts` writes the docs tree from what should be one answer to "which +categories/pages exist", but the ROOT `content/docs/references/meta.json` (§3 +— the sidebar's top-level category list) was still answering it a third way: + +- a category's own `meta.json` (§2) is built from the pages the run + **emitted**; +- that category's `index.mdx` card grid (§2.5) reads the SAME declared list + (#11260) — no longer a second, independently-derived enumeration; +- the root `meta.json` (§3), until now, filtered on `categoryZodFiles` — the + `.zod.ts` files found **on disk** — a third, independent enumeration. + +A category whose published pages all come from plain `.ts` files rather than +`.zod.ts` ones (the `misc` catch-all class `security/misc` proves is real) has +zero `.zod.ts` files while still publishing a page, a `meta.json` and an +`index.mdx`. The old filter would drop such a category from the sidebar even +though it is fully generated and routed everywhere else — a folder complete on +disk and unreachable from the nav. + +**No category is in that state today** — all 14 have at least one `.zod.ts` +file — so this was a latent defect with no live instance, and the regenerated +root `meta.json` is byte-identical. The filter now reads `categoryMetaPages`, +the same map §2.5 already reads, so all three files answer from one list +instead of three that happen to agree today. The rule moved into +`scripts/lib/root-meta.ts` (`rootCategoryDirs`), pinned directly with the +all-`misc`-category shape that has no instance in the repo — the same move +#11260 made for the category card grid, for the same reason: the defect's +output is an ABSENT sidebar entry, which `check:docs` cannot see any more than +it could see an absent card, and the edge that has no live instance cannot be +pinned from emitted output at all. diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index f39c10f63b..f4626f9e41 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -45,6 +45,7 @@ import { renderRootIndex, type RootIndexCategory, } from './lib/root-index'; +import { rootCategoryDirs } from './lib/root-meta'; import { buildSchemaIndex, formatConflicts, @@ -936,13 +937,12 @@ if (managedCount > 0) { } // 3. Update root meta.json -// Collect categories that have actual generated content (non-empty zod files) -const categoryDirs = Object.keys(CATEGORIES) - .filter(cat => { - const zodFiles = categoryZodFiles.get(cat); - return zodFiles && zodFiles.size > 0; - }) - .sort(); +// Which categories reach the sidebar — from `categoryMetaPages`, the same +// declared list §2.5 reads for its card grid, never a `.zod.ts`-keyed +// re-derivation. See `lib/root-meta.ts` for the defect this replaces and why +// the rule lives in an extracted, unit-testable function (#11482, same fix +// pattern as #11260 one enumeration up). +const categoryDirs = rootCategoryDirs(Object.keys(CATEGORIES), categoryMetaPages); // Collect other root files (if any exist, like implementation-status.mdx). // Root-level .mdx is hand-written and never generated, so this reads the disk in diff --git a/packages/spec/scripts/lib/root-meta.ts b/packages/spec/scripts/lib/root-meta.ts new file mode 100644 index 0000000000..6d51d697b1 --- /dev/null +++ b/packages/spec/scripts/lib/root-meta.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which category folders get an entry in the root + * `content/docs/references/meta.json` — the sidebar's top-level category list + * (#11482). + * + * ## The defect this replaces + * + * `build-docs.ts` writes THREE things from what should be one answer to "which + * categories/pages exist": a category's own `meta.json` (§2, from the pages + * this run emitted), that category's `index.mdx` card grid (§2.5, fixed by + * #11260 to read the same declared list), and the ROOT `meta.json` (§3) — + * which, until this fix, filtered on `categoryZodFiles`: the `.zod.ts` files + * found on disk, a fourth, independently derived enumeration. + * + * A category whose published pages all come from plain `.ts` files rather than + * `.zod.ts` ones — the `misc` catch-all class `security/misc` proves is real — + * has ZERO `.zod.ts` files while still publishing a page, a `meta.json` and an + * `index.mdx`. The old filter dropped such a category from the sidebar even + * though §2 and §2.5 both fully generated it: a folder that is complete on + * disk and unreachable from the nav. No category is in that state today (all + * 14 have at least one `.zod.ts` file), so the defect was latent — `check:docs` + * cannot see an ABSENT sidebar entry any more than #11260's card grid could see + * an absent card. + * + * ## Why a function, and why here + * + * Same move `category-index.ts` (#11260), `schema-section.ts` (#7658) and + * `format-type.ts` (#4912) made: the generator is a top-level script with side + * effects, so the only way to assert on the root `meta.json`'s category list + * was to run the whole thing and inspect emitted output — which cannot pin a + * category shape that has no live instance in the repo. Asserting an absence + * needs the rule out of the script. + * + * ## The invariant, now held by construction + * + * A category reaches the sidebar exactly when `categoryMetaPages` has a + * non-empty entry for it — the SAME map §2.5 reads to build that category's + * card grid, and the same "no meta.json ⇒ absent" discipline §2 already + * documents for `categoryMetaPages` itself. One answer, read three times, + * instead of three answers that happen to agree today. + */ + +/** + * The category directories that belong in the root `meta.json`'s `pages` + * array, alphabetically sorted. + * + * `categories` is `Object.keys(CATEGORIES)` — every known protocol module, + * whether or not it published anything this run. `metaPages` is + * `categoryMetaPages`: the `pages` array §2 wrote to a category's own + * `meta.json`, present only for a category that published at least one page. + */ +export function rootCategoryDirs( + categories: readonly string[], + metaPages: ReadonlyMap, +): string[] { + return categories.filter(cat => (metaPages.get(cat) ?? []).length > 0).sort(); +} diff --git a/packages/spec/scripts/root-meta.test.ts b/packages/spec/scripts/root-meta.test.ts new file mode 100644 index 0000000000..e377c7249b --- /dev/null +++ b/packages/spec/scripts/root-meta.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin for the root `content/docs/references/meta.json` category filter — WHICH + * categories reach the sidebar (#11482). + * + * THE DEFECT THIS PINS. `build-docs.ts` §3 built the root `meta.json`'s + * category list from `categoryZodFiles` — the `.zod.ts` files found on disk — + * a THIRD enumeration of "the pages of this category", independent of both the + * category's own `meta.json` (§2) and its card grid (§2.5, fixed by #11260 to + * read the same declared list §2 wrote). A category whose published pages all + * come from plain `.ts` files (the `misc` catch-all class — `security/misc` + * proves the shape) has zero `.zod.ts` files while still publishing a page, so + * the old filter dropped it from the sidebar even though it was fully + * generated and routed everywhere else. + * + * WHY A UNIT TEST, rather than the regenerated root `meta.json` alone. Same + * two reasons #11260's `category-index.test.ts` gives for the card grid: + * + * - the defect's output is an ABSENT sidebar entry. `check:docs` compares + * generated output to committed output, so a category that was never + * emitted into `pages` is green forever; + * - the edge this exists to catch — a category with pages but NO `.zod.ts` + * file at all — HAS NO INSTANCE in the repo today (measured on + * `origin/main`: all 14 categories have `zodFiles.size > 0`), so no + * generated artifact can pin it in either direction. Asserting on a + * category that does not exist requires the rule out of the side-effecting + * script. + */ + +import { describe, expect, it } from 'vitest'; + +import { rootCategoryDirs } from './lib/root-meta'; + +describe('rootCategoryDirs — which categories reach the root sidebar (#11482)', () => { + it('includes an all-misc category — no `.zod.ts` files, but it published a page', () => { + // The exact latent shape #11482 named: a category whose every published + // schema falls to the catch-all bucket, so `categoryZodFiles` would record + // zero files for it while `categoryMetaPages` (built from the pages §2 + // actually emitted) still holds `['misc']`. + const metaPages = new Map([['ghost', ['misc']]]); + + expect(rootCategoryDirs(['ghost'], metaPages)).toEqual(['ghost']); + }); + + it('excludes a category that published no page', () => { + // §2 writes no `meta.json` for a category with zero pages, so + // `categoryMetaPages` has no entry for it — the same "no meta.json ⇒ + // absent" discipline the map already documents at its declaration site. + const metaPages = new Map(); + + expect(rootCategoryDirs(['contracts'], metaPages)).toEqual([]); + }); + + it('sorts the result alphabetically, independent of input or map order', () => { + const metaPages = new Map([ + ['zebra', ['a']], + ['alpha', ['b']], + ]); + + expect(rootCategoryDirs(['zebra', 'alpha'], metaPages)).toEqual(['alpha', 'zebra']); + }); + + it('reproduces the real 14-category shape unchanged (#11482 "Measured")', () => { + // Every category on `origin/main` today has both `.zod.ts` files AND + // declared pages, so the old (`.zod.ts`-keyed) and new (`meta.json`-keyed) + // filters agree on all 14 — the byte-identical root `meta.json` the issue + // itself measured, and the acceptance bar this fix must not move. + const categories = [ + 'ai', 'api', 'automation', 'cloud', 'data', 'identity', 'integration', + 'kernel', 'qa', 'security', 'shared', 'studio', 'system', 'ui', + ]; + const metaPages = new Map(categories.map(c => [c, ['some-page']])); + + expect(rootCategoryDirs(categories, metaPages)).toEqual([...categories].sort()); + }); +});