Skip to content

Commit 8c2db68

Browse files
authored
feat(spec): fail CI when a schema variant is missing from its hand-written doc (#4177)
The liveness gate asks whether a property does anything; check-doc-authoring asks whether docs use the right authoring form. Neither asked the inverse-drift question: does a variant the schema DECLARES appear in the prose at all? content/docs/references/ is generated from these schemas and cannot drift; the hand-written pages are typed by humans and do. Founding case, which shipped for months: apps.mdx said the navigation tree "supports eight item types" and enumerated eight. The schema had nine — `separator` was added to match the objectui renderer (#1878/#1891/#1894) and no hand-written page ever learned about it. #4165 raised the cost: a mistyped discriminator now answers with the list of valid values, and the doc's enumeration is what an author checks it against. Identity is `<discriminator>:<variants sorted>`, deliberately not a source path — a union is reachable by several paths and the walk order picks one. Keying on the variant set means adding or removing a variant changes the key, failing the ratchet and sending the author back through the ledger to the doc. variant-docs.json classifies all 20 unions found by reflection (a count that matches `grep -c 'z.discriminatedUnion('` exactly): 5 governed, 15 exempt as either generated-reference-only or not-authorable. Verified it bites, not just that it passes: removing `separator` from apps.mdx fails with exit 1 naming the variant; deleting a ledger entry fails as an undeclared union; mutating a key fails as orphan + undeclared — the pair an author sees when adding a variant. Known limit, stated in the script header: a variant counts as documented via `<disc>: '<variant>'` or `` `<variant>` ``. The second form is loose and can match an unrelated sentence for short generic variants — a pass means the page says the word, not that it documents it correctly. Refs #4001, #4165
1 parent f5fe061 commit 8c2db68

5 files changed

Lines changed: 390 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Add a gate that fails CI when a discriminated-union variant the schema declares is
6+
never mentioned in the hand-written doc bound to it.
7+
8+
The liveness gate asks whether a property does anything; `check-doc-authoring` asks
9+
whether docs use the right authoring form. Neither asked the inverse-drift question:
10+
does a variant the schema declares appear in the prose at all? `content/docs/references/`
11+
is generated from the schemas and cannot drift, but the hand-written pages are typed by
12+
humans and do.
13+
14+
The founding case shipped for months: `content/docs/ui/apps.mdx` said the navigation
15+
tree "supports eight item types" and enumerated eight. The schema had nine — `separator`
16+
was added to match the objectui renderer and no hand-written page ever learned about it.
17+
Making `NavigationItemSchema` a discriminated union raised the cost of that gap, because
18+
a mistyped `type` now answers with the list of valid discriminators and the doc's
19+
enumeration is what an author checks it against.
20+
21+
`packages/spec/variant-docs.json` classifies all 20 unions: 5 governed (every variant
22+
must be mentioned in a bound doc), 15 exempt as either generated-reference-only or
23+
not-authorable. The ledger key is the discriminator plus its sorted variant set, so
24+
adding or removing a variant changes the key and sends the author back through the
25+
ledger — and therefore back to the doc.

.github/workflows/spec-liveness-check.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ on:
1818
paths:
1919
- 'packages/spec/**'
2020
- 'packages/qa/dogfood/**'
21+
# The variant/doc gate reads hand-written pages, so editing one can break it
22+
# without touching packages/spec at all.
23+
- 'content/docs/**'
2124

2225
permissions:
2326
contents: read
@@ -50,3 +53,11 @@ jobs:
5053
# commonest authoring error, and it must not also be the widest grant.
5154
- name: Check empty-state semantics
5255
run: pnpm --filter @objectstack/spec check:empty-state
56+
57+
# #4165 follow-up. Those two gates ask what a property does and what its empty
58+
# value means. This asks the inverse-drift question neither covers: does a variant
59+
# the schema declares appear in the hand-written doc at all? references/ is
60+
# generated and cannot drift; the hand-written pages are typed by humans and do.
61+
# apps.mdx said the nav tree had "eight item types" while the schema had nine.
62+
- name: Check discriminated-union variants are documented
63+
run: pnpm --filter @objectstack/spec check:variant-docs

packages/spec/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@
208208
"test:coverage": "vitest run --coverage",
209209
"check:liveness": "tsx scripts/liveness/check-liveness.mts",
210210
"check:empty-state": "tsx scripts/liveness/check-empty-state.mts",
211+
"check:variant-docs": "tsx scripts/check-variant-docs.mts",
211212
"gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts",
212213
"check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check",
213214
"check:react-conformance": "tsx scripts/check-react-blocks-conformance.ts",
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
#!/usr/bin/env tsx
2+
// Discriminated-union variant → hand-written doc gate.
3+
//
4+
// WHY THIS EXISTS (#4165). The liveness gate asks whether a schema property does
5+
// anything, and check-doc-authoring asks whether docs use the right authoring form.
6+
// Neither asks the inverse-drift question: does a variant the SCHEMA declares appear
7+
// in the hand-written doc at all? content/docs/references/ is generated from these
8+
// schemas and so cannot drift, but the hand-written pages are typed by humans and do.
9+
//
10+
// The founding case: content/docs/ui/apps.mdx said the navigation tree "supports eight
11+
// item types" and enumerated eight. The schema had nine — `separator` had been added to
12+
// match the objectui renderer (#1878/#1891/#1894) and no hand-written page ever learned
13+
// about it. Nothing failed, because nothing was looking in this direction.
14+
//
15+
// #4165 raised the cost of that gap: NavigationItemSchema is now discriminated on `type`,
16+
// so a mistyped discriminator answers with "Invalid discriminator value. Expected ..." —
17+
// the doc's enumeration is precisely what an author checks that list against. And
18+
// SeparatorNavItemSchema is .strict() over only type/id/order, so the base props the doc
19+
// promised "all navigation items" share are hard errors on a separator.
20+
//
21+
// THE RATCHET. `key` is '<discriminator>:<variants sorted, pipe-joined>' — not a source
22+
// path, because a union is reachable by several paths and the walk order picks one, which
23+
// would churn. Keying on the variant set means adding or removing a variant CHANGES THE
24+
// KEY, so the ledger no longer matches and CI sends the author back to variant-docs.json
25+
// — and from there to the doc. An unknown key (new union) and an orphaned key (deleted or
26+
// renamed union) both fail: no new undeclared surface, no stale claims.
27+
//
28+
// WHAT "MENTIONED" MEANS — and what this gate does NOT prove. A variant counts as
29+
// documented if the bound doc contains either `<discriminator>: '<variant>'` (the code-
30+
// sample form) or `` `<variant>` `` (the prose/table form). The second form is
31+
// deliberately loose, and for short generic variants (`object`, `api`, `value`) it can
32+
// match an unrelated sentence — so a pass here means "the page at least says the word",
33+
// NOT "the page documents it correctly". This gate catches the omission that shipped for
34+
// months; it is not a substitute for reading the page. Tightening the loose form would
35+
// cost more false failures than the precision is worth.
36+
//
37+
// Usage:
38+
// tsx check-variant-docs.mts # fail on undeclared/orphaned/undocumented
39+
// tsx check-variant-docs.mts --list # print every discovered union and its key
40+
41+
import fs from 'node:fs';
42+
import path from 'node:path';
43+
import url from 'node:url';
44+
45+
// Safe to set here despite ESM hoisting: the only static imports above are node
46+
// built-ins, and every schema module is pulled in via dynamic `await import()`
47+
// below — i.e. after this line runs. Keeps the script correct when invoked
48+
// directly (`tsx scripts/check-variant-docs.mts`), not just via the pnpm script.
49+
process.env.OS_EAGER_SCHEMAS ??= '1';
50+
51+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
52+
const SPEC = path.resolve(HERE, '..');
53+
const REPO = path.resolve(SPEC, '../..');
54+
const SRC = path.join(SPEC, 'src');
55+
const LEDGER = path.join(SPEC, 'variant-docs.json');
56+
const LIST = process.argv.includes('--list');
57+
58+
type Entry = {
59+
key: string; label: string;
60+
docs?: string[]; exempt?: string; reason?: string; note?: string;
61+
};
62+
63+
const defOf = (s: any) => s?._zod?.def ?? s?._def;
64+
65+
function zodFiles(dir: string, acc: string[] = []): string[] {
66+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
67+
const p = path.join(dir, e.name);
68+
if (e.isDirectory()) zodFiles(p, acc);
69+
else if (e.name.endsWith('.zod.ts')) acc.push(p);
70+
}
71+
return acc;
72+
}
73+
74+
const seen = new Set<any>();
75+
const found = new Map<string, { disc: string; variants: string[]; path: string }>();
76+
77+
function walk(s: any, p: string, depth: number): void {
78+
if (!s || typeof s !== 'object' || depth > 14 || seen.has(s)) return;
79+
seen.add(s);
80+
const d = defOf(s);
81+
if (!d) return;
82+
83+
if (d.type === 'union' && d.discriminator) {
84+
const variants: string[] = [];
85+
for (const o of d.options ?? []) {
86+
const od = defOf(o);
87+
const shape = typeof od?.shape === 'function' ? od.shape() : od?.shape;
88+
const dd = defOf(shape?.[d.discriminator]);
89+
const vals = dd?.values ?? (dd?.value !== undefined ? [dd.value] : []);
90+
for (const v of vals) variants.push(String(v));
91+
}
92+
if (variants.length) {
93+
const key = `${d.discriminator}:${[...variants].sort().join('|')}`;
94+
if (!found.has(key)) found.set(key, { disc: d.discriminator, variants, path: p });
95+
}
96+
}
97+
98+
// Structural recursion. Wrapped in try/catch because a lazy getter can reference a
99+
// module that is still initialising mid-import; an unresolvable branch must not abort
100+
// the whole sweep (it will be reached again via another path).
101+
try {
102+
if (typeof d.getter === 'function') walk(d.getter(), p, depth + 1);
103+
const shape = typeof d.shape === 'function' ? d.shape() : d.shape;
104+
if (shape) for (const [k, v] of Object.entries(shape)) walk(v, `${p}.${k}`, depth + 1);
105+
if (d.element) walk(d.element, `${p}[]`, depth + 1);
106+
if (d.innerType) walk(d.innerType, p, depth + 1);
107+
if (d.valueType) walk(d.valueType, `${p}{}`, depth + 1);
108+
if (d.left) walk(d.left, p, depth + 1);
109+
if (d.right) walk(d.right, p, depth + 1);
110+
if (d.in) walk(d.in, p, depth + 1);
111+
if (d.out) walk(d.out, p, depth + 1);
112+
for (const o of d.options ?? []) walk(o, p, depth + 1);
113+
for (const t of d.items ?? []) walk(t, p, depth + 1);
114+
} catch { /* unresolvable branch — reached via another path if it matters */ }
115+
}
116+
117+
const files = zodFiles(SRC).sort();
118+
for (const f of files) {
119+
let mod: Record<string, unknown>;
120+
try { mod = await import(url.pathToFileURL(f).href); } catch { continue; }
121+
const rel = path.relative(SRC, f).replace(/\.zod\.ts$/, '');
122+
for (const [name, val] of Object.entries(mod)) {
123+
if (val && typeof val === 'object' && defOf(val)) walk(val, `${rel}#${name}`, 0);
124+
}
125+
}
126+
127+
if (LIST) {
128+
const rows = [...found.entries()].sort((a, b) => a[1].path.localeCompare(b[1].path));
129+
console.log(`${rows.length} discriminated union(s):\n`);
130+
for (const [key, v] of rows) console.log(`${v.path}\n key: ${key}\n ${v.variants.join(', ')}\n`);
131+
process.exit(0);
132+
}
133+
134+
const ledger: { entries: Entry[] } = JSON.parse(fs.readFileSync(LEDGER, 'utf-8'));
135+
const byKey = new Map(ledger.entries.map((e) => [e.key, e]));
136+
const errors: string[] = [];
137+
138+
// Duplicate keys in the ledger would let one entry silently shadow another.
139+
const dupes = ledger.entries.map((e) => e.key).filter((k, i, a) => a.indexOf(k) !== i);
140+
for (const k of new Set(dupes)) errors.push(`duplicate ledger key: ${k}`);
141+
142+
// Ratchet A — every discovered union must be declared.
143+
for (const [key, v] of found) {
144+
if (byKey.has(key)) continue;
145+
errors.push(
146+
`undeclared discriminated union at ${v.path}\n` +
147+
` key: ${key}\n` +
148+
` variants (${v.variants.length}): ${v.variants.join(', ')}\n` +
149+
` → add it to packages/spec/variant-docs.json: bind \`docs\` (and make sure each variant\n` +
150+
` is mentioned there), or declare \`exempt\` with a reason.`,
151+
);
152+
}
153+
154+
// Ratchet B — every ledger entry must still correspond to a real union. A changed
155+
// variant set surfaces here as an orphan plus an undeclared entry above.
156+
for (const e of ledger.entries) {
157+
if (!found.has(e.key)) {
158+
errors.push(
159+
`orphaned ledger entry "${e.label}"\n` +
160+
` key: ${e.key}\n` +
161+
` → no union with this discriminator/variant set exists any more. If variants changed,\n` +
162+
` update the key AND re-check the bound doc; if the union is gone, delete the entry.`,
163+
);
164+
}
165+
}
166+
167+
// Coverage — every variant of a governed union must be mentioned in a bound doc.
168+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
169+
let governed = 0, exempt = 0;
170+
for (const e of ledger.entries) {
171+
const u = found.get(e.key);
172+
if (!u) continue;
173+
if (e.exempt) { exempt++; continue; }
174+
if (!e.docs?.length) {
175+
errors.push(`ledger entry "${e.label}" declares neither \`docs\` nor \`exempt\`.`);
176+
continue;
177+
}
178+
governed++;
179+
180+
const missingDocs = e.docs.filter((d) => !fs.existsSync(path.join(REPO, d)));
181+
if (missingDocs.length) {
182+
errors.push(`ledger entry "${e.label}" binds doc(s) that do not exist: ${missingDocs.join(', ')}`);
183+
continue;
184+
}
185+
const blob = e.docs.map((d) => fs.readFileSync(path.join(REPO, d), 'utf-8')).join('\n');
186+
const undocumented = u.variants.filter((v) => {
187+
const anchored = new RegExp(`${esc(u.disc)}\\s*:\\s*['"\`]${esc(v)}['"\`]`);
188+
const token = new RegExp('`' + esc(v) + '`');
189+
return !anchored.test(blob) && !token.test(blob);
190+
});
191+
if (undocumented.length) {
192+
errors.push(
193+
`"${e.label}" — ${undocumented.length} variant(s) the schema declares but the doc never mentions:\n` +
194+
` ${undocumented.join(', ')}\n` +
195+
` doc(s): ${e.docs.join(', ')}\n` +
196+
` → document them, or if they are genuinely not author-facing, re-classify the entry.`,
197+
);
198+
}
199+
}
200+
201+
if (errors.length) {
202+
console.error(`\n✗ variant/doc gate: ${errors.length} problem(s)\n`);
203+
for (const e of errors) console.error(` ${e}\n`);
204+
process.exit(1);
205+
}
206+
console.log(
207+
`✓ variant/doc gate: ${found.size} discriminated union(s) — ` +
208+
`${governed} governed (every variant mentioned in a bound doc), ${exempt} exempt.`,
209+
);

0 commit comments

Comments
 (0)