Skip to content

Commit 5f28297

Browse files
committed
fix(spec): docs category index cards the pages meta.json declares (#11260)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T9cDbY2NBiVJWYx3BpWfH2
1 parent b372318 commit 5f28297

5 files changed

Lines changed: 318 additions & 15 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the generated docs category index cards the pages its `meta.json` declares (#11260)
6+
7+
`content/docs/references/security/index.mdx` carded four of the five pages the
8+
`meta.json` beside it declares. The fifth, `misc`, was generated and routed in
9+
the sidebar — and unreachable from the one page whose job is to reach it.
10+
11+
Both files come out of one `gen:docs` run, from two enumerations of "the pages
12+
of this category" that disagreed about exactly one bucket:
13+
14+
- `meta.json` was built from the pages the run **emitted**, which is where a
15+
published schema that no `.zod.ts` accounts for lands (`security/` declares
16+
two in plain `.ts` files, so they fall to the `misc` catch-all);
17+
- the card grid was built from the `.zod.ts` files **on disk**.
18+
19+
`misc` has no `.zod.ts` behind it by definition — the generator says so twice,
20+
and `sourcePathFor` returns nothing for it precisely so the page prints no
21+
invented "Source:" line — so it was *structurally* absent from the second
22+
enumeration. The card loop never considered it, which also means the
23+
`wasEmitted` guard that the loop's own comment leaned on ("This aligns the
24+
index with `meta.json`") never ran for it. The comment was wrong in the shape
25+
that reads as verified: it named the invariant while the code held it by
26+
coincidence, for the 13 categories where two independent enumerations happen to
27+
agree.
28+
29+
The grid now iterates the list `meta.json` was built from, and keeps the
30+
`wasEmitted` guard — a `.zod.ts` whose schemas are all unrepresentable in JSON
31+
Schema still cannot be carded into a dangling 404. Because both files now read
32+
one list, that guard can no longer thin the grid silently: a declared page the
33+
run did not emit stops the build naming it. The stated invariant is true by
34+
construction rather than by coincidence, which closes the class instead of
35+
special-casing `misc`.
36+
37+
The regenerated output is one line: `security/index.mdx` gains its `misc` card
38+
(with no "Source:" line, correctly). The other 13 category grids are
39+
byte-identical.
40+
41+
An all-`misc` category — every published schema in the catch-all — would have
42+
rendered an **empty** `<Cards>` grid under the old loop, with every zod-derived
43+
slug filtered out and `misc` never considered. No such category exists in the
44+
repo, so no emitted file can pin it; the rule moved into
45+
`scripts/lib/category-index.ts` so it can be asserted directly, and an empty
46+
grid is now unreachable from a non-empty declaration.

‎content/docs/references/security/index.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This section contains all protocol schemas for the security layer of ObjectStack
77

88
<Cards>
99
<Card href="/docs/references/security/explain" title="Explain" description="Source: packages/spec/src/security/explain.zod.ts" />
10+
<Card href="/docs/references/security/misc" title="Misc" />
1011
<Card href="/docs/references/security/permission" title="Permission" description="Source: packages/spec/src/security/permission.zod.ts" />
1112
<Card href="/docs/references/security/rls" title="Rls" description="Source: packages/spec/src/security/rls.zod.ts" />
1213
<Card href="/docs/references/security/sharing" title="Sharing" description="Source: packages/spec/src/security/sharing.zod.ts" />

‎packages/spec/scripts/build-docs.ts‎

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import path from 'path';
2626
// a confident page from a tree nobody rebuilt (#4675, #4723).
2727
import { schemaTreeIsStale } from '../../../scripts/check-regen-pending.mjs';
2828

29+
import { categoryGrid } from './lib/category-index';
2930
import { resolveCategoryTitles } from './lib/category-title';
3031
import {
3132
evaluateBaseline,
@@ -154,6 +155,23 @@ const categoryZodFiles = new Map<string, Set<string>>();
154155
* grand total — cannot disagree with the pages themselves (#4759).
155156
*/
156157
const categoryPageSchemas = new Map<string, Map<string, string[]>>();
158+
/**
159+
* `category` -> the `pages` array written to that category's `meta.json`,
160+
* separators included.
161+
*
162+
* Filled by §2 as it emits each `meta.json`, and read back by §2.5 so the card
163+
* grid enumerates the pages the category DECLARES instead of re-deriving a
164+
* second list from the `.zod.ts` files on disk. Those two enumerations
165+
* disagreed for exactly one bucket — the `misc` catch-all, which by definition
166+
* has no `.zod.ts` behind it — and the grid was the side that lost it (#11260).
167+
* See `lib/category-index.ts` for the defect and why the fix is by
168+
* construction rather than a `misc` special case.
169+
*
170+
* Absent for a category that published no page: §2 writes no `meta.json` for
171+
* one, and §2.5 correspondingly writes no `index.mdx`, so the overview exists
172+
* exactly when the thing it is an overview OF does.
173+
*/
174+
const categoryMetaPages = new Map<string, string[]>();
157175
/**
158176
* Page slug -> its real path under `packages/spec/src/<category>/`.
159177
*
@@ -788,12 +806,41 @@ PAGES_BY_CATEGORY.forEach((zodFileSchemas, category) => {
788806
pages
789807
};
790808
emit(path.join(categoryDir, 'meta.json'), JSON.stringify(meta, null, 2));
809+
810+
// Hand the declared list to §2.5 — never a second enumeration, the same
811+
// discipline `categoryPageSchemas` applies two statements up for the root
812+
// index. The grid used to re-derive its own list and lost `misc` (#11260).
813+
categoryMetaPages.set(category, pages);
791814
});
792815

793816
// 2.5 Generate Category Overviews (index.mdx in each folder)
794817
Object.entries(CATEGORIES).forEach(([category, title]) => {
795-
const zodFiles = categoryZodFiles.get(category) || new Set<string>();
796-
if (zodFiles.size === 0) return;
818+
// The pages §2 DECLARED for this category, not a second list derived from the
819+
// `.zod.ts` files: `misc` is a real page with no `.zod.ts` behind it, so a
820+
// source-derived list cannot contain it and the grid dropped it (#11260).
821+
// Keyed off the same map that decides `meta.json`, so "no meta.json ⇒ no
822+
// index.mdx" needs no second guard to agree with §2's.
823+
const declared = categoryMetaPages.get(category) || [];
824+
if (declared.length === 0) return;
825+
826+
const { cards, undelivered } = categoryGrid(declared, page =>
827+
wasEmitted(path.join(DOCS_ROOT, category, `${page}.mdx`)),
828+
);
829+
830+
// Both lists come from the page map §2 emitted from, so this cannot fire on
831+
// any content state — only on a future edit that reintroduces the split the
832+
// `misc` omission came from. Loud, because the failure mode it replaces was
833+
// a grid quietly one card short of the pages it claimed to align with.
834+
if (undelivered.length > 0) {
835+
console.error(
836+
`\n✗ ${path.relative(REPO_ROOT, path.join(DOCS_ROOT, category, 'index.mdx'))} would card ` +
837+
`${cards.length} of the ${cards.length + undelivered.length} pages its meta.json declares.\n` +
838+
` Undelivered: ${undelivered.join(', ')}\n\n` +
839+
` meta.json and this grid are built from the same page map, so a declared page this\n` +
840+
` run did not emit is a generator bug, not a content state (#11260).`,
841+
);
842+
process.exit(1);
843+
}
797844

798845
let mdx = `---\n`;
799846
mdx += `title: ${title}\n`;
@@ -803,19 +850,16 @@ Object.entries(CATEGORIES).forEach(([category, title]) => {
803850
mdx += `This section contains all protocol schemas for the ${category} layer of ObjectStack.\n\n`;
804851

805852
mdx += `<Cards>\n`;
806-
Array.from(zodFiles).sort().forEach(zodFile => {
807-
// Only card zod files that actually produced a reference page. A
808-
// `.zod.ts` whose schemas are all unrepresentable in JSON Schema — e.g.
809-
// they embed a transform (the ADR-0031 control-flow constructs and the
810-
// Flow edge schema carry CEL-expression transforms) — generates no page,
811-
// so carding it would be a dangling 404 link. This aligns the index with
812-
// `meta.json`, which already lists only generated pages.
813-
//
814-
// Asks the sink, not the disk: this run's own output is the authority on
815-
// what pages exist. (Equivalent on disk, since the folder was just wiped
816-
// and rewritten — but it stays correct under --check, where nothing is
817-
// written and the stale files are still lying around.)
818-
if (!wasEmitted(path.join(DOCS_ROOT, category, `${zodFile}.mdx`))) return;
853+
// `cards` is the declared pages that this run emitted — the `wasEmitted`
854+
// guard is kept and applied in `categoryGrid`, so a `.zod.ts` whose schemas
855+
// are all unrepresentable in JSON Schema (they embed a transform — the
856+
// ADR-0031 control-flow constructs and the Flow edge schema carry
857+
// CEL-expression transforms) still cannot be carded into a dangling 404.
858+
// What changed is that the guard now runs over the SAME list `meta.json` was
859+
// built from, so "this aligns the index with meta.json" is true by
860+
// construction instead of true for the categories where two independent
861+
// enumerations happened to agree.
862+
cards.forEach(zodFile => {
819863
const fileTitle = zodFile.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
820864
const cardSource = sourcePathFor(category, zodFile);
821865
// Link relative to the category folder (where index.mdx lives)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pin for the category-overview card grid — WHICH pages
5+
* `content/docs/references/<cat>/index.mdx` cards (#11260).
6+
*
7+
* THE DEFECT THIS PINS. `build-docs.ts` wrote a category's `meta.json` from the
8+
* pages it had just emitted, and that category's card grid from a second,
9+
* independently derived list: the `.zod.ts` files on disk. The `misc` catch-all
10+
* — where a published schema that no `.zod.ts` accounts for lands — has no
11+
* `.zod.ts` behind it by definition, so it was structurally absent from the
12+
* second list. Measured on `origin/main`: `security/meta.json` declared five
13+
* pages, the grid carded four, and `misc.mdx` was generated, routed in the
14+
* sidebar, and unreachable from the category overview. The card loop's own
15+
* comment claimed "This aligns the index with `meta.json`" — true only for the
16+
* 13 categories where the two enumerations happened to agree.
17+
*
18+
* WHY A UNIT TEST, rather than the regenerated `.mdx` alone. Two reasons, and
19+
* the second is the load-bearing one:
20+
*
21+
* - the defect's output is an ABSENT card. `check:docs` compares generated
22+
* output to committed output, so a card that was never emitted is green
23+
* forever — which is how this survived until someone diffed the grid against
24+
* the `meta.json` beside it;
25+
* - the edge the card asked to verify — a category whose pages are ALL `misc`
26+
* — HAS NO INSTANCE in the repo, so no emitted file can pin it in either
27+
* direction. Under the old loop it would have rendered an EMPTY `<Cards>`
28+
* grid: every zod-derived slug filtered out by `wasEmitted`, and `misc`
29+
* never considered. Asserting on a category that does not exist requires the
30+
* rule to be out of the side-effecting script (the move #7658 and #4912 made
31+
* for `schema-section.ts` and `format-type.ts`).
32+
*/
33+
34+
import { describe, expect, it } from 'vitest';
35+
36+
import { categoryGrid } from './lib/category-index';
37+
38+
/** Every page emitted — the healthy case, where `meta.json` cannot over-declare. */
39+
const allEmitted = () => true;
40+
41+
describe('categoryGrid — the pages a category overview cards (#11260)', () => {
42+
it('cards `misc`, the page with no `.zod.ts` behind it', () => {
43+
// The exact shape measured on `origin/main`: security/ declares five pages,
44+
// one of which (`misc`) no source-derived enumeration can contain.
45+
const declared = ['explain', 'misc', 'permission', 'rls', 'sharing'];
46+
47+
const { cards, undelivered } = categoryGrid(declared, allEmitted);
48+
49+
expect(cards).toEqual(['explain', 'misc', 'permission', 'rls', 'sharing']);
50+
expect(undelivered).toEqual([]);
51+
});
52+
53+
it('cards every page `meta.json` declares, so the grid and the sidebar cannot disagree', () => {
54+
const declared = ['---Section One---', 'beta', 'alpha', '---Section Two---', 'gamma'];
55+
56+
const { cards } = categoryGrid(declared, allEmitted);
57+
58+
// Separators are sidebar headings, not pages — never carded.
59+
expect(cards).toEqual(['alpha', 'beta', 'gamma']);
60+
});
61+
62+
it('orders cards alphabetically, not in `meta.json` section order', () => {
63+
// The grid's order is the one the pre-fix `Array.from(zodFiles).sort()`
64+
// produced. Pinned because taking `meta.json`'s order instead would reshuffle
65+
// all 13 grids that were already correct — a fix to WHICH pages are carded
66+
// must not also churn the ones it is not fixing.
67+
const declared = ['---Grouped---', 'zebra', 'alpha', '---More---', 'middle'];
68+
69+
expect(categoryGrid(declared, allEmitted).cards).toEqual(['alpha', 'middle', 'zebra']);
70+
});
71+
72+
it('does not card a page this run did not emit — it reports it', () => {
73+
// The `wasEmitted` guard is KEPT: a `.zod.ts` whose schemas are all
74+
// unrepresentable in JSON Schema generates no page, and carding it would be
75+
// a dangling 404. What changed is that dropping a declared page is now
76+
// reported rather than silent — silence is what shipped the `misc` bug.
77+
const declared = ['delivered', 'unrepresentable'];
78+
79+
const { cards, undelivered } = categoryGrid(declared, page => page !== 'unrepresentable');
80+
81+
expect(cards).toEqual(['delivered']);
82+
expect(undelivered).toEqual(['unrepresentable']);
83+
});
84+
85+
describe('the all-`misc` category — no instance in the repo, so only assertable here', () => {
86+
it('cards `misc` rather than rendering an empty grid', () => {
87+
// A category whose every published schema falls to the catch-all. The old
88+
// loop iterated the `.zod.ts` slugs, all of which produced no page, and
89+
// emitted `<Cards>\n</Cards>` — an overview linking nowhere, for a folder
90+
// whose one page is routed in the sidebar.
91+
const { cards, undelivered } = categoryGrid(['misc'], allEmitted);
92+
93+
expect(cards).toEqual(['misc']);
94+
expect(cards).not.toHaveLength(0);
95+
expect(undelivered).toEqual([]);
96+
});
97+
98+
it('cannot produce an empty grid from a non-empty declaration without saying so', () => {
99+
// The invariant the caller enforces: declared pages in, at least one card
100+
// out — or `undelivered` names what went missing and the build stops.
101+
// Every way of reaching an empty grid passes through this list.
102+
const { cards, undelivered } = categoryGrid(['misc'], () => false);
103+
104+
expect(cards).toEqual([]);
105+
expect(undelivered).toEqual(['misc']);
106+
});
107+
});
108+
109+
it('declares nothing for a category with no pages', () => {
110+
// §2 writes no `meta.json` for a category that published no page, so §2.5
111+
// writes no `index.mdx` — the overview exists exactly when the pages do.
112+
expect(categoryGrid([], allEmitted)).toEqual({ cards: [], undelivered: [] });
113+
expect(categoryGrid(['---Empty Section---'], allEmitted).cards).toEqual([]);
114+
});
115+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Which pages a category overview (`content/docs/references/<cat>/index.mdx`)
5+
* cards — the pages that category's `meta.json` DECLARES (#11260).
6+
*
7+
* ## The defect this replaces
8+
*
9+
* `build-docs.ts` writes both files in one run, and they used to be built from
10+
* two different enumerations of "the pages of this category":
11+
*
12+
* - `meta.json` from `PAGES_BY_CATEGORY` — the pages the run actually
13+
* emitted, which is where a published schema that no `.zod.ts` accounts for
14+
* lands (the `misc` catch-all bucket);
15+
* - the `index.mdx` card grid from `categoryZodFiles` — page slugs derived
16+
* from the `.zod.ts` files on disk.
17+
*
18+
* `misc` has no file behind it — the generator says so twice, and
19+
* `sourcePathFor` returns `undefined` for it precisely so the page prints no
20+
* invented "Source:" line — so it is STRUCTURALLY absent from the second
21+
* enumeration. The card loop never considered it, and the `wasEmitted` guard
22+
* the loop's own comment leaned on ("This aligns the index with `meta.json`")
23+
* never ran for it. Measured on `origin/main`: `security/` shipped five pages,
24+
* its `meta.json` declared five, its grid carded four. `misc.mdx` was
25+
* generated and routed in the sidebar, and unreachable from the one page whose
26+
* job is to reach it.
27+
*
28+
* The comment was not merely wrong, it was wrong in the shape that reads as
29+
* verified — it named the invariant while the code held it by coincidence, for
30+
* the 13 categories where the two enumerations happen to agree.
31+
*
32+
* ## Why a function, and why here
33+
*
34+
* Same move `schema-section.ts` (#7658) and `format-type.ts` (#4912) made, for
35+
* the same reason: the generator is a top-level script with side effects, so
36+
* the only way to assert on the grid was to run the whole thing and grep the
37+
* emitted `.mdx`. This defect's output is an ABSENT card, and the edge that
38+
* cannot be caught that way at all is a category whose pages are ALL `misc`:
39+
* no such category exists today, so no emitted file can pin it, and under the
40+
* old loop it would have rendered an EMPTY `<Cards>` grid (every zod slug
41+
* filtered by `wasEmitted`, `misc` never considered). Asserting an absence
42+
* needs the rule out of the script.
43+
*
44+
* ## The invariant, now held by construction
45+
*
46+
* Card the declared pages, keep the `wasEmitted` guard. Both files then read
47+
* the same list, so the guard can no longer silently drop a page: a declared
48+
* page this run did not emit is reported as `undelivered` and stops the build,
49+
* rather than thinning the grid the way `misc` was thinned. That also makes an
50+
* empty grid unreachable — a category with declared pages cards at least one,
51+
* or the run fails naming the pages it could not deliver.
52+
*/
53+
54+
/**
55+
* A fumadocs section separator in a `meta.json` `pages` array (`---Section---`)
56+
* — a heading in the sidebar, not a page. Same predicate
57+
* `scripts/check-section-landing-index.mjs` applies to the same arrays.
58+
*/
59+
function isSectionSeparator(page: string): boolean {
60+
return page.startsWith('---');
61+
}
62+
63+
/** What `categoryGrid` decided about one category's overview. */
64+
export interface CategoryGrid {
65+
/**
66+
* Page slugs to card, in grid order (alphabetical — the order the old
67+
* `Array.from(zodFiles).sort()` produced, so a fix to WHICH pages are carded
68+
* does not also reshuffle the 13 grids that were already right).
69+
*/
70+
cards: string[];
71+
/**
72+
* Declared pages this run did not emit. Always empty in a healthy run:
73+
* `meta.json` is built from the emitted page map, so this is a generator bug
74+
* — the caller stops the build rather than publishing a thinned grid.
75+
*/
76+
undelivered: string[];
77+
}
78+
79+
/**
80+
* Split a category's declared pages into the ones its grid cards and the ones
81+
* that would be silently dropped.
82+
*
83+
* `declaredPages` is the `pages` array written to that category's `meta.json`,
84+
* separators included. `wasEmitted` answers whether a page slug got a reference
85+
* page out of THIS run — the sink, never the disk, because under `--check`
86+
* nothing is written and the stale tree is still lying around.
87+
*/
88+
export function categoryGrid(
89+
declaredPages: readonly string[],
90+
wasEmitted: (page: string) => boolean,
91+
): CategoryGrid {
92+
const declared = [...new Set(declaredPages.filter(page => !isSectionSeparator(page)))].sort();
93+
return {
94+
cards: declared.filter(page => wasEmitted(page)),
95+
undelivered: declared.filter(page => !wasEmitted(page)),
96+
};
97+
}

0 commit comments

Comments
 (0)