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
46 changes: 46 additions & 0 deletions .changeset/docs-category-index-cards-declared-pages.md
Original file line numberDiff line numberDiff line change
@@ -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** `<Cards>` 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.
1 change: 1 addition & 0 deletions content/docs/references/security/index.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,7 @@ This section contains all protocol schemas for the security layer of ObjectStack

<Cards>
<Card href="/docs/references/security/explain" title="Explain" description="Source: packages/spec/src/security/explain.zod.ts" />
<Card href="/docs/references/security/misc" title="Misc" />
<Card href="/docs/references/security/permission" title="Permission" description="Source: packages/spec/src/security/permission.zod.ts" />
<Card href="/docs/references/security/rls" title="Rls" description="Source: packages/spec/src/security/rls.zod.ts" />
<Card href="/docs/references/security/sharing" title="Sharing" description="Source: packages/spec/src/security/sharing.zod.ts" />
Expand Down
74 changes: 59 additions & 15 deletions packages/spec/scripts/build-docs.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -154,6 +155,23 @@ const categoryZodFiles = new Map<string, Set<string>>();
* grand total — cannot disagree with the pages themselves (#4759).
*/
const categoryPageSchemas = new Map<string, Map<string, string[]>>();
/**
* `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<string, string[]>();
/**
* Page slug -> its real path under `packages/spec/src/<category>/`.
*
Expand DownExpand Up@@ -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<string>();
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`;
Expand All@@ -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 += `<Cards>\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)
Expand Down
115 changes: 115 additions & 0 deletions packages/spec/scripts/category-index.test.ts
Original file line numberDiff line numberDiff line change
@@ -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/<cat>/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 `<Cards>`
* 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 `<Cards>\n</Cards>` — 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([]);
});
});
97 changes: 97 additions & 0 deletions packages/spec/scripts/lib/category-index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Which pages a category overview (`content/docs/references/<cat>/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 `<Cards>` 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)),
};
}
Loading