diff --git a/package.json b/package.json index 3932c1536..f60fa6ab4 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "check:skill-eval-tokens": "node scripts/check-skill-eval-tokens.mjs", "check:doc-types": "node scripts/check-doc-component-types.mjs", "check:doc-snippets": "node scripts/check-doc-snippet-types.mjs", + "check:doc-examples": "node scripts/check-doc-example-types.mjs", "check:doc-fences": "node scripts/check-doc-fence-languages.mjs", "check:eager-closure": "node scripts/check-eager-closure-budget.mjs", "check:side-effects-array": "node scripts/check-side-effects-array.mjs", diff --git a/scripts/__tests__/check-doc-example-types.test.ts b/scripts/__tests__/check-doc-example-types.test.ts new file mode 100644 index 000000000..71f15fdb9 --- /dev/null +++ b/scripts/__tests__/check-doc-example-types.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Plain-JS CI helper; its types are inferred from the .mjs source by +// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here. +import ts from 'typescript'; +import { + EXIT_CODES, + MIN_REASON_LENGTH, + TS_FENCE_LANGUAGES, + UNGATED_EXAMPLES, + codesMatch, + exampleCensus, + exportedOwnerOf, + fencesOfExample, + funnelLines, + judge, + ledgerKey, + listExampleSources, + preludeFor, +} from '../check-doc-example-types.mjs'; + +/** + * objectui#8258 — the test for `scripts/check-doc-example-types.mjs`. + * + * The gate compiles JSDoc `@example` blocks on exported symbols against the + * BUILT types. Two kinds of assertion live here, and the split matters: + * + * INSTRUMENT cases built from fixture text, which prove the extraction and + * the four ledger verdicts behave as the header says — including + * the two that make the ledger shrink-only, which are the ones a + * reader would otherwise have to take on trust. + * CORPUS cases about THIS repository, which prove the instrument is + * pointed at a real population and that the ledger is exact. They + * are what stops the gate going green by scanning nothing. + * + * ⛔ What is deliberately NOT here: a case that runs the whole gate and asserts + * exit 0. That is `pnpm check:doc-examples`' job, it needs the built closure, + * and duplicating it here would make this file's runtime depend on a build. + */ + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '..', '..'); +const gateSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'check-doc-example-types.mjs'), 'utf8'); + +// ── instrument: extraction ─────────────────────────────────────────────────── + +describe('fence extraction inside an `@example` body', () => { + it('reads ts, tsx and typescript fences and nothing else', () => { + const fences = fencesOfExample( + ['```ts', 'const a = 1;', '```', '```json', '{}', '```', '```tsx', '
', '```'].join('\n'), + ); + expect(fences.map((f) => f.language)).toEqual(['ts', 'json', 'tsx']); + expect(fences.filter((f) => TS_FENCE_LANGUAGES.has(f.language))).toHaveLength(2); + }); + + it('closes a block at a fence of its own run length, so a wider wrapper is one block', () => { + const fences = fencesOfExample(['````ts', '```', 'const a = 1;', '```', '````'].join('\n')); + expect(fences).toHaveLength(1); + expect(fences[0].body).toBe(['```', 'const a = 1;', '```'].join('\n')); + }); + + it('keeps the snippet own indentation', () => { + const fences = fencesOfExample(['```ts', 'if (x) {', ' go();', '}', '```'].join('\n')); + expect(fences[0].body).toContain('\n go();'); + }); + + it('an unterminated fence runs to the end rather than swallowing the walk', () => { + const fences = fencesOfExample(['```ts', 'const a = 1;'].join('\n')); + expect(fences).toHaveLength(1); + expect(fences[0].body).toBe('const a = 1;'); + }); +}); + +describe('the exported owner is read from the AST, never from the text', () => { + const ownerOf = (source: string, needle: string) => { + const sf = ts.createSourceFile('probe.tsx', source, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX); + let found: ReturnType | null = null; + const visit = (node: ts.Node) => { + if (node.getText(sf).startsWith(needle) && found === null) found = exportedOwnerOf(node); + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + return found; + }; + + it('names an exported function', () => { + expect(ownerOf('export function useThing() {}', 'export function')).toEqual({ + exported: true, + symbol: 'useThing', + }); + }); + + it('names the VARIABLE, not the statement, for an exported const', () => { + expect(ownerOf('export const thing = 1;', 'export const')).toEqual({ + exported: true, + symbol: 'thing', + }); + }); + + it('walks OUTWARD: a property inside an exported interface belongs to the interface', () => { + const sf = ts.createSourceFile( + 'probe.ts', + 'export interface Shape {\n /** @example 1 */\n size: number;\n}', + ts.ScriptTarget.ES2022, + true, + ); + const iface = sf.statements[0] as ts.InterfaceDeclaration; + expect(exportedOwnerOf(iface.members[0])).toEqual({ exported: true, symbol: 'Shape' }); + }); + + it('reports a non-exported declaration as not exported', () => { + expect(ownerOf('function local() {}', 'function local')).toEqual({ + exported: false, + symbol: null, + }); + }); + + it('the word `export` inside a STRING does not make a declaration exported', () => { + expect(ownerOf('const doc = "export function fake() {}";\nfunction real() {}', 'function real')).toEqual({ + exported: false, + symbol: null, + }); + }); +}); + +// ── instrument: the one transformation ─────────────────────────────────────── + +describe('the documented symbol import is injected only when all three conditions hold', () => { + const block = { symbol: 'useThing', package: '@object-ui/x', body: 'const r = useThing();' }; + + it('injects when the block references the symbol and does not import it', () => { + expect(preludeFor(block, new Set())).toBe("import { useThing } from '@object-ui/x';\n"); + }); + + it('does NOT inject when the block never references the symbol', () => { + expect(preludeFor({ ...block, body: 'const r = 1;' }, new Set())).toBe(''); + }); + + it('does NOT inject when the block already imports the symbol itself', () => { + expect( + preludeFor({ ...block, body: "import { useThing } from 'somewhere';\nuseThing();" }, new Set()), + ).toBe(''); + }); + + it('does NOT inject when the package entry does not export the symbol — the gate never blames an example for this transformation', () => { + expect(preludeFor(block, new Set(['@object-ui/x useThing']))).toBe(''); + }); +}); + +// ── instrument: the four ledger verdicts ───────────────────────────────────── + +describe('the ledger is re-derived, never trusted', () => { + const reason = 'a written reason long enough to be a declaration'; + + it('a failing block with no row is a FAILURE — the default is COVERED', () => { + const { findings } = judge({ results: [{ key: 'a.ts:1 A', codes: [2322] }], ledger: {} }); + expect(findings.map((f) => f.reason)).toEqual(['undeclared-failure']); + }); + + it('a failing block whose row matches is exempt, and nothing is reported', () => { + const { findings, exempt } = judge({ + results: [{ key: 'a.ts:1 A', codes: [2322] }], + ledger: { 'a.ts:1 A': { codes: [2322], reason } }, + }); + expect(findings).toEqual([]); + expect(exempt).toEqual(['a.ts:1 A']); + }); + + it('SHRINK-ONLY: a row whose block now COMPILES is stale, and that is a red', () => { + const { findings } = judge({ + results: [{ key: 'a.ts:1 A', codes: [] }], + ledger: { 'a.ts:1 A': { codes: [2322], reason } }, + }); + expect(findings).toHaveLength(1); + expect(findings[0].reason).toBe('stale-ledger-row'); + expect(findings[0].detail).toContain('COMPILES now'); + }); + + it('SHRINK-ONLY: a row whose block fails DIFFERENTLY is a red — a row may not cover a failure it never declared', () => { + const { findings } = judge({ + results: [{ key: 'a.ts:1 A', codes: [2322] }], + ledger: { 'a.ts:1 A': { codes: [2304], reason } }, + }); + expect(findings).toHaveLength(1); + expect(findings[0].reason).toBe('ledger-row-drifted'); + expect(findings[0].detail).toContain('TS2304'); + expect(findings[0].detail).toContain('TS2322'); + }); + + it('a row naming a block that is gone is stale', () => { + const { findings } = judge({ results: [], ledger: { 'gone.ts:1 A': { codes: [2322], reason } } }); + expect(findings.map((f) => f.reason)).toEqual(['stale-ledger-row']); + expect(findings[0].detail).toContain('no such example'); + }); + + it('a row with no written reason is not a declaration', () => { + const { findings } = judge({ + results: [{ key: 'a.ts:1 A', codes: [2322] }], + ledger: { 'a.ts:1 A': { codes: [2322], reason: 'short' } }, + }); + expect(findings.map((f) => f.reason)).toEqual(['unexplained-ledger-row']); + }); + + it('a clean block with no row is silent — the whole point of the exercise', () => { + expect(judge({ results: [{ key: 'a.ts:1 A', codes: [] }], ledger: {} }).findings).toEqual([]); + }); + + it('codes are compared as a SET: a doubled diagnostic is not a changed failure', () => { + expect(codesMatch([2322, 2322], [2322])).toBe(true); + expect(codesMatch([2322, 2304], [2304, 2322])).toBe(true); + expect(codesMatch([2322], [2322, 2304])).toBe(false); + }); +}); + +// ── corpus: this repository ────────────────────────────────────────────────── + +describe('this repository', () => { + const census = exampleCensus({ root: repoRoot }); + + it('walks a plausible number of sources — an empty walk makes every verdict vacuous', () => { + expect(census.files.length).toBeGreaterThan(500); + expect(census.excludedAsTooling.length).toBeGreaterThan(100); + }); + + it('has examples to judge — the compiled tier is not empty', () => { + expect(census.blocks.length).toBeGreaterThan(50); + }); + + it('every block in the compiled tier names an exported symbol and a package', () => { + for (const block of census.blocks) { + expect(block.symbol, block.file).toBeTruthy(); + expect(block.package, block.file).toMatch(/^@object-ui\/|^object-ui$/); + } + }); + + it('the bare tier is COUNTED, not dropped — the header stands or falls on that number', () => { + expect(census.bare.length).toBeGreaterThan(0); + expect(census.exported.length).toBe( + census.withTsFence.length + census.otherFenceOnly.length + census.bare.length, + ); + }); + + it('the funnel prints every narrowing step, so the enforced number is derived', () => { + const lines = funnelLines(census); + expect(lines.join('\n')).toContain('EXPORTED declarations'); + expect(lines.join('\n')).toContain('BLOCKS in the compiled tier'); + expect(lines.at(-1)).toContain(String(census.blocks.length)); + }); + + it('tooling files contribute nothing to the compiled tier', () => { + const toolingFiles = new Set(census.excludedAsTooling); + for (const block of census.blocks) expect(toolingFiles.has(block.file)).toBe(false); + }); +}); + +describe('the real ledger', () => { + const census = exampleCensus({ root: repoRoot }); + const keys = new Set(census.blocks.map((b) => ledgerKey(b))); + + it('every row names a block that is actually in the compiled tier', () => { + for (const key of Object.keys(UNGATED_EXAMPLES)) expect(keys.has(key), key).toBe(true); + }); + + it('every row carries a written reason and a code list', () => { + for (const [key, row] of Object.entries(UNGATED_EXAMPLES)) { + expect(row.reason.trim().length, key).toBeGreaterThanOrEqual(MIN_REASON_LENGTH); + expect(row.codes.length, key).toBeGreaterThan(0); + expect(row.codes.every((c) => Number.isInteger(c)), key).toBe(true); + } + }); + + it('is a DEBT, not the corpus: some blocks are off it and therefore actually judged', () => { + const declared = Object.keys(UNGATED_EXAMPLES).length; + expect(declared).toBeLessThan(census.blocks.length); + expect(census.blocks.length - declared).toBeGreaterThan(0); + }); + + it('a row that names a card names one this repository can be asked about', () => { + for (const [key, row] of Object.entries(UNGATED_EXAMPLES)) { + if (row.card === null) continue; + expect(row.card, key).toMatch(/^objectui#\d+$/); + } + }); +}); + +// ── the card's own acceptance criterion ────────────────────────────────────── + +describe('objectui#7974 — the defect this gate was filed for', () => { + const key = 'packages/mobile/src/useSpecGesture.ts:69 useSpecGesture'; + + it('its example is IN the compiled tier — the gate reaches the block the card named', () => { + const census = exampleCensus({ root: repoRoot }); + expect(census.blocks.map((b) => ledgerKey(b))).toContain(key); + }); + + it('the scalar `direction` the card measured is what the block still carries', () => { + const source = fs.readFileSync(path.join(repoRoot, 'packages/mobile/src/useSpecGesture.ts'), 'utf8'); + expect(source).toContain("direction: 'left'"); + }); + + it('its row records TS2322 by NUMBER and names the card that owns the repair', () => { + const row = UNGATED_EXAMPLES[key]; + expect(row).toBeDefined(); + expect(row.codes).toContain(2322); + expect(row.card).toBe('objectui#7974'); + }); + + it('the row is the ONLY thing keeping this green — remove it and the block is an undeclared failure', () => { + const { findings } = judge({ + results: [{ key, codes: UNGATED_EXAMPLES[key].codes }], + ledger: {}, + }); + expect(findings.map((f) => f.reason)).toEqual(['undeclared-failure']); + }); + + it("when that lane repairs the example the row goes STALE, so the debt cannot outlive the defect", () => { + const { findings } = judge({ + results: [{ key, codes: [] }], + ledger: { [key]: UNGATED_EXAMPLES[key] }, + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ reason: 'stale-ledger-row', site: key }); + }); +}); + +// ── the decisions this gate is asked to state in its own source ────────────── + +describe('the script states its own rulings, so they cannot drift out of the source', () => { + it('states why bare `@example` bodies are counted and not compiled', () => { + expect(gateSource).toContain('Why bare `@example` bodies are counted and not compiled'); + }); + + it('states the ONE transformation and that it is the only one', () => { + expect(gateSource).toContain('The ONE transformation, stated out loud'); + }); + + it('records that the `declare var NAME: any` route was priced and REFUSED', () => { + expect(gateSource).toContain('declare var NAME: any'); + expect(gateSource).toContain('REFUSED'); + }); + + it('states the template-literal decision and points at the instrument that already owns it', () => { + expect(gateSource).toContain('The template-literal half of objectui#8258'); + expect(gateSource).toContain('--emit-census'); + }); + + it('carries the three exit codes the sibling carries, for the reason the sibling gives', () => { + expect(EXIT_CODES).toEqual({ verified: 0, examplesFailed: 1, couldNotRun: 2 }); + }); + + it('refuses a verdict when the walk collapses or the compiled tier is empty', () => { + expect(gateSource).toContain('would be a zero that means nothing'); + expect(gateSource).toContain('The compiled tier is EMPTY'); + }); +}); + +// ── the sibling is not perturbed ───────────────────────────────────────────── + +describe('the sibling harness is imported, not forked', () => { + it('imports the compile harness rather than re-spelling it', () => { + expect(gateSource).toContain("from './check-doc-snippet-types.mjs'"); + expect(gateSource).toMatch(/import \{[^}]*compileSnippets[^}]*\} from '\.\/check-doc-snippet-types\.mjs'/s); + }); + + it("does not re-declare the sibling's controls — a second sentinel would be a second answer", () => { + expect(gateSource).not.toContain('ThisNameIsDefinitelyNotExported'); + }); + + it('the strictness region of the sibling is untouched by this card', () => { + const sibling = fs.readFileSync( + path.join(repoRoot, 'scripts', 'check-doc-snippet-types.mjs'), + 'utf8', + ); + const banner = sibling.indexOf('── Fence scanning'); + expect(banner).toBeGreaterThan(0); + // The region is licensed: this card may read it, never edit it. Pinned by + // content rather than by hash so the failure names WHAT moved. + expect(sibling.slice(banner)).toContain('export function scanFences(source)'); + expect(sibling.slice(banner)).toContain('export function compileSnippets('); + }); + + it('the walk uses the same tooling rule the other source-walking gates use', () => { + const { files, excludedAsTooling } = listExampleSources(repoRoot); + expect(files.some((f) => f.includes('__tests__'))).toBe(false); + expect(excludedAsTooling.some((f) => f.includes('__tests__') || /\.test\./.test(f))).toBe(true); + }); +}); diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs new file mode 100644 index 000000000..c71311fdf --- /dev/null +++ b/scripts/check-doc-example-types.mjs @@ -0,0 +1,1349 @@ +#!/usr/bin/env node +/** + * Every ```ts / ```tsx fenced block inside a JSDoc `@example` on an EXPORTED + * declaration under `packages/NAME/src/**` must COMPILE, `--strict`, against the + * packages' BUILT `dist/*.d.ts` — or be DECLARED in the ledger below with a + * written reason and the diagnostics it currently produces. + * + * Run: node scripts/check-doc-example-types.mjs (also `pnpm check:doc-examples`) + * Exit: 0 = every covered example compiles, or fails exactly as its ledger row + * says it does; the harness proved itself on its own controls. + * 1 = THE GATE RAN AND FOUND ERRORS. An example failed and nothing + * declared it, an example a row declares now compiles, or a row's + * diagnostics no longer match what the example produces. + * 2 = THE GATE COULD NOT RUN, so nothing printed above is a verdict about + * any example: the packages are unbuilt (or typed from source), the walk + * collapsed, or one of the harness's own controls failed. + * + * This is a SIBLING of `scripts/check-doc-snippet-types.mjs`, not a fork. The + * scan surface, the extraction and the ledger are this file's; the compiler + * host, the built-`.d.ts` resolution, the root bound and every control are + * IMPORTED from that harness and run unmodified, so an `@example` block is + * judged by exactly the program a documentation fence is judged by. The two + * gates' exit codes are deliberately the same three, for the reason that file's + * header gives: "I could not run" and "I ran and found errors" are different + * facts and must not share a code. + * + * ## What this gate answers, and the four things it does NOT + * + * It answers exactly one question: **does this shipped `@example` still compile + * against the published types.** objectui#7974 is the defect that shape has: + * `useSpecGesture`'s own `@example` passes a scalar `swipe.direction` where the + * declared type is `SpecSwipeDirection[]`, so a reader who copies the example + * out of their IDE's hover gets TS2322 — and nothing in this repository had ever + * compiled it, because the Markdown gate's surface stops at `content/docs`, the + * per-app docs trees, the package READMEs and the root pages. + * + * It does NOT answer: + * + * 1. **Whether a BARE `@example` body is correct.** Only FENCED ts/tsx blocks + * are compiled. The census below counts the bare ones rather than hiding + * them, and the reason they stay out is measured, not assumed — see + * "Why bare `@example` bodies are counted and not compiled". + * 2. **Whether the example is idiomatic, runnable, or true.** A block that + * compiles can still teach a call nobody should make. That is review. + * 3. **Schema-key validity** — the sibling's blind spot 1, unchanged here: a + * metadata literal that `safeParse` would reject can still satisfy a + * TypeScript annotation. + * 4. **Code inside template literals.** That population already has an + * instrument: `check-doc-snippet-types.mjs --emit-census` (objectui#7864) + * walks the same `packages/NAME/src/**` tree, recognises a template that + * carries an `import`, substitutes its holes and compiles it through the + * same `compileSnippets()`. Adding a second reader of the same population + * here would be a second answer to a question that already has one. See + * "The template-literal half of objectui#8258" below. + * + * ## The population, and why the compiled tier is the fenced blocks + * + * Measured on the branch point (objectui#8258's first run), with the funnel this + * gate re-prints on every run so the number it enforces stays DERIVED rather + * than asserted: + * + * 1418 source files under packages/NAME/src (.ts/.tsx), tooling excluded + * 2421 ... tooling files excluded by TOOLING_FILE (tests, mocks, stories) + * 228 `@example` tags found by the AST + * 0 ... in tooling files + * 15 ... on a NON-exported declaration + * 213 `@example` tags on EXPORTED declarations + * 124 ... carrying a ts/tsx/typescript fence <- THE COMPILED TIER + * 1 ... carrying only an unlabelled fence + * 88 ... BARE, no fence at all + * + * ### Why bare `@example` bodies are counted and not compiled + * + * Not an optimisation and not a silent skip — a measurement. 67 of the 88 bare + * bodies parse as TSX, which sounds like a compilable population and is not: the + * dominant shape is a VALUE illustration on an interface property, and it parses + * only because a comma-separated list of string literals is a legal expression + * statement. From `packages/types/src/base.ts`: + * + * Component type identifier. Determines which renderer to use. + * `@example` 'input', 'button', 'form', 'grid' + * + * Compiling that judges nothing — it is four string literals — while the fenced + * tier holds the blocks whose authors wrote a module. Feeding the bare tier to a + * strict program would judge prose on rules its author never opted into, which is + * the same reason `TS_FENCE_LANGUAGES` in the sibling excludes `js` and `jsx`. + * The count is printed every run, so the day someone starts fencing them the + * number moves and this paragraph is re-read. + * + * ## The ONE transformation, stated out loud + * + * A JSDoc `@example` is read in the IDE beside the declaration it documents, so + * the documented symbol is in scope for its reader by construction; a fenced + * block almost never imports it (measured: 8 of 124 import anything at all). The + * gate therefore prepends ONE line: + * + * import { SYMBOL } from 'PACKAGE'; + * + * and only when all three hold, each re-checked per run: + * + * - the block's text references SYMBOL; + * - the block does not already import SYMBOL itself; + * - PACKAGE's BUILT entry really exports SYMBOL — probed in the same program, + * never assumed. Measured: 111 of 114 documented symbols are on their + * package's public entry; the 3 that are not are reported by name. + * + * ⚠️ The probe asks the package's ROOT specifier and only that one, so a symbol + * published under a SUBPATH reads as "not on a public entry" here. That is a + * deliberately conservative answer — it withholds the injection rather than + * guessing a subpath — but it means the printed list is "symbols this gate did + * not inject", NOT a list of defects. On this corpus 2 of the 3 are subpath + * exports (`@object-ui/types/zod`) and both blocks compile anyway; the third, + * `MetadataCache`, is genuinely absent from its package's only export. + * + * Prepended, not appended, because an `import` must precede the code that uses + * it; the printed line numbers therefore carry an offset, which `formatDiagnostic` + * is given as the block's fence line. Without this transformation the run is + * unreadable rather than strict: 303 of 348 diagnostics were TS2304 naming the + * documented symbol itself, and objectui#7974's real TS2322 was MASKED behind + * `Cannot find name 'useSpecGesture'`. That is the whole argument for the line — + * it does not make failures go away, it makes the surviving ones be about the + * documented API. + * + * ⛔ What was priced and REFUSED: a second pass declaring every remaining free + * name as `declare var NAME: any`. It shrinks the ledger from 90 rows to 32 and + * is the wrong trade in every direction. It INVENTS diagnostics (measured: 6 + * TS2749 "refers to a value, but is being used as a type" and 2 TS2451 + * redeclarations that exist only because of the injected declarations), it makes + * every downstream check on those names vacuous, and a green bought with `any` + * teaches the next author that referencing an undeclared name is how you stop the + * gate looking — the consumer-side tolerance AGENTS.md commandment #0.1 bans, one + * level up. A 90-row ledger that says a true thing beats a 32-row ledger that + * launders 58 rows through `any`. + * + * ## The ledger, and what makes it shrink-only + * + * `UNGATED_EXAMPLES` is keyed by `path:line symbol` and each row carries the + * diagnostic CODES the example currently produces, a written reason, and the card + * that owns it. Four verdicts, and only the first is silent: + * + * block fails + row's codes match -> declared debt, exempt + * block fails + no row -> RED. A new example must compile. + * block COMPILES + row -> RED, stale. The debt was paid; the + * row outlived it and must be deleted. + * block fails + row's codes DIFFER -> RED. The failure changed underneath + * the declaration; re-derive the row. + * row naming a block that is gone -> RED, stale. + * + * The third verdict is what makes the ledger shrink-only, and it is the same pin + * `UNGATED_DOCS` carries in the sibling: a debt that can be declared once and + * never re-examined is not a ledger, it is a mute button. The fourth is this + * gate's addition — a row that says "fails with TS2304" must not go on covering + * the block after the failure became TS2322. + * + * ⚠️ Recording codes makes a row sensitive to the TypeScript version, on purpose. + * An upgrade that renumbers or splits a diagnostic reddens the rows it moved, + * with "re-derive" printed beside them. That is a loud, correct signal about a + * ledger whose rows are claims about a compiler; the alternative — a row that + * covers whatever the block does today — is the mute button again. + * + * ### The first run: 90 of 124, and why it lands as a ledger rather than a clean + * + * 119 blocks reached the semantic phase (5 do not parse), 34 compile, 85 fail. + * The failures are overwhelmingly ONE shape: a usage fragment that references + * ambient names it never declares (`manager`, `evaluator`, `navigate`, `App`). + * Those are not defects in the documented API and repairing them is editorial + * work on 66 separate files — which is precisely what a declared, shrink-only + * ledger is for, and precisely how objectui#5174 burned `UNGATED_DOCS` down to + * the empty object it is today. Each row names what the example references, so + * the row goes stale the moment the example is made self-contained. + * + * ⚠️ objectui#7974 is OPEN, and its row is the reason this gate ships with a + * ledger rather than a green: `packages/mobile/src/useSpecGesture.ts` still + * carries the scalar `direction` on `main`, its card is on another lane's queue + * (`domain:ui`, `pm:queue`), and this gate may not fix it. The row records + * TS2322 by number. When that lane repairs the example the row goes STALE and + * reddens on THEIR pull request, which is the hand-off working as designed, not + * a defect in it. + * + * ## The template-literal half of objectui#8258 + * + * The card asks this gate to "decide per case whether to extract (a fenced marker + * inside the literal) or to exempt with a reason". The decision, measured: + * **no new rule here, and the exemption has a reason that is not "too hard".** + * + * - The extraction the card imagines ALREADY EXISTS and already runs on this + * exact tree. `--emit-census` reports 20 recognised templates across + * `packages/cli`, `packages/create-plugin` and `packages/vscode-extension`, + * compiled through the same `compileSnippets()`. Building a second extractor + * here would put two readers on one population, which is how the two answers + * start disagreeing. + * - objectui#7977 is CLOSED (by objectui#8112, which corrected the prose). + * - objectui#7976's residue is NOT a template-parsing problem. It is + * `packages/vscode-extension/DESIGN.md` holding a hand-copied MIRROR of a + * template's text. Compiling the template — which `--emit-census` already + * does, at 0 diagnostics — says nothing about whether the mirror still + * matches it. That card needs an equality pin between two texts, which is a + * different instrument from a type-checker and belongs on that card. + * + * @type {never} + */ + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, resolve, sep, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { isEntrypoint } from './invoked-as.mjs'; +import { TOOLING_FILE } from './check-phantom-dependencies.mjs'; +import { analyze, compileSnippets } from './check-doc-snippet-types.mjs'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// ── Configuration ──────────────────────────────────────────────────────────── + +/** The tree this gate walks. Identical to the emitted census's, and to + * `check-phantom-dependencies`', so "a package's own source" means one thing. */ +export const PACKAGES_DIR = 'packages'; +export const SOURCE_SUBDIR = 'src'; + +/** Source extensions collected. */ +const SOURCE_EXTENSION = /\.tsx?$/; + +/** Fence languages treated as compilable TypeScript. Deliberately the same set + * the sibling uses; `js` / `jsx` are excluded there for a reason that holds + * here unchanged — they are not type-annotated. */ +export const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); + +/** The shortest reason that counts as a declaration. The sibling's number. */ +export const MIN_REASON_LENGTH = 12; + +// ── Extraction ─────────────────────────────────────────────────────────────── + +/** + * Every `.ts`/`.tsx` file under a workspace package's `src/`, split by the rule + * `check-phantom-dependencies.mjs` already owns. The tooling half is RETURNED + * rather than dropped: a population hiding in a test fixture is a different fact + * from no population at all, and the funnel prints both. + * + * @param {string} root + * @returns {{ files: string[], excludedAsTooling: string[] }} + */ +export function listExampleSources(root = repoRoot) { + const files = []; + const excludedAsTooling = []; + const pkgDir = join(root, PACKAGES_DIR); + if (!existsSync(pkgDir)) return { files, excludedAsTooling }; + for (const entry of readdirSync(pkgDir).sort()) { + const src = join(pkgDir, entry, SOURCE_SUBDIR); + if (!existsSync(src) || !statSync(src).isDirectory()) continue; + const walk = (dir) => { + for (const name of readdirSync(dir).sort()) { + const p = join(dir, name); + if (statSync(p).isDirectory()) { + walk(p); + continue; + } + if (!SOURCE_EXTENSION.test(name)) continue; + const rel = relative(root, p).split(sep).join('/'); + (TOOLING_FILE.test(rel) ? excludedAsTooling : files).push(rel); + } + }; + walk(src); + } + return { files, excludedAsTooling }; +} + +/** + * The name of the EXPORTED declaration that owns this JSDoc, or `null` when the + * nearest declaration carrying an `export` modifier has no identifier to name. + * + * Walks OUTWARD from the documented node, because the `export` keyword sits on + * the statement while the JSDoc may hang off a member inside it — an `@example` + * on an interface property belongs to the exported interface. A + * `VariableStatement` carries the modifier but not the name; its first + * declaration does. + * + * ⚠️ Read from the AST, never from a regex over the text, for the reason + * `moduleSpecifiersOf` gives in the sibling (objectui#7555): the word `export` + * inside a string or a comment is not an export. + * + * @param {ts.Node} node + * @returns {{ exported: boolean, symbol: string | null }} + */ +export function exportedOwnerOf(node) { + let n = node; + while (n) { + const modifiers = ts.canHaveModifiers(n) ? ts.getModifiers(n) : undefined; + if (modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + if (ts.isVariableStatement(n)) { + const d = n.declarationList.declarations[0]; + return { exported: true, symbol: d?.name && ts.isIdentifier(d.name) ? d.name.text : null }; + } + const named = /** @type {{ name?: ts.Node }} */ (n).name; + return { exported: true, symbol: named && ts.isIdentifier(named) ? named.text : null }; + } + n = n.parent; + } + return { exported: false, symbol: null }; +} + +/** + * Every fenced block inside one `@example` body, matched by its own fence run + * length so a wider wrapper containing ``` does not confuse the walk. + * + * A JSDoc comment body has already had its leading ` * ` column stripped by the + * TypeScript parser, so no quote-prefix machinery is needed here — that is the + * one piece of `scanFences` this file does not reuse, and the reason it does + * not. + * + * @param {string} text + * @returns {{ language: string, body: string }[]} + */ +export function fencesOfExample(text) { + const lines = text.split('\n'); + const out = []; + for (let i = 0; i < lines.length; i++) { + const open = /^[ \t]*(`{3,})(.*)$/.exec(lines[i]); + if (!open) continue; + const ticks = open[1]; + let close = lines.length; + for (let j = i + 1; j < lines.length; j++) { + const c = /^[ \t]*(`{3,})[ \t]*$/.exec(lines[j]); + if (c && c[1].length >= ticks.length) { + close = j; + break; + } + } + out.push({ + language: (open[2].trim().split(/\s+/)[0] || '').toLowerCase(), + body: lines.slice(i + 1, close).join('\n'), + }); + i = close; + } + return out; +} + +/** + * The census: every `@example` tag in the tree, classified. Counts nothing away + * — the funnel it feeds prints each narrowing step, so the number the gate + * enforces is derived from the population rather than asserted about it. + * + * @param {{ root?: string }} options + */ +export function exampleCensus({ root = repoRoot } = {}) { + const { files, excludedAsTooling } = listExampleSources(root); + const packageNameOf = {}; + const pkgDir = join(root, PACKAGES_DIR); + if (existsSync(pkgDir)) { + for (const entry of readdirSync(pkgDir).sort()) { + const manifest = join(pkgDir, entry, 'package.json'); + if (!existsSync(manifest)) continue; + const name = JSON.parse(readFileSync(manifest, 'utf8')).name; + if (name) packageNameOf[`${PACKAGES_DIR}/${entry}`] = name; + } + } + + const tags = []; + const collect = (fileList, tooling) => { + for (const file of fileList) { + const source = readFileSync(join(root, file), 'utf8'); + if (!source.includes('@example')) continue; + const sf = ts.createSourceFile( + file, + source, + ts.ScriptTarget.ES2022, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const packageDir = file.split('/').slice(0, 2).join('/'); + const visit = (node) => { + const jsDoc = /** @type {{ jsDoc?: ts.JSDoc[] }} */ (node).jsDoc; + for (const doc of Array.isArray(jsDoc) ? jsDoc : []) { + for (const tag of doc.tags ?? []) { + if (tag.tagName.text !== 'example') continue; + const { exported, symbol } = exportedOwnerOf(node); + const text = + typeof tag.comment === 'string' + ? tag.comment + : (ts.getTextOfJSDocComment(tag.comment) ?? ''); + tags.push({ + file, + line: sf.getLineAndCharacterOfPosition(tag.getStart(sf)).line + 1, + tooling, + exported, + symbol, + package: packageNameOf[packageDir], + fences: fencesOfExample(text), + text, + }); + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sf, visit); + } + }; + collect(files, false); + collect(excludedAsTooling, true); + + const inSources = tags.filter((t) => !t.tooling); + const exported = inSources.filter((t) => t.exported && t.symbol !== null); + const blocks = []; + for (const tag of exported) { + for (const fence of tag.fences) { + if (!TS_FENCE_LANGUAGES.has(fence.language)) continue; + blocks.push({ + file: tag.file, + line: tag.line, + symbol: tag.symbol, + package: tag.package, + language: fence.language, + body: fence.body, + }); + } + } + const withTsFence = exported.filter((t) => + t.fences.some((f) => TS_FENCE_LANGUAGES.has(f.language)), + ); + + return { + files, + excludedAsTooling, + tags, + inSources, + exported, + withTsFence, + otherFenceOnly: exported.filter( + (t) => t.fences.length > 0 && !t.fences.some((f) => TS_FENCE_LANGUAGES.has(f.language)), + ), + bare: exported.filter((t) => t.fences.length === 0), + blocks, + }; +} + +// ── The ledger ─────────────────────────────────────────────────────────────── + +/** + * The declared debt: examples that do not compile today, each with the codes it + * produces, a written reason, and the card that owns it. + * + * Keys are `path:line symbol`. The line is the `@example` TAG's line, which is + * where a reader looking for the block starts; it moves when the file moves, and + * a row whose key no longer resolves is reported as stale rather than ignored. + * + * ⛔ A row is not a place to park a defect. Every row here is a claim that the + * example is a FRAGMENT (it references context its reader supplies) or that a + * named card owns the repair. Adding a row to silence a real defect in a + * documented API is the failure this gate exists to catch, one level up. + * + * @type {Record} + */ +export const UNGATED_EXAMPLES = { + 'packages/auth/src/AuthGuard.tsx:36 AuthGuard': { + card: null, + codes: [2657], + reason: + 'two sibling JSX elements with no wrapper: the block is a render-body excerpt, not a module', + }, + 'packages/auth/src/AuthProvider.tsx:142 AuthProvider': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `App`, which the example never declares', + }, + 'packages/auth/src/AuthProvider.tsx:149 AuthProvider': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `App`, which the example never declares', + }, + 'packages/auth/src/AuthProvider.tsx:155 AuthProvider': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `App`, which the example never declares', + }, + 'packages/auth/src/AuthShell.tsx:66 AuthShell': { + card: null, + codes: [2304, 2552], + reason: + 'usage fragment: references `LoginForm`, `navigate`, which the example never declares', + }, + 'packages/auth/src/createAuthClient.ts:270 createAuthClient': { + card: null, + codes: [18004], + reason: + 'shorthand `{ email, password }` stands for credentials the caller supplies; the example never declares them', + }, + 'packages/auth/src/ForgotPasswordForm.tsx:107 ForgotPasswordForm': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `setShowSuccess`, which the example never declares', + }, + 'packages/auth/src/LoginForm.tsx:126 LoginForm': { + card: null, + codes: [2552], + reason: + 'usage fragment: references `navigate`, which the example never declares', + }, + 'packages/auth/src/RegisterForm.tsx:102 RegisterForm': { + card: null, + codes: [2552], + reason: + 'usage fragment: references `navigate`, which the example never declares', + }, + 'packages/auth/src/useAuth.ts:16 useAuth': { + card: null, + codes: [18047], + reason: + 'guards on `isAuthenticated`, which strict null checking cannot correlate with `user` being non-null', + }, + 'packages/auth/src/UserMenu.tsx:31 UserMenu': { + card: null, + codes: [2552], + reason: + 'usage fragment: references `navigate`, which the example never declares', + }, + 'packages/components/src/notifications/NotificationAlerts.tsx:58 NotificationAlerts': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `App`, `NotificationProvider`, which the example never declares', + }, + 'packages/components/src/notifications/NotificationBanners.tsx:38 NotificationBanners': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `Outlet`, which the example never declares', + }, + 'packages/components/src/notifications/NotificationInline.tsx:43 NotificationInline': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `notify`, which the example never declares', + }, + 'packages/components/src/notifications/NotificationSnackbar.tsx:43 NotificationSnackbar': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `App`, `NotificationProvider`, which the example never declares', + }, + 'packages/core/src/actions/TransactionManager.ts:129 TransactionManager': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `actionExecutor`, `createOrderAction`, `manager`, `sendNotificationAction`, `updateInventoryAction`, which the example never declares', + }, + 'packages/core/src/actions/TransactionManager.ts:244 TransactionManager': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `dataSource`, `manager`, which the example never declares', + }, + 'packages/core/src/actions/TransactionManager.ts:324 TransactionManager': { + card: null, + codes: [2304, 7006], + reason: + 'usage fragment: references `items`, `manager`, which the example never declares, so what depends on them is judged unbound', + }, + 'packages/core/src/adapters/resolveDataSource.ts:36 resolveDataSource': { + card: null, + codes: [2304, 18047], + reason: + 'usage fragment: references `contextDataSource`, which the example never declares, so what depends on it is judged unbound', + }, + 'packages/core/src/data-scope/DataScopeManager.ts:68 DataScopeManager': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `myDataSource`, which the example never declares', + }, + 'packages/core/src/data-scope/ViewDataProvider.ts:139 ViewDataProvider': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `myFetcher`, which the example never declares', + }, + 'packages/core/src/evaluator/ExpressionEvaluator.ts:264 ExpressionEvaluator': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `evaluator`, which the example never declares', + }, + 'packages/core/src/evaluator/ExpressionEvaluator.ts:334 ExpressionEvaluator': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `evaluator`, which the example never declares', + }, + 'packages/core/src/evaluator/ExpressionEvaluator.ts:534 ExpressionEvaluator': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `fmt`, which the example never declares', + }, + 'packages/core/src/registry/WidgetRegistry.ts:41 WidgetRegistry': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `registry`, which the example never declares', + }, + 'packages/core/src/utils/debug.ts:109 debugLog': { + card: null, + codes: [7017], + reason: + 'sets a debug flag on `globalThis`, which has no index signature under strict mode', + }, + 'packages/core/src/utils/freeze-schema.ts:144 defineSystemView': { + card: null, + codes: [2339], + reason: + 'demonstrates that the returned view is frozen by showing a `push` the readonly type rejects — the diagnostic IS the lesson', + }, + 'packages/core/src/utils/record-source.ts:140 resolveRecordSourceConfig': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `resolveRecordSourceObjectName`, `schema`, `useMemo`, which the example never declares', + }, + 'packages/core/src/utils/record-source.ts:69 resolveRecordSourceObjectName': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `resolveRecordSourceConfig`, `schema`, `useMemo`, which the example never declares', + }, + 'packages/core/src/validation/schema-validator.ts:490 assertValidSchema': { + card: null, + codes: [2304, 18046], + reason: + 'usage fragment: references `schema`, which the example never declares, so what depends on it is judged unbound', + }, + 'packages/core/src/validation/schema-validator.ts:515 isValidSchema': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `data`, `renderSchema`, which the example never declares', + }, + 'packages/data-objectstack/src/cache/MetadataCache.ts:56 MetadataCache': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `MetadataCache`, `fetchSchemaFromServer`, which the example never declares', + }, + 'packages/data-objectstack/src/index.ts:6156 createObjectStackAdapter': { + card: null, + codes: [2591], + reason: + 'usage fragment: references `process`, which the example never declares, so what depends on it is judged unbound', + }, + 'packages/i18n/src/provider.tsx:370 I18nProviderProps': { + card: null, + codes: [2304, 7006], + reason: + 'usage fragment: references `App`, `I18nProvider`, which the example never declares, so what depends on them is judged unbound', + }, + 'packages/i18n/src/provider.tsx:393 I18nProviderProps': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `App`, `I18nProvider`, `loadLanguage`, `loadLocales`, which the example never declares', + }, + 'packages/i18n/src/provider.tsx:428 I18nProvider': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `App`, which the example never declares', + }, + 'packages/i18n/src/useObjectLabel.ts:88 useObjectLabel': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `objectDef`, which the example never declares', + }, + 'packages/i18n/src/utils/spec-formatters.ts:64 resolvePlural': { + card: null, + codes: [2304], + reason: + 'annotates with `SpecPluralRule`, a type the example does not import', + }, + 'packages/layout/src/AppSchemaRenderer.tsx:485 AppSchemaRenderer': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `Outlet`, `appJson`, `can`, `evaluateVisibility`, `evaluator`, which the example never declares', + }, + 'packages/layout/src/NavigationRenderer.tsx:1243 NavigationRenderer': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `appSchema`, `can`, `evaluateVisibility`, `evaluator`, `saveOrder`, `searchTerm`, `updatePin`, which the example never declares', + }, + 'packages/layout/src/ResponsiveGrid.tsx:119 ResponsiveGrid': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `Card`, which the example never declares', + }, + 'packages/mobile/src/useGesture.ts:29 useGesture': { + card: null, + codes: [1108], + reason: + 'a hook-body excerpt: its `return` sits outside any function, so the block is a fragment by shape', + }, + 'packages/mobile/src/useSpecGesture.ts:69 useSpecGesture': { + card: 'objectui#7974', + codes: [1108, 2322], + reason: + 'the scalar `swipe.direction` this example passes is rejected by the declared `SpecSwipeDirection[]` (TS2322). objectui#7974 owns BOTH halves — the example and the lenient cast that hides it — and is on another lane. Delete this row when that card lands; the block also returns outside a function (TS1108), a hook-body excerpt', + }, + 'packages/mobile/src/useTouchTarget.ts:33 useTouchTarget': { + card: null, + codes: [1108], + reason: + 'a hook-body excerpt: its `return` sits outside any function, so the block is a fragment by shape', + }, + 'packages/plugin-designer/src/EditorModeToggle.tsx:46 EditorModeToggle': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `mode`, `setMode`, which the example never declares', + }, + 'packages/plugin-designer/src/hooks/useDesignerHistory.ts:24 useDesignerHistory': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `DesignerState`, `initialState`, `newState`, which the example never declares', + }, + 'packages/plugin-form/src/FormSection.tsx:109 FormSectionContainer': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `FormField`, which the example never declares', + }, + 'packages/plugin-form/src/ObjectForm.tsx:122 ObjectForm': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `dataSource`, which the example never declares', + }, + 'packages/plugin-form/src/TabbedForm.tsx:220 TabbedForm': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `dataSource`, which the example never declares', + }, + 'packages/plugin-form/src/WizardForm.tsx:361 WizardForm': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `dataSource`, which the example never declares', + }, + 'packages/plugin-grid/src/VirtualGrid.tsx:49 VirtualGrid': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `items`, which the example never declares', + }, + 'packages/plugin-list/src/ListView.tsx:808 ListViewHandle': { + card: null, + codes: [2304, 2686], + reason: + 'names the `React` UMD global, which a module-shaped block may not reach without an import', + }, + 'packages/plugin-report/src/LiveReportExporter.ts:150 exportExcelWithFormulas': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `data`, `report`, which the example never declares', + }, + 'packages/plugin-report/src/LiveReportExporter.ts:234 createScheduleTrigger': { + card: null, + codes: [1109], + reason: + 'the block is a prose-and-code mixture that does not parse as TSX in isolation', + }, + 'packages/plugin-report/src/LiveReportExporter.ts:88 exportWithLiveData': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `myAdapter`, `report`, which the example never declares', + }, + 'packages/plugin-view/src/ObjectView.tsx:591 ObjectView': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `dataSource`, which the example never declares', + }, + 'packages/plugin-view/src/ObjectView.tsx:605 ObjectView': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `dataSource`, which the example never declares', + }, + 'packages/plugin-view/src/ObjectView.tsx:622 ObjectView': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `dataSource`, which the example never declares', + }, + 'packages/react/src/context/ActionContext.tsx:72 ActionProvider': { + card: null, + codes: [2304, 18004], + reason: + 'shorthand `{ user }` stands for context the caller supplies; the example never declares it', + }, + 'packages/react/src/context/DndContext.tsx:128 DndProvider': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `KanbanBoard`, `handleDrop`, which the example never declares', + }, + 'packages/react/src/context/NotificationContext.tsx:377 NotificationProvider': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `App`, `NotificationAlerts`, `NotificationBanners`, `NotificationSnackbar`, `toast`, which the example never declares', + }, + 'packages/react/src/context/ThemeContext.tsx:120 ThemeProvider': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `App`, `myTheme`, which the example never declares', + }, + 'packages/react/src/element-data-source/ElementDataSourceGate.tsx:182 useElementDataSourceSchema': { + card: null, + codes: [1108, 2304], + reason: + 'usage fragment: references `schema`, which the example never declares, so what depends on it is judged unbound', + }, + 'packages/react/src/hooks/useActionRunner.ts:42 useActionRunner': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `formData`, `toast`, which the example never declares', + }, + 'packages/react/src/hooks/useClientNotifications.ts:103 useClientNotifications': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `Button`, which the example never declares', + }, + 'packages/react/src/hooks/useCrudShortcuts.ts:37 useCrudShortcuts': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `closeDialog`, `deleteSelected`, `openCreateDialog`, `saveRecord`, which the example never declares', + }, + 'packages/react/src/hooks/useDataRefresh.ts:24 useDataRefresh': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `dataSource`, `objectName`, `params`, `schema`, `setData`, `useEffect`, which the example never declares', + }, + 'packages/react/src/hooks/useDebugMode.ts:34 useDebugMode': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `DebugPanel`, which the example never declares', + }, + 'packages/react/src/hooks/useDensityMode.ts:77 useDensityMode': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `activeView`, `dataSource`, `obj`, `vid`, which the example never declares', + }, + 'packages/react/src/hooks/useDiscovery.ts:88 useDiscovery': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `AuthProvider`, `LoadingScreen`, which the example never declares', + }, + 'packages/react/src/hooks/useDynamicApp.ts:58 useDynamicApp': { + card: null, + codes: [2304, 2307, 2693], + reason: + 'imports \'../config/app.json\', a sibling file the reader\'s own project supplies, and names `Console` as a value', + }, + 'packages/react/src/hooks/useElementDataSource.ts:125 useElementDataSource': { + card: null, + codes: [1108, 2304], + reason: + 'usage fragment: references `adapter`, `schema`, which the example never declares, so what depends on them is judged unbound', + }, + 'packages/react/src/hooks/useETagCache.ts:174 useETagCache': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `User`, `setUser`, `useEffect`, which the example never declares', + }, + 'packages/react/src/hooks/useExpression.ts:163 useExpression': { + card: null, + codes: [18004], + reason: + 'shorthand `{ data, user }` stands for the scope the caller supplies; the example never declares it', + }, + 'packages/react/src/hooks/useKeyboardShortcuts.ts:34 useKeyboardShortcuts': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `closeModal`, `createNew`, `openSearch`, which the example never declares', + }, + 'packages/react/src/hooks/useNavigationOverlay.ts:211 useNavigationOverlay': { + card: null, + codes: [1003, 1382], + reason: + 'the block is a prose-and-code mixture that does not parse as TSX in isolation', + }, + 'packages/react/src/hooks/useOffline.ts:239 useOffline': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `Banner`, which the example never declares', + }, + 'packages/react/src/hooks/usePageVariables.tsx:249 usePageVariableBinding': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `record`, `schema`, which the example never declares', + }, + 'packages/react/src/hooks/usePageVariables.tsx:98 PageVariablesProvider': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `MyComponents`, which the example never declares', + }, + 'packages/react/src/hooks/usePerformance.ts:139 usePerformance': { + card: null, + codes: [2304, 2345], + reason: + 'usage fragment: references `NormalList`, `VirtualList`, which the example never declares, so what depends on them is judged unbound', + }, + 'packages/react/src/hooks/usePerformanceBudget.ts:131 usePerformanceBudget': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `Dashboard`, `analytics`, which the example never declares', + }, + 'packages/react/src/hooks/useSchemaPersistence.ts:212 useSchemaPersistence': { + card: null, + codes: [2304, 2451, 7006], + reason: + 'usage fragment: references `SchemaPersistenceAdapter`, `pageSchema`, which the example never declares, so what depends on them is judged unbound', + }, + 'packages/react/src/hooks/useSettledSchema.ts:112 useSettledSchema': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `dataConfig`, `resolveRecordSourceObjectName`, `schema`, which the example never declares', + }, + 'packages/react/src/hooks/useViewData.ts:72 useViewData': { + card: null, + codes: [2304, 7031], + reason: + 'usage fragment: references `ErrorMessage`, `Spinner`, `Table`, which the example never declares, so what depends on them is judged unbound', + }, + 'packages/react/src/hooks/useViewSharing.ts:53 useViewSharing': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `currentFilters`, `currentSort`, `initialViews`, which the example never declares', + }, + 'packages/types/src/data.ts:263 GlobalSearchHit': { + card: null, + codes: [2304, 7006], + reason: + 'usage fragment: references `DataSource`, `User`, `buildQuery`, which the example never declares, so what depends on them is judged unbound', + }, + 'packages/types/src/data.ts:740 DataSource': { + card: null, + codes: [2304, 7006], + reason: + 'usage fragment: references `dataSource`, `refreshList`, which the example never declares, so what depends on them is judged unbound', + }, + 'packages/types/src/icon-key-migration.ts:122 migrateIconNodeKeys': { + card: null, + codes: [2304], + reason: + 'usage fragment: references `save`, `storedPage`, which the example never declares', + }, + 'packages/types/src/objectql.ts:1572 ObjectFormSchema': { + card: null, + codes: [1005, 1109], + reason: + 'the block is a prose-and-code mixture that does not parse as TSX in isolation', + }, + 'packages/types/src/plugin-scope.ts:227 AppMetadataPlugin': { + card: null, + codes: [1128], + reason: + 'the block is a prose-and-code mixture that does not parse as TSX in isolation', + }, +}; + +// ── The run ────────────────────────────────────────────────────────────────── + +/** + * Which documented symbols the built package entries really export. + * + * Probed, never assumed: one throwaway module per `PACKAGE SYMBOL` pair, handed + * to the same `compileSnippets()` the blocks go through, so the answer comes + * from the same resolution the verdict does. A pair that cannot be imported is + * reported by name and its block is judged WITHOUT the injected import, so the + * gate never blames an example for this file's own transformation. + * + * @param {{ root: string, blocks: {package: string, symbol: string}[], paths: object, declaredSpecifiers: string[] }} options + * @returns {Set} the `PACKAGE SYMBOL` pairs that are NOT on a public entry + */ +export function probeExportedSymbols({ root, blocks, paths, declaredSpecifiers }) { + const pairs = [...new Set(blocks.map((b) => `${b.package} ${b.symbol}`))].sort(); + const probes = pairs.map((pair, index) => { + const [pkg, symbol] = pair.split(' '); + return { + doc: `probe/${pkg}`, + fenceLine: index, + language: 'ts', + quoteDepth: 0, + fragmentReason: null, + // `[typeof S]` rather than `typeof S`: a tuple accepts a value position for + // a name that is only a type, so this probe answers "is it importable" + // without also asking "is it a value", which is a different question. + body: `import { ${symbol} } from '${pkg}';\nexport type P = [typeof ${symbol}];\n`, + pair, + }; + }); + if (probes.length === 0) return new Set(); + const run = compileSnippets({ root, compiled: probes, paths, declaredSpecifiers }); + const missing = new Set(); + for (const { block, diagnostics } of run.semanticFailures) { + if (diagnostics.some((d) => d.code === 2305 || d.code === 2307)) missing.add(block.pair); + } + return missing; +} + +/** + * The ONE transformation, applied per block. See the header. + * + * @param {{ symbol: string, package: string, body: string }} block + * @param {Set} missing pairs the export probe could not import + */ +export function preludeFor(block, missing) { + const references = new RegExp(`\\b${block.symbol}\\b`).test(block.body); + const alreadyImported = new RegExp(`import[^;]*\\b${block.symbol}\\b[^;]*from`).test(block.body); + const onPublicEntry = !missing.has(`${block.package} ${block.symbol}`); + return references && !alreadyImported && onPublicEntry + ? `import { ${block.symbol} } from '${block.package}';\n` + : ''; +} + +/** `path:line symbol` — the ledger key for one block. */ +export function ledgerKey(block) { + return `${block.file}:${block.line} ${block.symbol}`; +} + +/** + * Compare a block's actual diagnostic codes against the row that declares it. + * Sorted, de-duplicated and compared as a SET: a compiler that reports the same + * failure twice has not changed the failure. + * + * @param {number[]} actual + * @param {number[]} declared + */ +export function codesMatch(actual, declared) { + const a = [...new Set(actual)].sort((x, y) => x - y); + const d = [...new Set(declared)].sort((x, y) => x - y); + return a.length === d.length && a.every((code, i) => code === d[i]); +} + +/** + * The verdicts, from a completed run. Pure: it prints nothing and it exits + * nothing, so the test can hand it a fixture ledger and read the same answers + * `main` reads. + * + * @param {{ results: {key: string, codes: number[]}[], ledger: Record }} input + */ +export function judge({ results, ledger }) { + const findings = []; + const seen = new Set(results.map((r) => r.key)); + const exempt = []; + for (const result of results) { + const row = ledger[result.key]; + const failed = result.codes.length > 0; + if (!failed) { + if (row) { + findings.push({ + reason: 'stale-ledger-row', + site: result.key, + detail: + 'this example COMPILES now — the debt was paid and the row outlived it. Delete the row.', + }); + } + continue; + } + if (!row) { + findings.push({ reason: 'undeclared-failure', site: result.key, codes: result.codes }); + continue; + } + if (!codesMatch(result.codes, row.codes)) { + findings.push({ + reason: 'ledger-row-drifted', + site: result.key, + detail: + `declared TS${[...new Set(row.codes)].sort((a, b) => a - b).join(', TS')} but produces ` + + `TS${[...new Set(result.codes)].sort((a, b) => a - b).join(', TS')} — the failure changed ` + + 'underneath the declaration. Re-derive the row.', + }); + continue; + } + exempt.push(result.key); + } + for (const [key, row] of Object.entries(ledger)) { + if (!seen.has(key)) { + findings.push({ + reason: 'stale-ledger-row', + site: key, + detail: 'no such example in the scan set', + }); + continue; + } + if (!row.reason || row.reason.trim().length < MIN_REASON_LENGTH) { + findings.push({ + reason: 'unexplained-ledger-row', + site: key, + detail: 'an entry with no written reason is not a declaration', + }); + } + } + return { findings, exempt }; +} + +// ── Reporting ──────────────────────────────────────────────────────────────── + +/** + * The funnel. Printed every run so the enforced number stays derived. + * + * @param {ReturnType} census + */ +export function funnelLines(census) { + const row = (n, label) => ` ${String(n).padStart(5)} ${label}`; + return [ + 'Population funnel (every narrowing step, so the enforced number is derived):', + row(census.files.length, `source files under ${PACKAGES_DIR}/NAME/${SOURCE_SUBDIR} (.ts/.tsx)`), + row(census.excludedAsTooling.length, ' ... tooling files excluded (tests, mocks, benchmarks, stories)'), + row(census.tags.length, '`@example` tags found by the AST'), + row(census.tags.length - census.inSources.length, ' ... in a tooling file'), + row( + census.inSources.length - census.exported.length, + ' ... on a declaration that is not exported (or has no name)', + ), + row(census.exported.length, '`@example` tags on EXPORTED declarations'), + row(census.withTsFence.length, ' ... carrying a ts/tsx/typescript fence'), + row(census.otherFenceOnly.length, ' ... carrying only a non-ts fence'), + row(census.bare.length, ' ... BARE, no fence — counted, never compiled (see the header)'), + row(census.blocks.length, 'BLOCKS in the compiled tier'), + ]; +} + +function formatDiagnostic(diagnostic, block) { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '); + if (diagnostic.file && typeof diagnostic.start === 'number') { + const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); + return `${block.doc}:${block.fenceLine + line}:${character + 1} TS${diagnostic.code}: ${message}`; + } + return `${block.doc}:${block.fenceLine} TS${diagnostic.code}: ${message}`; +} + +// ── main ───────────────────────────────────────────────────────────────────── + +export const EXIT_CODES = { + /** Every covered example compiles or fails exactly as its row declares. */ + verified: 0, + /** The gate RAN. An example or the ledger is at fault. */ + examplesFailed: 1, + /** The gate COULD NOT RUN. Nothing printed is a verdict about any example. */ + couldNotRun: 2, +}; + +function main() { + const census = exampleCensus({ root: repoRoot }); + + if (census.files.length === 0) { + console.error( + `The walk collected 0 file(s) under ${PACKAGES_DIR}/NAME/${SOURCE_SUBDIR}, so every count below ` + + 'would be a zero that means nothing. A walk that collapsed is a verdict about this instrument, ' + + 'never about the corpus.', + ); + return EXIT_CODES.couldNotRun; + } + if (census.blocks.length === 0) { + console.error( + 'The compiled tier is EMPTY: the walk found files but no ts/tsx fenced `@example` on any exported ' + + 'declaration. A gate that judges nothing must not report a pass.', + ); + return EXIT_CODES.couldNotRun; + } + + // The sibling's `analyze()` derives the built-`.d.ts` map and the declared + // dependency reach for the DOCUMENTATION corpus. Reused wholesale: an + // `@example` block imports the same workspace packages a documentation fence + // does, and a gate that re-derived the map would be a second answer to a + // question that already has one. + const state = analyze({ root: repoRoot }); + const blocking = state.findings.filter( + (f) => f.reason === 'unbuilt-package' || f.reason === 'source-typed-package', + ); + if (blocking.length > 0) { + console.error('THE GATE COULD NOT RUN — the packages these examples import are not built:'); + for (const f of blocking) console.error(` ${f.site}: ${f.detail}`); + console.error( + '\n pnpm exec turbo run build $(node scripts/check-doc-snippet-types.mjs --build-filter) --concurrency=2\n' + + ' pnpm check:doc-examples', + ); + return EXIT_CODES.couldNotRun; + } + + const missing = probeExportedSymbols({ + root: repoRoot, + blocks: census.blocks, + paths: state.paths, + declaredSpecifiers: state.declaredSpecifiers, + }); + + const compiled = census.blocks.map((block) => { + const prelude = preludeFor(block, missing); + return { + doc: block.file, + // `formatDiagnostic` prints `fenceLine + line`. The prelude shifts the + // body down by its own line count, so the anchor is pulled back by the + // same amount and a printed number still points at the real source line. + fenceLine: block.line - (prelude === '' ? 0 : prelude.split('\n').length - 1), + language: block.language, + quoteDepth: 0, + fragmentReason: null, + body: prelude + block.body, + key: ledgerKey(block), + injected: prelude !== '', + }; + }); + + const run = compileSnippets({ + root: repoRoot, + compiled, + paths: state.paths, + declaredSpecifiers: state.declaredSpecifiers, + }); + + // ── the harness's own controls, the sibling's, unmodified ───────────────── + // A program that resolves everything to `any` reports a clean corpus forever, + // and a clean corpus is exactly what this card must not manufacture. + const controls = []; + if (!run.resolvedFileName || !/[\\/]dist[\\/].*\.d\.ts$/.test(run.resolvedFileName)) { + controls.push( + `resolution did not land on a built artifact (${run.resolvedFileName ?? 'unresolved'})`, + ); + } + if (run.srcLeaks.length > 0) { + controls.push( + `${run.srcLeaks.length} file(s) under a package's src/ entered the program, e.g. ${run.srcLeaks[0]}`, + ); + } + if (!run.sentinelDiagnostics.map((d) => d.code).includes(2305)) { + controls.push("the planted sentinel produced no TS2305 — the program is resolving everything to 'any'"); + } + if (run.positiveDiagnostics.length > 0) { + controls.push( + `the positive control failed (${ts.flattenDiagnosticMessageText(run.positiveDiagnostics[0].messageText, ' ')})`, + ); + } + if (controls.length > 0) { + console.error('HARNESS CONTROL FAILED — no verdict below is a fact about any example:'); + for (const c of controls) console.error(` - ${c}`); + return EXIT_CODES.couldNotRun; + } + + // ── collect one result per block ────────────────────────────────────────── + const codesOf = new Map(); + const detailOf = new Map(); + for (const { block, diagnostics } of run.parseFailures) { + codesOf.set(block.key, diagnostics.map((d) => d.code)); + detailOf.set(block.key, diagnostics.map((d) => `[syntax] ${formatDiagnostic(d, block)}`)); + } + for (const { block, diagnostics } of run.semanticFailures) { + codesOf.set(block.key, diagnostics.map((d) => d.code)); + detailOf.set(block.key, diagnostics.map((d) => `[semantic] ${formatDiagnostic(d, block)}`)); + } + for (const { block, specifiers } of run.boundFailures) { + codesOf.set(block.key, [0]); + detailOf.set(block.key, [ + `[bound] ${block.doc}:${block.fenceLine} imports ${specifiers.map((s) => `'${s}'`).join(', ')}, ` + + "which resolve only through this repository's ROOT package.json", + ]); + } + const results = compiled.map((block) => ({ key: block.key, codes: codesOf.get(block.key) ?? [] })); + + const { findings, exempt } = judge({ results, ledger: UNGATED_EXAMPLES }); + + // ── report ─────────────────────────────────────────────────────────────── + for (const line of funnelLines(census)) console.log(line); + console.log(''); + console.log('Controls:'); + console.log(` resolution ${run.resolvedFileName}`); + console.log( + ` sentinel importing a name no package exports produced ${run.sentinelDiagnostics.length} diagnostic(s) (TS2305)`, + ); + console.log(` positive importing a real export produced ${run.positiveDiagnostics.length} diagnostic(s)`); + console.log(` src leaks ${run.srcLeaks.length}`); + console.log( + ` injection ${compiled.filter((b) => b.injected).length} of ${compiled.length} block(s) received the documented symbol's import; ` + + `${missing.size} documented symbol(s) are NOT on a public entry`, + ); + for (const pair of [...missing].sort()) console.log(` not on a public entry: ${pair}`); + console.log(''); + + const clean = results.length - codesOf.size; + console.log( + `Examples: ${results.length} block(s) — ${clean} compile, ${codesOf.size} fail, ` + + `${exempt.length} of those declared in the ledger (${Object.keys(UNGATED_EXAMPLES).length} row(s)).`, + ); + + if (findings.length === 0) { + console.log('\nEvery covered `@example` compiles, or fails exactly as its ledger row declares.'); + return EXIT_CODES.verified; + } + + console.error(''); + for (const finding of findings) { + if (finding.reason === 'undeclared-failure') { + console.error(`UNDECLARED FAILURE ${finding.site}`); + for (const line of detailOf.get(finding.site) ?? []) console.error(` ${line}`); + console.error( + ` Fix the example, or declare it: add a row keyed \`${finding.site}\` carrying ` + + `codes [${[...new Set(finding.codes)].sort((a, b) => a - b).join(', ')}], a written reason and the card that owns it.`, + ); + } else { + console.error(`${finding.reason.toUpperCase().replace(/-/g, ' ')} ${finding.site}`); + console.error(` ${finding.detail}`); + } + } + console.error( + '\n`@example` blocks on exported symbols must compile against the built types. See the header of this script.', + ); + return EXIT_CODES.examplesFailed; +} + +if (isEntrypoint(import.meta.url)) { + process.exit(main()); +} + +export { main };