|
| 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