Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/docs-root-meta-declared-pages.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 7 additions & 7 deletions packages/spec/scripts/build-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
renderRootIndex,
type RootIndexCategory,
} from './lib/root-index';
import { rootCategoryDirs } from './lib/root-meta';
import {
buildSchemaIndex,
formatConflicts,
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions packages/spec/scripts/lib/root-meta.ts
Original file line number Diff line number Diff line change
@@ -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, readonly string[]>,
): string[] {
return categories.filter(cat => (metaPages.get(cat) ?? []).length > 0).sort();
}
77 changes: 77 additions & 0 deletions packages/spec/scripts/root-meta.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>();

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());
});
});
Loading