From 93124e4d564471fb5126597afb988b5c0b2ea704 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 01:33:05 +0000 Subject: [PATCH] feat(tooling): read plugin key tables in the doc component-type gate, and judge the namespace half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/check-doc-component-types.mjs` read fenced code only. The objectui#5002 family (eight PRs) replaced the plugin pages' fictional registration loops with a markdown key table — the right form, landing entirely outside the scan surface, while the code blocks it replaced had been inside it. A fake key in a table was green; the same key in a fence was red. It also never judged a namespace. It compared bare keys against a universe that merely happens to contain namespaced ones, so flipping a registration's `namespace` left every doc teaching the old one green. Both halves of every key-table row are now judged. The anchor is the table HEADER, not the row shape: the row heuristic was measured over this tree and matched 33 rows of which only 22 were keys — the rest are route patterns, URLs, HTTP routes and JSON literals, every one a false red on correct docs. Also fixes a dead floor: `FLOORS.docFiles` named a counter that never existed (`scanDocs` publishes `files`), so it compared `undefined` and the check meant to catch the walk finding nothing was inert. The class is closed too — a floor naming no counter now fails loudly. Part of #5106 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe --- .changeset/5106-doc-key-table-scan-surface.md | 11 + .github/workflows/doc-component-types.yml | 10 +- .../check-doc-component-types.test.ts | 225 ++++++++++++++++ scripts/check-doc-component-types.mjs | 252 +++++++++++++++++- 4 files changed, 488 insertions(+), 10 deletions(-) create mode 100644 .changeset/5106-doc-key-table-scan-surface.md diff --git a/.changeset/5106-doc-key-table-scan-surface.md b/.changeset/5106-doc-key-table-scan-surface.md new file mode 100644 index 0000000000..6dc86a21d0 --- /dev/null +++ b/.changeset/5106-doc-key-table-scan-surface.md @@ -0,0 +1,11 @@ +--- +--- + +Tooling only, no published surface. `scripts/check-doc-component-types.mjs` now +reads the plugin KEY TABLES in `content/docs/**` — the canonical +`| Namespaced key | Bare-name fallback | Renderer behind it |` form the +objectui#5002 family standardised on — and judges both halves of every row, +including the namespaced one the gate never judged at all. Also fixes a floor +that named a counter which never existed (`docFiles`), so the check that catches +the docs walk finding nothing had been inert, and adds a guard that makes the +same mis-key fail loudly instead of silently passing. diff --git a/.github/workflows/doc-component-types.yml b/.github/workflows/doc-component-types.yml index fd357f17be..d6414e906b 100644 --- a/.github/workflows/doc-component-types.yml +++ b/.github/workflows/doc-component-types.yml @@ -24,10 +24,12 @@ name: Doc Component Types # gate, one home. # # It needs no install and no build. The script reads the checkout with `node:fs` -# only: 183 pages for the snippets (143 `.mdx` + 40 `.md`, objectui#5342), and -# the `packages/` + `apps/` sources for the registered-key universe it compares -# them against. A few seconds. Keep it that way if you add checks to it — the moment this needs `pnpm install` it -# stops being cheap enough to run unfiltered, and the filter is the hole. +# only: 184 pages (144 `.mdx` + 40 `.md`, objectui#5342) for the fenced snippets +# AND for the plugin key tables (objectui#5106), and the `packages/` + `apps/` +# sources for the registered-key universe it compares them against. A few +# seconds. Keep it that way if you add checks to it — the moment this needs +# `pnpm install` it stops being cheap enough to run unfiltered, and the filter is +# the hole. on: pull_request: diff --git a/scripts/__tests__/check-doc-component-types.test.ts b/scripts/__tests__/check-doc-component-types.test.ts index 4395a613b4..718d0334bb 100644 --- a/scripts/__tests__/check-doc-component-types.test.ts +++ b/scripts/__tests__/check-doc-component-types.test.ts @@ -412,6 +412,231 @@ describe('the scan cannot collapse quietly', () => { }); }); +// ── objectui#5106: the key-table surface ───────────────────────────────────── + +/** + * objectui#5106 — the gate's second scan surface. + * + * Two measured facts on the card, both reproduced below as fixtures: + * + * 1. The objectui#5002 family replaced eight plugin pages' fictional + * registration loops with a markdown KEY TABLE. The new form is right, and + * it landed entirely outside a scan surface that reads fenced code only — so + * a fake key in a table was GREEN while the same fake key in a fence was RED. + * 2. The gate never judged a NAMESPACE at all. It compared bare keys against a + * universe that merely happens to contain namespaced ones, so flipping a + * registration's `namespace` left every doc that teaches the old namespace + * green. + * + * The false-positive corpus in `does not read a table that is not a key table` + * is the reason the anchor is the table HEADER rather than the row shape, and it + * is taken from this repository rather than invented — see the gate's header for + * the measurement (33 rows matched by the row heuristic, only 22 of them keys). + */ +describe('objectui#5106 — plugin key tables are judged, on both halves', () => { + /** A tree with one registration and one key table over it. */ + const tableTree = (rows: string[], registration: string) => + withTree((write) => { + write('packages/demo/src/index.tsx', registration); + write( + 'content/docs/plugins/demo.mdx', + ['# Demo', '', '| Namespaced key | Bare-name fallback | Renderer behind it |', '| --- | --- | --- |', ...rows, ''].join( + '\n', + ), + ); + }, (dir) => analyze(dir, BARE)); + + const REG = "ComponentRegistry.register('widget', W, { namespace: 'view' });\n"; + + it('passes a row whose halves both name registered keys', () => { + const { findings, counters } = tableTree(['| `view:widget` | `widget` | `W` |'], REG); + expect(findings as Finding[]).toEqual([]); + expect(counters.keyTables).toBe(1); + expect(counters.keyTableRows).toBe(1); + expect(counters.keyTableKeys).toBe(2); + }); + + it('reds a table row that names a key nothing registers — the surface that was green', () => { + // The card's own reproduction: a fake key in the TABLE, which produced + // `rc=0, Every documented component type is registered.` before this landed. + const findings = tableTree( + ['| `view:widget` | `widget` | `W` |', '| `view:phantom-widget` | `phantom-widget` | nothing registers this |'], + REG, + ).findings as Finding[]; + expect(findings.map((f) => `${f.reason} :: ${f.value}`)).toEqual([ + 'unregistered-key-table-key :: view:phantom-widget', + 'unregistered-key-table-key :: phantom-widget', + ]); + }); + + it('⭐ judges the NAMESPACED half — a namespace move reds the doc that teaches the old one', () => { + // The half objectui#5106 was filed for. Same table, same bare name, only the + // registration's `namespace` differs: `deriveRegistryKeys` follows it live, + // so the row's namespaced cell is the ONLY thing that can notice. + const rows = ['| `view:widget` | `widget` | `W` |']; + expect(tableTree(rows, REG).findings as Finding[]).toEqual([]); + + const moved = tableTree(rows, "ComponentRegistry.register('widget', W, { namespace: 'dash' });\n"); + const findings = moved.findings as Finding[]; + expect( + findings.map((f) => `${f.reason} :: ${f.value}`), + 'the bare half still matches, so a gate that judges only bare keys stays green here', + ).toEqual(['unregistered-key-table-key :: view:widget']); + }); + + it('accepts the declared "none — `skipFallback: true`" fallback, and does not assert the negative', () => { + // The universe is a UNION across the repo, so a call skipping its own bare + // fallback says nothing about whether another package registers that bare + // name — and in this tree one does. Asserting the negative would red a + // correct row (`plugins/plugin-grid.mdx:185` is the live specimen). + const { findings, counters } = tableTree( + ['| `view:widget` | none — `skipFallback: true` | `W` |'], + "ComponentRegistry.register('widget', W, { namespace: 'view', skipFallback: true });\n" + + "OtherRegistry.register('widget', Other);\n", + ); + expect(findings as Finding[]).toEqual([]); + expect(counters.keyTableRows).toBe(1); + expect(counters.keyTableKeys, 'only the namespaced half is a judgeable key here').toBe(1); + }); + + it('does not read a table that is not a key table — the false-positive corpus', () => { + // Every row here matches the rejected row heuristic ("backticked cell with a + // colon, then a backticked cell") and none of them is a component key. They + // are the real shapes this repository writes: React route patterns, URLs, + // HTTP routes and JSON literals. A gate that reds on these is worse than no + // gate, because false RED on correct docs is the expensive direction. + const findings = withTree((write) => { + write('packages/demo/src/index.tsx', REG); + write( + 'content/docs/guide/routes.md', + [ + '| Route Pattern | Component | Purpose |', + '| --- | --- | --- |', + '| `/apps/:appName/:objectName` | `ObjectView` | Object list |', + '| `http://localhost:5173/` | `LocalBundleLoader` | bundled JSON |', + '| `GET /api/v1/meta/items/:type` | `effective._diagnostics` | per item |', + '| `{ "type": "object", "objectName": "project" }` | `/apps/my_app/project` | default view |', + '', + ].join('\n'), + ); + }, (dir) => analyze(dir, BARE).findings as Finding[]); + expect(findings).toEqual([]); + }); + + it('does not read a key table drawn INSIDE a code fence', () => { + // A table inside a fence is an example OF a table, not a claim about this + // repository — the mirror of the rule that a fenced `type` IS a claim. + const { findings, counters } = withTree((write) => { + write('packages/demo/src/index.tsx', REG); + write( + 'content/docs/plugins/demo.mdx', + [ + '# Demo', + '', + '```markdown', + '| Namespaced key | Bare-name fallback | Renderer behind it |', + '| --- | --- | --- |', + '| `view:phantom-widget` | `phantom-widget` | nothing registers this |', + '```', + '', + ].join('\n'), + ); + }, (dir) => analyze(dir, BARE)); + expect(findings as Finding[]).toEqual([]); + expect(counters.keyTables).toBe(0); + }); + + it('reports a row it cannot read rather than skipping it', () => { + // A row this gate cannot parse is a row it silently stops guarding, which is + // how a scan narrows itself into vacuity one page at a time. + const findings = tableTree(['| ObjectGrid | `widget` | prose, not a key |'], REG).findings as Finding[]; + expect(findings.map((f) => f.reason)).toEqual(['unreadable-key-table-row']); + }); + + it('does not let DOC_TYPE_EXEMPTIONS silence a table row', () => { + // Exemptions declare "this value belongs to another vocabulary". A row under + // a header that says "Namespaced key" has already declared its vocabulary, so + // an exemption there would be a lie rather than a fact — the escape hatch is + // deliberately absent. + const findings = withTree((write) => { + write('packages/demo/src/index.tsx', REG); + write( + 'content/docs/plugins/demo.mdx', + [ + '| Namespaced key | Bare-name fallback | Renderer behind it |', + '| --- | --- | --- |', + '| `view:phantom-widget` | `phantom-widget` | nothing registers this |', + '', + ].join('\n'), + ); + }, (dir) => + analyze(dir, { + ...BARE, + exemptions: { 'content/docs/plugins/demo.mdx': { 'view:phantom-widget': 'a written reason', 'phantom-widget': 'ditto' } }, + }).findings as Finding[], + ); + expect(findings.map((f) => f.reason)).toEqual([ + 'unregistered-key-table-key', + 'unregistered-key-table-key', + // The exemptions went unhit, which is itself reported — an exemption that + // matches nothing widens the hole for the next snippet that lands there. + 'stale-exemption', + 'stale-exemption', + ]); + }); + + it('this repository has key tables, and every key in them is registered', () => { + // The repo-level half. The fixtures above prove the mechanism; this proves + // the mechanism is pointed at something. `content/docs` carries the + // objectui#5002 family's four plugin key tables. + const { counters, findings } = analyze(repoRoot); + expect(counters.keyTables, 'the family form vanished, or the header was renamed').toBeGreaterThanOrEqual(4); + expect(counters.keyTableRows).toBeGreaterThanOrEqual(24); + expect(counters.keyTableKeys).toBeGreaterThanOrEqual(45); + expect(counters.keyTableKeys).toBe(counters.keyTableRegistered); + expect((findings as Finding[]).filter((f) => f.reason.includes('key-table'))).toEqual([]); + }); + + it('really reads the four plugin pages, not just some table somewhere', () => { + const { tableRows } = scanDocs(repoRoot) as { tableRows: { file: string; namespaced: string }[] }; + expect([...new Set(tableRows.map((r) => r.file))].sort()).toEqual([ + 'content/docs/plugins/plugin-dashboard.mdx', + 'content/docs/plugins/plugin-form.mdx', + 'content/docs/plugins/plugin-grid.mdx', + 'content/docs/plugins/plugin-view.mdx', + ]); + expect(tableRows.map((r) => r.namespaced)).toContain('`view:dashboard`'); + }); +}); + +describe('objectui#5106 — a floor that names no counter is not a floor', () => { + // `FLOORS.docFiles` named a counter that never existed (`scanDocs` publishes + // `files`), so it compared `undefined`, which is never below anything. The one + // floor whose job is to catch the walk finding NOTHING was inert for its whole + // life. Fixed by spelling, and the CLASS closed by the guard this pins. + const script = fs.readFileSync(path.join(repoRoot, SCRIPT), 'utf8'); + + it('every FLOORS key names a counter `analyze` really publishes', () => { + const block = /const FLOORS = \{([\s\S]*?)\n\};/.exec(script); + expect(block, 'FLOORS moved or changed shape').not.toBeNull(); + const keys = [...block![1].matchAll(/^\s*([A-Za-z]\w*):\s*\d+,/gm)].map((m) => m[1]); + expect(keys.length, 'no floors parsed — the assertion below would be vacuous').toBeGreaterThan(4); + const counters = analyze(repoRoot).counters as Record; + for (const key of keys) { + expect(Object.hasOwn(counters, key), `FLOORS.${key} names no counter, so it can never fail`).toBe(true); + } + }); + + it('does not reintroduce the `docFiles` spelling', () => { + expect(script, 'the counter is `files`; `docFiles` compares undefined').not.toMatch(/\bdocFiles\b\s*:/); + }); + + it('the guard rejects a mis-keyed floor at runtime, not just in review', () => { + expect(script).toMatch(/Object\.hasOwn\(counters, key\)/); + expect(script).toContain('A floor over a missing counter compares'); + }); +}); + describe('the three snippets this gate found on its first run stay fixed', () => { // Named rather than left to the repo-wide green assertion: these are the live // specimens of objectui#4823's shape, and a revert would otherwise read as an diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index a1e7015f7f..56c6d8157a 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -2,7 +2,10 @@ /** * Every `type` string literal in a `content/docs/**` code block — `.mdx` and * `.md` alike — must name a component the repository actually registers, or be - * declared, per file, as belonging to some other vocabulary. + * declared, per file, as belonging to some other vocabulary. Since objectui#5106 + * the same question is also asked of the KEY TABLES that document a plugin's + * registrations, on BOTH halves of the row: the namespaced key and the bare-name + * fallback (see "The second surface" below). * * Run: node scripts/check-doc-component-types.mjs (also `pnpm check:doc-types`) * Exit: 0 = every teaching snippet names a registered type (or a declared @@ -132,6 +135,68 @@ * A site is reported with `file:line` so the author can go straight to it, and * the exemption is keyed without the line so ordinary editing above a snippet * does not invalidate the table. + * + * ## The second surface: plugin key tables (objectui#5106) + * + * The rule above reads FENCED CODE ONLY, on purpose — prose that mentions a type + * in backticks is not a snippet. That scope had a measured cost. The objectui#5002 + * family (PRs #5071 / #5078 / #5079 / #5085 / #5089 / #5093 / #5100 / #5104) + * replaced a fictional "manual `*Components` registration loop" on eight plugin + * pages with one canonical form — a markdown table of the keys the plugin's entry + * really registers. The new form is the right one, and it landed entirely OUTSIDE + * the scan surface, while the code blocks it replaced had been inside it. Net + * effect: the fact "which keys does this plugin claim" moved from a checked place + * to an unchecked one, guarded only by hand comparison. + * + * So key tables are now read, and the anchor is the TABLE HEADER, not the row: + * + * | Namespaced key | Bare-name fallback | Renderer behind it | + * + * Anchoring on the header rather than pattern-matching rows is the whole design, + * and it was chosen after measuring the alternative. The obvious row heuristic — + * "first cell is a backticked token containing a colon, second cell is a + * backticked token" — was run over this tree and matched 33 rows, of which only + * 22 were keys. The other 11 are a `:`-bearing vocabulary this repo writes in + * tables constantly: + * + * guide/console-architecture.md:104 `/apps/:appName/:objectName` | `ObjectView` + * utilities/runner.mdx:99 `http://localhost:5173/` | `LocalBundleLoader` + * guide/metadata-diagnostics.md:43 `GET /api/v1/meta/items/:type/:name?layered=true` + * guide/designing-app-navigation.md:21 `{ "type": "object", … }` + * + * React route patterns, URLs, HTTP routes and JSON literals — every one of them a + * false RED on correct documentation, which is the expensive direction for a gate + * whose whole job is to be trusted about docs. The header is a DECLARATION by the + * page that the rows beneath it are registry keys, so it discriminates perfectly + * where a row shape cannot, and it costs an author nothing they were not already + * writing. + * + * Both halves of the row are judged, and judging the namespaced half is the point + * objectui#5106 was filed for: this gate never judged a namespace at all. It + * compared bare keys against a universe that happens to contain namespaced keys + * too, so `view:dashboard` documented as `plugin-dashboard:dashboard` produced no + * signal from any static check — the bare `dashboard` matched and the row passed. + * Flip `namespace: 'view'` to `'dash'` in `plugin-dashboard/src/index.tsx` and + * `deriveRegistryKeys` follows it live to `dash:dashboard`, while every doc that + * teaches `view:dashboard` stays green. That is the hole; the namespaced cell + * closes it. + * + * What is deliberately NOT checked, and why: when the fallback cell reads + * "none — `skipFallback: true`", this gate does not assert that the bare name is + * absent from the universe. It cannot. The universe is a deliberate UNION across + * every package in the repo (see "generous" above), so `view:grid` skipping its + * own bare fallback says nothing about whether some other package registers a + * bare `grid` — and one does. Asserting the negative would red + * `plugins/plugin-grid.mdx:185`, which is correct. The positive half is checkable + * and is checked; the negative half needs per-host registration modelling this + * gate deliberately does not do. + * + * `DOC_TYPE_EXEMPTIONS` does not apply to table rows, and that is deliberate + * rather than an omission. An exemption declares "this value belongs to another + * vocabulary" — but a row under a header that says "Namespaced key" has already + * declared its vocabulary, and there is no other one it could be. A row that + * cannot be registered is a wrong row (or a header being borrowed for a table + * that is not a key table), and both are worth fixing rather than silencing. */ import { readFileSync, readdirSync, statSync } from 'node:fs'; @@ -166,6 +231,29 @@ const DOCS_ROOT = 'content/docs'; * sidecars) holds no prose and is not a page. */ const DOC_EXTENSIONS = ['.mdx', '.md']; +/** The header that marks a markdown table as a plugin KEY TABLE — the canonical + * form the objectui#5002 family standardised on, and the anchor this gate uses + * to read tables without reading prose. See "The second surface" in the header + * for the measurement that rejected row-shape matching in favour of this. + * + * Matched on the first two cells only: pages spell the third column + * "Renderer behind it", and pinning a description column would make the gate + * brittle about wording that carries no meaning for it. Anchored with `^` and a + * literal `|` so it cannot match the same words in prose. */ +const KEY_TABLE_HEADER = /^\s*\|\s*Namespaced key\s*\|\s*Bare-name fallback\s*\|/i; + +/** A markdown delimiter row (`| --- | --- |`), which is what makes the line above + * it a header rather than an ordinary row that happens to read like one. */ +const TABLE_DELIMITER = /^\s*\|[\s:|-]+\|\s*$/; + +/** A table cell holding exactly one backticked token, and nothing else. */ +const BACKTICKED_CELL = /^`([^`]+)`$/; + +/** The fallback cell's "this registration passes `skipFallback: true`" spelling. + * Recognised so the row is still JUDGED on its namespaced half rather than + * skipped — a row this gate cannot read is a row it silently stops guarding. */ +const NO_FALLBACK_CELL = /^none\b/i; + /** Where registrations live. Every workspace source root that can register. */ const SOURCE_ROOTS = ['packages', 'apps', 'examples']; @@ -503,10 +591,27 @@ const DOC_TYPE_EXEMPTIONS = { * verdict depends on has a size the tree is known to clear by a wide margin. */ const FLOORS = { - docFiles: 100, + // `files`, not `docFiles`: the counter `scanDocs` publishes is `files`, so the + // key used to name a counter that has never existed. `undefined < 100` is + // `false`, so this floor — the one that catches the walk finding NOTHING — + // silently passed an empty tree for its whole life. Found while adding the key + // table floors below (objectui#5106); the mis-key is now unspellable, because + // `analyze` fails on any FLOORS key that names no counter. + files: 100, codeBlocks: 400, typeSites: 300, registryKeys: 300, + // objectui#5106. Roughly half of what this tree holds today (4 tables, 24 rows, + // 45 judged keys), matching the margin the four floors above keep: a floor is a + // collapse detector, not a ratchet, and one set at today's exact count turns + // every legitimate docs edit red. What it must catch is the scan silently + // finding NOTHING — a renamed header, a broken walk, a regex that stopped + // matching — because zero rows compared against a universe passes while + // asserting nothing at all, which is the failure this whole surface exists to + // prevent one level up. + keyTables: 2, + keyTableRows: 12, + keyTableKeys: 20, }; // ── Source utilities ───────────────────────────────────────────────────────── @@ -847,12 +952,24 @@ export function deriveRegistryKeys(root, options = {}) { * Collect every `type: ''` / `"type": ""` site inside a fenced * code block. Fences are tracked so prose that merely mentions a type in * backticks is not read as a snippet. + * + * In the SAME walk, collect the rows of every plugin key table — the tables + * introduced by the objectui#5002 family and anchored by `KEY_TABLE_HEADER`. + * One walk rather than two because two collectors over one tree is the defect + * this file's `DOC_EXTENSIONS` note already warns about, one level down: they + * drift, and a page ends up covered by the surface it passes and invisible to + * the one it fails. + * + * Key tables are read OUTSIDE fences, which is the opposite of the snippet rule + * and correct for both: a table is prose-level markdown, and a table drawn + * inside a ``` block is an example OF a table, not a claim about this repo. */ export function scanDocs(root) { const docsDir = join(root, DOCS_ROOT); const files = walkFiles(docsDir, (f) => DOC_EXTENSIONS.some((ext) => f.endsWith(ext))).sort(); const sites = []; - const counters = { files: files.length, codeBlocks: 0, typeSites: 0 }; + const tableRows = []; + const counters = { files: files.length, codeBlocks: 0, typeSites: 0, keyTables: 0, keyTableRows: 0 }; for (const abs of files) { const rel = relative(root, abs).split(sep).join('/'); @@ -872,7 +989,32 @@ export function scanDocs(root) { } continue; } - if (!inFence) continue; + if (!inFence) { + if (KEY_TABLE_HEADER.test(lines[i]) && TABLE_DELIMITER.test(lines[i + 1] ?? '')) { + counters.keyTables++; + const header = i + 1; + // Consume the body until the table ends. A table ends at the first line + // that is not a row; markdown needs no terminator, so "not a row" is the + // only signal there is. + for (let j = i + 2; j < lines.length && /^\s*\|/.test(lines[j]); j++) { + const cells = lines[j] + .split('|') + .slice(1, -1) + .map((c) => c.trim()); + counters.keyTableRows++; + tableRows.push({ + file: rel, + line: j + 1, + header, + namespaced: cells[0] ?? '', + fallback: cells[1] ?? '', + text: lines[j].trim(), + }); + i = j; + } + } + continue; + } for (const m of lines[i].matchAll(/(?:"type"|'type'|(?