From 274ceb35d48c043bc5ab3eb0203f8e5b63fa65ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 12:00:05 +0000 Subject: [PATCH 1/2] tooling: compile JSDoc `@example` blocks against the built types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `scripts/check-doc-example-types.mjs`, a SIBLING of the Markdown doc-snippet gate: it extracts ```ts / ```tsx fenced blocks from JSDoc `@example` tags on exported declarations under `packages/NAME/src/**` and compiles them through the existing harness — same compiler host, same built `dist/*.d.ts` resolution, same root bound, same controls. The strictness region of the sibling is untouched. Measured first, enforced second, as the card asks. The funnel is printed on every run so the enforced number stays derived: 228 `@example` tags, 213 on exported declarations, 124 carrying a ts/tsx fence. Of those 124, 34 compile and 90 do not; the 90 are declared in a shrink-only ledger, each row carrying the diagnostic codes it produces, a written reason and the card that owns it. The ledger reddens in three directions, not one: an undeclared failure, a row whose block now compiles (stale — the debt was paid), and a row whose block now fails differently (the failure changed underneath the declaration). One transformation, stated in the header: the documented symbol's import is prepended, because a reader of a JSDoc `@example` has that symbol in scope by construction. Without it 303 of 348 diagnostics were TS2304 naming the documented symbol itself, and objectui#7974's real TS2322 was masked. The `declare var NAME: any` route that would shrink the ledger to 32 rows was priced and refused: it invents 8 diagnostics and launders the rest through `any`. The template-literal half of the card is answered by measurement rather than by a second extractor: `check-doc-snippet-types.mjs --emit-census` already walks the same tree and compiles the same templates. Part of #8258 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr --- package.json | 1 + .../__tests__/check-doc-example-types.test.ts | 390 +++++ scripts/check-doc-example-types.mjs | 1341 +++++++++++++++++ 3 files changed, 1732 insertions(+) create mode 100644 scripts/__tests__/check-doc-example-types.test.ts create mode 100644 scripts/check-doc-example-types.mjs 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..4854dcf9c --- /dev/null +++ b/scripts/check-doc-example-types.mjs @@ -0,0 +1,1341 @@ +#!/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. + * + * 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 }; From 66b04b782c1de5bb25a9c4408e6ad70de27e80ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 12:08:25 +0000 Subject: [PATCH 2/2] docs(tooling): state that the export probe asks the ROOT specifier only The gate prints the documented symbols it did NOT inject an import for. Two of the three on this corpus are published under `@object-ui/types/zod`, a SUBPATH the probe deliberately does not guess, and both blocks compile anyway. Without this note the printed list reads as three defects when it is one. Part of #8258 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr --- scripts/check-doc-example-types.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index 4854dcf9c..c71311fdf 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -104,6 +104,14 @@ * 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