diff --git a/.changeset/doc-component-type-ratchet-4823.md b/.changeset/doc-component-type-ratchet-4823.md new file mode 100644 index 0000000000..65bd974184 --- /dev/null +++ b/.changeset/doc-component-type-ratchet-4823.md @@ -0,0 +1,32 @@ +--- +--- + +Tooling + docs-only (objectui#4823). Every `type` string literal in a `content/docs/**.mdx` +code block must now name a component the repository actually registers, enforced by a new CI +gate — `pnpm check:doc-types`, `scripts/check-doc-component-types.mjs`, in its own +`doc-component-types.yml` workflow. + +The catalog side has had this ratchet since objectui#4616: +`examples/schema-catalog/test/catalog-gallery-render.test.tsx` renders every catalog entry and +fails if any paints the registry's "Unknown component type" panel (OBJUI-001). The teaching +surface had no equivalent — a fenced snippet in `content/docs/**` is not rendered, not parsed +and not compared against anything — so a page could teach a `type` that does not exist and +every check in the repo stayed green. The same defect landed three times on that surface +(objectui#4786 `stats-card`, objectui#4796 `plugin:grid` and `plugin:map`), each found by a +human probe rather than by a check. + +The registered-key universe is derived from the register calls themselves on every run — no +hard-coded list and no build step, so the gate is a checkout plus one `node` call and can +therefore run unfiltered, which matters because the change that introduces this defect is +docs-only and `ci.yml`'s gates skip those by design. + +The first full scan read 558 `type` literals across 143 pages against 661 derived keys and +found three more instances of the same shape, fixed here: `content/docs/utilities/runner.mdx` +and `content/docs/utilities/vscode-extension.mdx` taught `heading`, which nothing registers +(now `h1`, which `html-elements.tsx` registers and which renders the node's `children`), and +`content/docs/plugins/plugin-form.mdx` taught a `multi-step-form` type that appears nowhere in +the repo outside that snippet (now the `object-form` + `formType: 'wizard'` + `sections` shape +that `WizardFormSchema` itself declares). + +No published behaviour changes — repo tooling plus three documentation snippets — so this +declares "no release" rather than a bump. diff --git a/.github/workflows/doc-component-types.yml b/.github/workflows/doc-component-types.yml new file mode 100644 index 0000000000..67f037387c --- /dev/null +++ b/.github/workflows/doc-component-types.yml @@ -0,0 +1,79 @@ +name: Doc Component Types + +# Why this is its own workflow instead of a step in `ci.yml` or `lint.yml`: the +# defect this gate exists for arrives in a DOCS-ONLY pull request, and that is +# precisely the shape both of those workflows skip. `ci.yml`'s `type-check` job +# decides whether to run its expensive steps with a `git diff` that excludes +# `content/**`, `'**/*.md'`, `docs/**` and `apps/site/**` — so a PR that edits +# only `content/docs/**.mdx` reports the context and runs none of the gates +# inside it. A gate against a wrong `type` in a teaching snippet, wired there, +# would be blind to every change that can introduce one. +# +# This is the fifth instance of the shape in this repo and the reasoning is +# borrowed, not invented: `docs-links.yml`'s header records the link check +# spending #3213 to #3448 inside `ci.yml`'s `docs` job, unable to see the one +# class of PR most likely to break a link; `control-bytes.yml`'s header names the +# consequence — a gate that cannot see a markdown-only change "rebuilds the hole +# it exists to close". `changeset-guard.yml` and `skills-paths.yml` are the third +# and fourth. +# +# Hence: no `paths` and no `paths-ignore` here, deliberately. +# `scripts/__tests__/check-doc-component-types.test.ts` fails if either is ever +# added, and fails too if a second workflow starts running the same script — one +# gate, one home. +# +# It needs no install and no build. The script reads the checkout with `node:fs` +# only: 143 mdx files for the snippets, 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: + branches: [main, develop] + push: + branches: [main, develop] + # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note + # and the measurements behind it). A required check that does not report on a + # queue build stalls the queue until the ruleset's 60-minute timeout fails it, + # so an unfiltered gate that could become required subscribes here from the + # start. `types:` is named although `checks_requested` is currently the only + # activity type GitHub defines for `merge_group`. + merge_group: + types: [checks_requested] + workflow_dispatch: + +concurrency: + group: doc-component-types-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + doc-component-types: + name: Doc Component Type Check + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + + # A `type` string in a `content/docs/**.mdx` code block is not rendered, + # not parsed and not compared against anything, so a snippet can name a + # component that does not exist and every check in the repo stays green — + # while a reader who copies it gets the renderer's red "Unknown component + # type" panel (OBJUI-001). That defect landed three times before this gate + # (objectui#4786 `stats-card`, objectui#4796 `plugin:grid` and + # `plugin:map`), each found by a human probe. The catalog side has had the + # equivalent ratchet since objectui#4616 + # (`examples/schema-catalog/test/catalog-gallery-render.test.tsx`); this is + # the missing half. Reads the checkout and nothing else, so no install. + - name: Check documented component types against the registry + run: node scripts/check-doc-component-types.mjs diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index f8c6f2ed1c..04f23b988c 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -30,6 +30,7 @@ one has its own section below. | `control-bytes.yml` | Control Byte Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** | | `docs-links.yml` | Internal Docs Link Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** | | `skills-paths.yml` | Skill Guide Path Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a path stated in a `skills/` guide does not exist | +| `doc-component-types.yml` | Doc Component Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `content/docs/**.mdx` snippet teaches a `type` nothing registers | | `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | | `live-e2e.yml` | Live E2E (informational) | PR to `main`, `develop` (code paths); nightly cron `30 6 * * *`; manual | No — informational lane, `continue-on-error` | | `labeler.yml` | Auto Label PRs | PR `opened`, `synchronize`, `reopened` | No | @@ -538,6 +539,57 @@ the sentence's whole point is that the path does not exist. Run it locally with `pnpm check:skills-paths`, or `node scripts/check-skills-paths.mjs --list` to see every candidate and how it was classified. +## Documented Component Types (`doc-component-types.yml`) + +**Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no +path filter at all**, and here the reason is sharper than in the three sections above. `ci.yml`'s +`type-check` job decides whether to run its gates with a `git diff` that *excludes* `content/**`, so +a pull request editing only `content/docs/**.mdx` reports that context and runs nothing inside it — +and a docs-only pull request is exactly the change that introduces the defect this gate exists for. +It appears in the checks list as **Doc Component Type Check**. + +Runs `scripts/check-doc-component-types.mjs`, which reads every fenced code block under +`content/docs/**` and asks, of each `type` string literal in one, whether the repository registers a +component under that name. + +**Why the teaching surface needed its own ratchet.** The catalog side has had one since +[#4616](https://github.com/objectstack-ai/objectui/issues/4616): +`examples/schema-catalog/test/catalog-gallery-render.test.tsx` renders every catalog entry and fails +if any paints the registry's `Unknown component type` panel (OBJUI-001). A snippet in the docs is +rendered by nothing, parsed by nothing and compared against nothing, so it could name any string at +all and every check stayed green — while a reader who copied it got the red panel. The same defect +landed three times that way, each found by a human probe: +[#4786](https://github.com/objectstack-ai/objectui/issues/4786) taught `stats-card`, and +[#4796](https://github.com/objectstack-ai/objectui/issues/4796) taught `plugin:grid` and +`plugin:map` (the registered names are `object-grid` and `object-map`). + +**Where the key list comes from.** Nowhere — it is derived from the `ComponentRegistry.register(…)` +and `registerLazy(…)` calls themselves on every run, including the loop forms and two helpers that +register from a collection, with `namespace` and `skipFallback` read out of each call's own balanced +argument span. There is no hard-coded enumeration to drift, and no build step, which is what keeps +the whole run to a checkout plus one `node` call. A registration whose key the derivation cannot +resolve **fails the gate** rather than being skipped: a key silently missing from the universe turns +*correct* documentation red, which is the failure mode that gets gates deleted. + +**How a snippet is judged.** `type` is not one vocabulary in these pages — measured across 143 files +and 558 literals, the corpus spells action schemas, block schemas, theme and report schemas, field +and JSON-Schema data types, validation rules and navigation items all under the same key. A +structural discriminator was built and rejected on measurement (a TypeScript annotation reads exactly +like an object key to a brace tracker, and `items` carries navigation entries on one page and +renderable children on another, so any global rule is a silent false green somewhere). So the rule is +flat: every literal is a candidate component key, and a value outside the derived universe must be +**declared** in the script's `DOC_TYPE_EXEMPTIONS` — keyed by (file, value), with a written reason +naming the vocabulary it really belongs to. A whole-file exemption is deliberately not offered: +`blocks/block-schema.mdx` carries `type: 'block'` and `type: 'div'` in the same document. + +Entries are re-derived per run, so one whose page stopped spelling that type fails as a stale +exemption rather than quietly widening the hole. + +**If it fails:** it prints every `file:line — type ''` with the offending source line. Either +spell the registered key (`grep -rn "ComponentRegistry.register(" packages/` for the real name), or — +if the value belongs to another vocabulary — add the declaration with its reason. Run it locally with +`pnpm check:doc-types`. + ## Link Checking (`check-links.yml`) **Trigger:** Weekly cron (`17 4 * * 0` — Sundays, off the top of the hour, when the scheduled-run diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index 8bd156ec67..6ca5a59ebb 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -205,23 +205,29 @@ Object.entries(formComponents).forEach(([type, component]) => { ### Multi-Step Form +Multi-step is a **mode of the object-bound form**, not a component of its own — there is +no `multi-step-form` type. The registered type is `object-form`, and `formType: 'wizard'` +turns its `sections` into steps; `WizardFormSchema` (exported by +`@object-ui/plugin-form`) declares the shape. Because the wizard resolves its fields from +the object's own metadata, a section lists field **names** rather than field definitions. + ```json { - "type": "multi-step-form", - "steps": [ + "type": "object-form", + "objectName": "contact", + "mode": "create", + "formType": "wizard", + "showStepIndicator": true, + "sections": [ { - "title": "Personal Info", - "fields": [ - { "name": "firstName", "type": "input", "label": "First Name", "required": true }, - { "name": "lastName", "type": "input", "label": "Last Name", "required": true } - ] + "name": "personal", + "label": "Personal Info", + "fields": ["first_name", "last_name"] }, { - "title": "Contact Info", - "fields": [ - { "name": "email", "type": "input", "inputType": "email", "label": "Email", "required": true }, - { "name": "phone", "type": "input", "inputType": "tel", "label": "Phone" } - ] + "name": "contact_details", + "label": "Contact Info", + "fields": ["email", "phone"] } ] } diff --git a/content/docs/utilities/runner.mdx b/content/docs/utilities/runner.mdx index 991cadd7f5..76be2f5a83 100644 --- a/content/docs/utilities/runner.mdx +++ b/content/docs/utilities/runner.mdx @@ -319,8 +319,7 @@ serve it from your own backend and load it with `?api=`. "className": "p-8 space-y-6", "children": [ { - "type": "heading", - "level": 1, + "type": "h1", "children": "Sales Dashboard" }, { diff --git a/content/docs/utilities/vscode-extension.mdx b/content/docs/utilities/vscode-extension.mdx index b21d1e1dd3..e324e4a8c4 100644 --- a/content/docs/utilities/vscode-extension.mdx +++ b/content/docs/utilities/vscode-extension.mdx @@ -87,7 +87,7 @@ const schema = { "type": "div", "className": "p-4", "children": [ - { "type": "heading", "level": 1, "children": "Hello World" } + { "type": "h1", "children": "Hello World" } ] } diff --git a/package.json b/package.json index a82001fd37..81909fe525 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "check:i18n-drift": "node scripts/check-i18n-en-drift.mjs", "check:i18n-dead-keys": "node scripts/check-i18n-dead-keys.mjs", "check:skills-paths": "node scripts/check-skills-paths.mjs", + "check:doc-types": "node scripts/check-doc-component-types.mjs", "cli": "node packages/cli/dist/cli.js", "objectui": "node packages/cli/dist/cli.js", "create-plugin": "node packages/create-plugin/dist/index.js", diff --git a/scripts/__tests__/check-doc-component-types.test.ts b/scripts/__tests__/check-doc-component-types.test.ts new file mode 100644 index 0000000000..c086ddb882 --- /dev/null +++ b/scripts/__tests__/check-doc-component-types.test.ts @@ -0,0 +1,473 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +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 — +// re-adding one is now itself an error (TS2578). See objectui#3494. +import { analyze, deriveRegistryKeys, scanDocs } from '../check-doc-component-types.mjs'; + +/** + * objectui#4823 — the test for `scripts/check-doc-component-types.mjs`. + * + * The gate answers one question: does every `type` string literal in a + * `content/docs/**.mdx` code block name a component this repository actually + * registers. Nothing rendered or parsed those snippets before it, so the same + * defect landed three times (objectui#4786 `stats-card`, objectui#4796 + * `plugin:grid` and `plugin:map`) and CI was green through all three. + * + * What this file pins, in the order the gate can go wrong: + * + * 1. **The registry derivation**, because a key it MISSES turns correct + * documentation red — the expensive direction. Every registration form the + * repo uses is fixtured, including the two that a naive scan gets wrong: a + * `skipFallback` belonging to the NEXT call, and a registration quoted + * inside a comment or a string. + * 2. **The verdicts**, over throwaway trees rather than this repository, so + * they stay decidable when the docs move. + * 3. **The exemption table is load-bearing and re-derived, never trusted.** + * 4. **The scan cannot collapse quietly** — an empty walk would make every + * assertion vacuous. + * 5. **This repository is green**, and the three snippets the first run of this + * gate found stay fixed. + * 6. **The gate is wired** where the other install-free gates are, and where a + * docs-only pull request can start it. + * + * Fixtures are temporary trees, never the real `content/docs`: a committed + * fixture page would have to contain a deliberately wrong `type`, and this very + * gate would then scan it. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const SCRIPT = 'scripts/check-doc-component-types.mjs'; + +interface Finding { + reason: string; + site: string; + value?: string; + detail?: string; +} + +/** Builds a throwaway tree and runs the REAL derivation/scan over it. */ +function withTree(build: (write: (rel: string, contents: string) => void) => void, run: (dir: string) => T): T { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-component-types-')); + const write = (rel: string, contents: string) => { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + }; + try { + build(write); + return run(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * The live tables are keyed by real repository paths, so a fixture tree can only + * exercise the MECHANISM if it supplies its own. Empty ones are the neutral + * default; a test that wants an exemption passes one. + */ +const BARE = { exemptions: {}, indirectRegistrations: [], openRegistrationSites: {} }; + +const keysOf = (dir: string): string[] => [...deriveRegistryKeys(dir, BARE).keys.keys()].sort(); +const derivationFindings = (dir: string): Finding[] => deriveRegistryKeys(dir, BARE).findings as Finding[]; + +// ── 1. the registry derivation ─────────────────────────────────────────────── + +describe('the registered-key universe is derived from the registration calls', () => { + it('reads a namespaced registration as BOTH the namespaced key and the bare fallback', () => { + const keys = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + // No import of a workspace package here, not even as fixture TEXT. + // `scripts-type-check.test.ts` greps this project's files for the + // `from 'object-ui/…'` shape to pin the claim that + // `pnpm type-check:scripts` needs no build, and that grep cannot tell a + // string literal — or a comment quoting one — from a real import. The + // fixture does not need the import line: the derivation reads the + // register CALL, not what the file imports. + "ComponentRegistry.register('object-grid', Renderer, {", + " namespace: 'plugin-grid',", + " label: 'Object Grid',", + '});', + ].join('\n'), + ); + }, keysOf); + expect(keys).toEqual(['object-grid', 'plugin-grid:object-grid']); + }); + + it('honours skipFallback — and reads it from the call it belongs to, not the next one', () => { + // The measured bug this assertion exists for. `plugin-grid/src/index.tsx` + // registers `object-grid` (no skipFallback) twelve lines above `grid` (which + // HAS it). A derivation that looks for `skipFallback` in a fixed-size window + // after the call reads the second call's flag on the first, drops the bare + // `object-grid` key — and then reports the thirteen doc sites that spell it + // correctly as unregistered types. A gate's derivation bug is a false RED on + // correct prose, which is the failure mode that gets gates deleted. + const keys = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + "ComponentRegistry.register('object-grid', Renderer, {", + " namespace: 'plugin-grid',", + " label: 'Object Grid',", + ' inputs: GRID_INPUTS.map((i) => ({ ...i })),', + '});', + '', + "ComponentRegistry.register('grid', Renderer, {", + " namespace: 'view',", + ' skipFallback: true,', + '});', + ].join('\n'), + ); + }, keysOf); + expect(keys).toEqual(['object-grid', 'plugin-grid:object-grid', 'view:grid']); + expect(keys, 'the bare `grid` key belongs to the layout container, not to this registration').not.toContain('grid'); + }); + + it('resolves the three loop forms the repo registers through', () => { + const keys = withTree((write) => { + write( + 'packages/demo/src/loops.tsx', + [ + "const TAGS = ['h1', 'h2'];", + 'for (const tag of TAGS) {', + " ComponentRegistry.register(tag, El, { namespace: 'ui' });", + '}', + '', + "const tags = ['aside', 'main'];", + 'tags.forEach(tag => {', + " ComponentRegistry.register(tag, El, { namespace: 'ui' });", + '});', + '', + "for (const variant of ['metric', 'pivot']) {", + " ComponentRegistry.registerLazy(variant, () => import('x'), { namespace: 'plugin-dashboard' });", + '}', + ].join('\n'), + ); + }, keysOf); + expect(keys).toEqual([ + 'aside', + 'h1', + 'h2', + 'main', + 'metric', + 'pivot', + 'plugin-dashboard:metric', + 'plugin-dashboard:pivot', + 'ui:aside', + 'ui:h1', + 'ui:h2', + 'ui:main', + ]); + }); + + it('does not read a registration written inside a comment or a string', () => { + // Both live in this repository: `Registry.ts` documents `register()` in + // JSDoc and quotes it inside a deprecation warning, `errors/index.ts` names + // it in an English sentence. Treating prose as a registration puts arbitrary + // strings into the universe, which makes the gate accept them in the docs. + const result = withTree((write) => { + write( + 'packages/demo/src/index.tsx', + [ + '/**', + " * @example ComponentRegistry.register('from-jsdoc', C, { namespace: 'ui' });", + ' */', + "// ComponentRegistry.register('from-line-comment', C, { namespace: 'ui' });", + 'export const warn = () =>', + ' `Ensure the component is registered via registry.register() before rendering.`;', + "ComponentRegistry.register('real', C, { namespace: 'ui' });", + ].join('\n'), + ); + }, (dir) => deriveRegistryKeys(dir, BARE)); + expect([...result.keys.keys()].sort()).toEqual(['real', 'ui:real']); + expect((result.findings as Finding[]).map((f) => f.reason)).not.toContain('unresolved-registration'); + }); + + it('reports a registration whose key it cannot resolve, rather than losing it', () => { + // Silently skipping an unresolvable call NARROWS the universe, and a + // narrowed universe reports correct documentation as wrong. The gate has to + // fail loudly on a registration form it was never taught. + const findings = withTree((write) => { + write('packages/demo/src/index.tsx', 'ComponentRegistry.register(computeKey(), C, { namespace: "ui" });\n'); + }, derivationFindings); + expect(findings.map((f) => f.reason)).toEqual(['unresolved-registration']); + }); + + it('ignores registrations that live in test files', () => { + // `probe`, `crashing-widget`, `test-widget` and friends are registered by + // suites all over this repo. Letting them into the universe would let a doc + // page teach a type that exists only inside a test. + const keys = withTree((write) => { + write('packages/demo/src/index.tsx', "ComponentRegistry.register('real', C, { namespace: 'ui' });\n"); + write('packages/demo/src/__tests__/x.test.tsx', "ComponentRegistry.register('probe', C, { namespace: 'ui' });\n"); + write('packages/demo/src/y.test.tsx', "ComponentRegistry.register('probe2', C, { namespace: 'ui' });\n"); + }, keysOf); + expect(keys).toEqual(['real', 'ui:real']); + }); +}); + +// ── 2. the docs scan ───────────────────────────────────────────────────────── + +describe('the docs scan reads code blocks, in both spellings, and only code blocks', () => { + it('captures JSON and object-literal spellings and ignores prose', () => { + const { sites, counters } = withTree((write) => { + write( + 'content/docs/x.mdx', + [ + 'Prose mentioning `type: \'never-scanned\'` in backticks.', + '', + '```json', + '{ "type": "from-json" }', + '```', + '', + '```plaintext', + "{ type: 'from-literal' }", + '```', + '', + '```tsx', + "const node = { type: 'from-tsx' };", + '```', + ].join('\n'), + ); + }, (dir) => scanDocs(dir)); + expect(sites.map((s) => s.value)).toEqual(['from-json', 'from-literal', 'from-tsx']); + expect(counters.codeBlocks).toBe(3); + }); + + it('does not read a JSX `type=` attribute or a dotted `.type` access as a site', () => { + const { sites } = withTree((write) => { + write( + 'content/docs/x.mdx', + ['```tsx', '', "const t = schema.type; // 'x'", "const nested = { subtype: 'y' };", '```'].join( + '\n', + ), + ); + }, (dir) => scanDocs(dir)); + expect(sites).toEqual([]); + }); + + it('reports an unterminated fence rather than guessing where code stops', () => { + const { findings } = withTree((write) => { + write('content/docs/x.mdx', ['```json', '{ "type": "div" }'].join('\n')); + write('packages/demo/src/i.tsx', "ComponentRegistry.register('div', C, { namespace: 'ui' });\n"); + }, (dir) => analyze(dir, BARE)); + expect((findings as Finding[]).map((f) => f.reason)).toContain('unterminated-code-fence'); + }); +}); + +// ── 3. the verdicts ────────────────────────────────────────────────────────── + +describe('a documented type that nothing registers is a finding', () => { + const tree = (mdx: string) => (write: (rel: string, contents: string) => void) => { + write( + 'packages/demo/src/index.tsx', + [ + "ComponentRegistry.register('object-grid', C, { namespace: 'plugin-grid' });", + "ComponentRegistry.register('object-map', C, { namespace: 'plugin-map' });", + ].join('\n'), + ); + write('content/docs/page.mdx', mdx); + }; + + it('passes a registered type, bare or namespaced', () => { + const { findings, counters } = withTree( + tree(['```json', '{ "type": "object-grid" }', '{ "type": "plugin-map:object-map" }', '```'].join('\n')), + (dir) => analyze(dir, BARE), + ); + expect(findings).toEqual([]); + expect(counters.registered, 'both sites must have been READ, not skipped').toBe(2); + }); + + it('flags the exact three recurrences objectui#4823 was filed for', () => { + const findings = withTree( + tree( + [ + '```plaintext', + "{ type: 'stats-card' }", + "{ type: 'plugin:grid' }", + "{ type: 'plugin:map' }", + '```', + ].join('\n'), + ), + (dir) => analyze(dir, BARE).findings as Finding[], + ); + expect(findings.map((f) => `${f.reason} :: ${f.value}`)).toEqual([ + 'unregistered-doc-type :: stats-card', + 'unregistered-doc-type :: plugin:grid', + 'unregistered-doc-type :: plugin:map', + ]); + expect(findings[1].site).toBe('content/docs/page.mdx:3'); + }); +}); + +// ── 4. the exemption table ─────────────────────────────────────────────────── + +describe('the exemption table is load-bearing, and re-derived rather than trusted', () => { + // The table in the script is keyed by REAL repository paths, so a fixture + // cannot exercise it directly. What a fixture CAN prove is the mechanism, and + // what the repository proves is that the entries are live — both below. + + it('every entry in the live table is hit by a real site', () => { + // The stale check is the mechanism; this asserts the repository currently + // satisfies it. An entry whose page stopped spelling that type silently + // widens the hole for the next snippet that lands there. + const findings = (analyze(repoRoot).findings as Finding[]).filter((f) => f.reason === 'stale-exemption'); + expect(findings.map((f) => f.site)).toEqual([]); + }); + + it('the table is doing work — emptying it turns this repository red', () => { + // The direction was decided before it was run: the exempted vocabularies are + // real (action schemas, block schemas, validation rules, field data types), + // so removing their declarations must produce findings, not silence. A gate + // whose exemption table could be deleted with no effect would be judging + // nothing that the registry check does not already accept. + const source = fs.readFileSync(path.join(repoRoot, SCRIPT), 'utf8'); + const exempted = analyze(repoRoot).counters.exempted; + expect(exempted, 'the live scan exempts nothing, so the table cannot be load-bearing').toBeGreaterThan(50); + // …and each exempted site is a distinct (file, value) declaration rather + // than one blanket rule. + const declarations = [...source.matchAll(/^\s{4}'?[\w:-]+'?:\s*$|^\s{4}'?[\w:-]+'?:\s*\n?\s*'/gm)].length; + expect(declarations, 'the exemption table has collapsed to a handful of entries').toBeGreaterThan(20); + }); + + it('an exemption without a written reason does not count as one', () => { + // A blank reason is how an exemption table degrades into a mute allow-list. + const source = fs.readFileSync(path.join(repoRoot, SCRIPT), 'utf8'); + expect(source).toContain("reason.trim().length > 0"); + expect(source).toContain('exemption carries no written reason'); + }); +}); + +// ── 5. the floors, and this repository ─────────────────────────────────────── + +describe('the scan cannot collapse quietly', () => { + it('an empty docs tree produces zero sites, which the floors reject', () => { + // Proven at the analysis layer, since the floors themselves live in the CLI: + // a walk that finds nothing must be visible as nothing, not as "no findings". + const counters = withTree((write) => { + write('packages/demo/src/index.tsx', "ComponentRegistry.register('div', C, { namespace: 'ui' });\n"); + }, (dir) => analyze(dir, BARE).counters); + expect(counters.files).toBe(0); + expect(counters.typeSites).toBe(0); + }); + + it('this repository clears every floor by a wide margin', () => { + const { counters } = analyze(repoRoot); + expect(counters.files).toBeGreaterThan(120); + expect(counters.codeBlocks).toBeGreaterThan(500); + expect(counters.typeSites).toBeGreaterThan(400); + expect(counters.registryKeys).toBeGreaterThan(500); + expect(counters.registered).toBeGreaterThan(400); + }); + + it('this repository is green', () => { + const findings = analyze(repoRoot).findings as Finding[]; + expect(findings.map((f) => `${f.reason} :: ${f.site} :: ${f.value ?? ''}`)).toEqual([]); + }); +}); + +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 + // ordinary docs edit. + const read = (rel: string) => fs.readFileSync(path.join(repoRoot, rel), 'utf8'); + + it('`heading` is gone from the two page schemas that taught it', () => { + // Nothing registers `heading`. `h1` is registered by `html-elements.tsx`'s + // TAGS loop and renders `schema.children`, which is what both snippets want. + for (const file of ['content/docs/utilities/runner.mdx', 'content/docs/utilities/vscode-extension.mdx']) { + const body = read(file); + expect(body, `${file} still teaches the unregistered \`heading\` type`).not.toContain('"type": "heading"'); + expect(body).toContain('"type": "h1"'); + } + }); + + it('the multi-step form teaches the wizard shape its own schema declares', () => { + const body = read('content/docs/plugins/plugin-form.mdx'); + // The page still SAYS `multi-step-form` — in prose, telling the reader the + // type does not exist. What must not come back is the snippet that authored + // it, which is the only spelling a reader copies. + expect(body).not.toContain('"type": "multi-step-form"'); + expect(body).toContain('there is\nno `multi-step-form` type'); + expect(body).toContain('"formType": "wizard"'); + expect(body).toContain('"type": "object-form"'); + // The package's own exported type is what makes that spelling the canonical + // one, so the pin fails if the declaration moves rather than going stale. + const schema = read('packages/plugin-form/src/WizardForm.tsx'); + expect(schema).toContain("type: 'object-form';"); + expect(schema).toContain("formType: 'wizard';"); + }); +}); + +// ── 6. the wiring ──────────────────────────────────────────────────────────── + +describe('wiring — the gate is reachable and a docs-only PR starts it', () => { + const workflowDir = path.join(repoRoot, '.github/workflows'); + const workflowPath = path.join(workflowDir, 'doc-component-types.yml'); + const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); + + /** + * A workflow's YAML with whole-line comments removed — these headers name each + * other's scripts in prose, and a scan that counted comments would report + * duplicate homes that no file has. + */ + const yamlOf = (file: string) => + fs + .readFileSync(path.join(workflowDir, file), 'utf8') + .split('\n') + .filter((line) => !/^\s*#/.test(line)) + .join('\n'); + + it('is exposed as a root package script', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { + scripts: Record; + }; + expect(pkg.scripts['check:doc-types']).toBe(`node ${SCRIPT}`); + }); + + it('has a workflow that gates pull requests, not just pushes', () => { + expect(fs.existsSync(workflowPath), 'a check nothing runs is not a gate').toBe(true); + const yaml = yamlOf('doc-component-types.yml'); + expect(yaml).toMatch(new RegExp(`run:\\s*node\\s+${SCRIPT.replace(/[.]/g, '\\.')}`)); + expect(yaml).toMatch(/^\s*pull_request:/m); + expect(yaml).toMatch(/^\s*push:/m); + expect(yaml).toMatch(/^\s*merge_group:/m); + }); + + it('runs it in NO path-filtered workflow — the change that breaks it is docs-only', () => { + // The whole reason this is its own workflow. `ci.yml`'s type-check job + // excludes `content/**` from the diff that decides whether its gates run, so + // a PR editing only `content/docs/**.mdx` would start this gate nowhere. + expect(workflowFiles.length, 'the workflow directory scan returned implausibly few files').toBeGreaterThan(5); + for (const file of workflowFiles) { + const yaml = yamlOf(file); + if (!yaml.includes(SCRIPT)) continue; + expect(yaml, `${file} runs ${SCRIPT} behind a paths-ignore — a docs-only change would not start it`).not.toMatch( + /paths-ignore:/, + ); + expect(yaml, `${file} runs ${SCRIPT} behind a paths filter — see objectui#3448`).not.toMatch(/^\s+paths:/m); + } + }); + + it('has exactly one home', () => { + expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual(['doc-component-types.yml']); + }); + + it('needs no install, so it can afford to run unfiltered', () => { + // The moment this needs `pnpm install` it stops being cheap enough to run on + // every PR shape, and the filter that follows is the hole. + const yaml = yamlOf('doc-component-types.yml'); + expect(yaml).not.toContain('pnpm install'); + expect(yaml).not.toContain('corepack'); + const gate = fs.readFileSync(path.join(repoRoot, SCRIPT), 'utf8'); + const imports = [...gate.matchAll(/^import .* from '([^']+)';$/gm)].map((m) => m[1]); + expect(imports.every((spec) => spec.startsWith('node:')), `non-builtin import in the gate: ${imports}`).toBe(true); + }); +}); diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs new file mode 100644 index 0000000000..4593c3ddc0 --- /dev/null +++ b/scripts/check-doc-component-types.mjs @@ -0,0 +1,902 @@ +#!/usr/bin/env node +/** + * Every `type` string literal in a `content/docs/**.mdx` code block must name a + * component the repository actually registers — or be declared, per file, as + * belonging to some other vocabulary. + * + * 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 + * exemption), 1 = at least one snippet teaches a type nothing registers, + * an exemption has gone stale, or the scan collapsed. + * + * ## The failure this closes (objectui#4823, recurrence #4 would have been free) + * + * `examples/schema-catalog/test/catalog-gallery-render.test.tsx` (objectui#4616) + * renders every catalog entry and fails if any paints the registry's + * "Unknown component type" panel (OBJUI-001). The TEACHING surface had no + * equivalent: a ```plaintext block in `content/docs/**` is not rendered, not + * parsed and not compared against anything, so a snippet could name any `type` + * string at all and CI stayed green end to end. + * + * The same defect landed three times before this gate existed, each one found + * by a human probe rather than by a check: + * + * objectui#4786 `content/docs/**` taught `stats-card` + * objectui#4796 `content/docs/fields/grid.mdx` taught `plugin:grid` (real: `object-grid`) + * objectui#4796 `content/docs/fields/location.mdx` taught `plugin:map` (real: `object-map`) + * + * A reader who copied any of them got a red panel instead of a component. The + * first scan under this gate found two more of exactly that shape, fixed in the + * same PR: `heading` (`utilities/runner.mdx`, `utilities/vscode-extension.mdx` + * — nothing registers it; `h1` does) and `multi-step-form` + * (`plugins/plugin-form.mdx` — the string appears nowhere in the repo outside + * that snippet; the wizard is `object-form` + `formType: 'wizard'`, which + * `WizardFormSchema` declares). + * + * NOT in scope, deliberately: whether the snippet's OTHER keys are read by the + * renderer the type resolves to. That is objectui#4823's second dimension and + * needs a per-renderer read-point contract; this gate answers one question only + * — does the type exist. + * + * ## Where the registered-key universe comes from — derived, never a list + * + * A hard-coded key list would be the Nth copy of an enumeration this repo + * already keeps in the register calls themselves, and it would rot the first + * time a plugin adds a component. So the universe is derived from source on + * every run, with no build step (which is what lets this run as a per-PR check + * next to `check-doc-links.mjs` rather than behind a `turbo build`): + * + * - DIRECT: `X.register('key', …)` / `X.registerLazy('key', …)` for the + * receivers listed in `REGISTRY_RECEIVERS`. `namespace` and `skipFallback` + * are read out of the call's OWN balanced argument span — not a fixed-size + * window. That distinction is load-bearing and was measured: with a + * 1500-character window, `plugin-grid/src/index.tsx`'s `object-grid` + * registration (line 129) picked up the `skipFallback: true` belonging to + * the `grid` registration 12 lines below it, and the derivation silently + * dropped the bare `object-grid` key — which 13 doc sites teach correctly. + * A window bug in a derivation this gate trusts shows up as a false RED on + * correct documentation, so the span is matched, not guessed. + * - LOOP: `for (const v of ['a','b'])`, `for (const v of ARR)` and + * `ARR.forEach(v => …)` where `ARR` is a literal array in the same file. + * Five registration sites use this form (`html-elements.tsx`'s `TAGS`, + * `semantic.tsx`'s `tags`, and three `for (const variant of […])` blocks in + * `apps/console`). + * - INDIRECT: two helpers register from a collection, and each is named + * explicitly in `INDIRECT_REGISTRATIONS` with the collection it reads. Both + * entries are re-derived per run; a named collection that disappears or + * stops yielding keys fails the gate rather than shrinking the universe. + * - OPEN: a handful of call sites take a key that is not knowable statically + * (a third-party plugin's own type, a widget manifest's `type`). Those are + * listed in `OPEN_REGISTRATION_SITES` with the reason. Every OTHER + * unresolvable call site fails the gate as `unresolved-registration` — a + * new dynamic registration path must be taught to this derivation, because + * silently missing one narrows the universe and turns correct docs red. + * + * The universe is deliberately GENEROUS: it unions every key any package or app + * in the repo can register, including the opt-in protocol placeholders, without + * modelling which host loaded which package. A doc site is judged on "does this + * string name a component that exists", never on "is it registered in the host + * this page happens to describe". Host-specific registration is a different + * question with a different answer per page, and a gate that guessed at it + * would produce exactly the false reds that get gates deleted. + * + * ## How a doc site is judged, and why there is no structural discriminator + * + * `type` is not one vocabulary in these pages. Measured over all 143 files on + * the tree this gate was written against — 560 `type: '…'` sites in fenced code + * blocks — the corpus carries at least seven distinct vocabularies that all + * spell the key `type`: + * + * SDUI component keys `{ type: 'object-grid', … }` the one this gate judges + * action schemas `action: { type: 'submit' }` `@object-ui/types`' ActionSchema + * block schemas `type: 'block-instance'` `packages/types/src/blocks.ts` + * theme / report schemas`type: 'theme-switcher'`, `'matrix'` `types/src/theme.ts`, `reports.ts` + * field + JSON-Schema `type: 'string'`, `type: 'currency'` variable / property declarations + * validation rules `validation: [{ type: 'minLength' }]` form rule discriminants + * nav + feed items `type: 'item'`, `type: 'comment'` menu entries, activity feed items + * + * The obvious discriminator — classify by the enclosing key path, so a `type` + * under `validation` is a rule and a `type` under `children` is a node — was + * built and MEASURED before being rejected. Two findings killed it. First, the + * snippets are TypeScript as often as JSON, and a TS annotation reads exactly + * like an object key to a brace-tracking scanner: `const heroBlock: BlockSchema + * = {` made `heroBlock` the enclosing key for everything inside it, and 31 of + * the 92 off-registry sites came out with a path a human would not recognise. + * Second, and fatally, the fix does not converge: `items` carries nav entries on + * one page and renderable children on another, so any global parent-key rule is + * a silent false GREEN on one of them. A misclassifying discriminator is worse + * than none, because its mistakes are invisible in both directions. + * + * So the rule is flat and stated rather than inferred: + * + * EVERY `type` string literal in a docs code block is a candidate SDUI + * component key. If its value is in the derived universe it passes. If it + * is not, the file must DECLARE it in `DOC_TYPE_EXEMPTIONS` with a written + * reason naming the vocabulary it really belongs to. Anything else is red. + * + * Exemptions are keyed by (file, value), never by file alone and never by value + * alone. A whole-file exemption would silence real defects on pages that mix + * vocabularies — `blocks/block-schema.mdx` carries `type: 'block'` AND + * `type: 'div'` in the same document — and a value-only exemption would let a + * page anywhere in the tree teach `submit` as a component. (file, value) also + * keeps the entry honest: it says which page speaks which dialect, which is the + * fact a reader of that page needs. + * + * Every entry is re-derived per run. An entry whose file no longer contains + * that value, or that carries no reason, fails as `stale-exemption` — an + * exemption is worth what its evidence is worth, and one that outlives its site + * widens the hole for the next snippet that lands there. + * + * ## Line numbers are reported, but the unit of judgment is the value + * + * 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. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); + +// ── Configuration ──────────────────────────────────────────────────────────── + +/** Where the teaching prose lives. */ +const DOCS_ROOT = 'content/docs'; + +/** Where registrations live. Every workspace source root that can register. */ +const SOURCE_ROOTS = ['packages', 'apps', 'examples']; + +/** + * Receiver expressions whose `.register(` / `.registerLazy(` is a + * ComponentRegistry registration. Spelled out rather than matched loosely + * because this repo has several unrelated `.register(` methods — react-hook-form + * controls, the formula table, the record context, `serviceWorker.register` — + * and treating those as registrations would put arbitrary strings into the + * universe. + */ +const REGISTRY_RECEIVERS = ['ComponentRegistry', 'componentRegistry', 'registry', 'scope']; + +/** + * Helpers that register from a collection instead of from a literal argument. + * Each entry names the collection it reads; the derivation re-reads that + * collection every run and fails if it is gone. + */ +const INDIRECT_REGISTRATIONS = [ + { + site: 'packages/components/src/renderers/placeholders.tsx', + collection: 'PROTOCOL_COMPONENTS', + kind: 'array', + namespace: 'protocol-placeholder', + reason: + '`registerPlaceholder(type)` registers every entry of PROTOCOL_COMPONENTS under the ' + + '`protocol-placeholder` namespace (opt-in via registerPlaceholders(); apps/console calls it). ' + + 'The keys are protocol vocabulary the docs legitimately teach.', + }, + { + site: 'packages/fields/src/index.tsx', + collection: 'fieldWidgetMap', + kind: 'object-keys', + namespace: 'field', + skipFallbackSet: 'FIELD_TYPES_SKIP_FALLBACK', + reason: + '`registerField(fieldType)` registers each key of fieldWidgetMap as `field:` (plus the ' + + 'bare key unless FIELD_TYPES_SKIP_FALLBACK holds it), and `registerAllFields()` runs it for ' + + 'every key at module load. Field pages teach these bare keys.', + }, +]; + +/** + * Registration call sites whose key genuinely cannot be known statically. Each + * is matched by `:` and must still BE a register call on that line, + * so a moved or deleted site is reported rather than silently forgiven. + */ +const OPEN_REGISTRATION_SITES = { + 'packages/core/src/registry/PluginScopeImpl.ts': { + receiver: 'registry', + reason: + 'PluginScope.register() forwards a third-party plugin\'s own type through to the registry. ' + + 'The key belongs to the plugin, not to this repository, so there is nothing here to derive.', + }, + 'packages/core/src/registry/WidgetRegistry.ts': { + receiver: 'componentRegistry', + reason: + 'WidgetRegistry registers `manifest.type` from a host-supplied widget manifest. The manifest ' + + 'is data, not source, so its types are not derivable from this tree.', + }, +}; + +/** + * Per-file declarations that a `type` value in that page belongs to a + * vocabulary other than the SDUI component registry. + * + * Keyed `` -> `` -> reason. The reason must + * name the vocabulary and, where one exists, where it is declared — an + * exemption that only says "not a component" teaches the next reader nothing + * and cannot be re-checked. + */ +const DOC_TYPE_EXEMPTIONS = { + 'content/docs/blocks/authentication.mdx': { + submit: + 'ActionSchema discriminant under a button\'s `action` key, not a node type. ' + + '`@object-ui/types` ActionSchema.', + }, + 'content/docs/blocks/block-schema.mdx': { + block: + 'BlockSchema discriminant — `packages/types/src/blocks.ts` declares `type: \'block\'`, and ' + + '`packages/types/src/zod/blocks.zod.ts` validates it. A block definition is not a rendered node.', + 'block-instance': + 'BlockInstanceSchema discriminant — packages/types/src/blocks.ts:357, zod/blocks.zod.ts:130.', + 'block-library': + 'BlockLibrarySchema discriminant — packages/types/src/blocks.ts:263, zod/blocks.zod.ts:100.', + 'block-editor': + 'BlockEditorSchema discriminant — packages/types/src/blocks.ts:315, zod/blocks.zod.ts:116.', + slot: + 'Block slot placeholder inside `BlockSchema.template`, which IS a `SchemaNode` — so unlike its ' + + 'siblings above this one sits on the render path and nothing registers `slot`. Filed as ' + + 'objectui#4895: the correct spelling is not one thing (register a slot node, or route the ' + + 'snippet through the declared `slotContent` key), and objectui#4823 does not pre-decide it. ' + + 'DELETE this entry when #4895 lands — the gate reports a stale exemption, so it cannot be ' + + 'forgotten.', + string: + 'BlockVariable.type — a variable declaration\'s data type, next to `defaultValue` / `required`.', + }, + 'content/docs/blocks/dashboard.mdx': { + navigate: 'ActionSchema discriminant under a node\'s `action` key.', + }, + 'content/docs/blocks/ecommerce.mdx': { + submit: 'ActionSchema discriminant under a node\'s `action` key.', + }, + 'content/docs/blocks/forms.mdx': { + submit: 'ActionSchema discriminant under a node\'s `action` key.', + }, + 'content/docs/blocks/marketing.mdx': { + analytics: 'ActionSchema discriminant under a node\'s `action` key.', + }, + 'content/docs/components/complex/filter-ui.mdx': { + 'date-range': + 'Filter control type — `packages/types/src/crud.ts:329` and `views.ts:804` declare it in the ' + + 'filter enum, alongside `date-picker` / `number-range`.', + }, + 'content/docs/components/complex/view-switcher.mdx': { + share: + 'First member of a TypeScript union of view-action ids (`\'share\' | \'settings\' | ' + + '\'duplicate\' | \'delete\'`) in a Schema API declaration, not a node type.', + }, + 'content/docs/core/app-schema.mdx': { + item: 'AppSchema menu entry kind — a navigation item, sibling of `group`. Not a rendered node.', + group: 'AppSchema menu entry kind — a navigation group holding `children` items.', + }, + 'content/docs/core/enhanced-actions.mdx': { + action: + 'ActionSchema discriminant. This page documents the action vocabulary end to end, so every ' + + '`type: \'action\'` here is an action definition rather than a node.', + message: 'ActionSchema discriminant for the message/toast action under `onFailure`.', + }, + 'content/docs/core/report-schema.mdx': { + line: 'Chart series kind under a report section\'s `chart.series`, not a node type.', + 'page-break': + 'ReportSection kind — `packages/types/src/reports.ts:210` declares the section enum ' + + '(`header | summary | chart | table | text | page-break`).', + 'report-builder': + 'ReportBuilderSchema discriminant — packages/types/src/reports.ts:464, zod/reports.zod.ts:154.', + string: 'Report field data type in a `fields` declaration, not a node type.', + }, + 'content/docs/core/schema-renderer.mdx': { + 'my-widget': + 'Deliberate placeholder in the "register your own component" walkthrough — the page teaches ' + + 'the reader to register this key, so it is unregistered here by design.', + }, + 'content/docs/core/theme-schema.mdx': { + theme: + 'ThemeSchema discriminant — `packages/types/src/theme.ts` declares the theme document\'s own ' + + '`type`, validated by zod/theme.zod.ts.', + 'theme-preview': 'ThemePreviewSchema discriminant — packages/types/src/theme.ts:167.', + 'theme-switcher': 'ThemeSwitcherSchema discriminant — packages/types/src/theme.ts:145.', + }, + 'content/docs/fields/object.mdx': { + array: 'JSON Schema property type inside a field\'s `schema.properties`, not a node type.', + string: 'JSON Schema property type inside a field\'s `schema.properties`, not a node type.', + }, + 'content/docs/guide/objectos-integration.mdx': { + 'my-custom-widget': + 'Deliberate placeholder in the "register a lazy custom widget" walkthrough — the reader ' + + 'supplies this key.', + }, + 'content/docs/plugins/plugin-dashboard.mdx': { + bar: 'Dashboard widget kind under `widgets[]`, alongside `line`. Not a node type.', + line: 'Dashboard widget kind under `widgets[]`, alongside `bar`. Not a node type.', + }, + 'content/docs/plugins/plugin-detail.mdx': { + comment: 'FeedItem kind in a `FeedItem[]` literal — `@object-ui/types` activity feed vocabulary.', + field_change: 'FeedItem kind in a `FeedItem[]` literal — activity feed vocabulary.', + }, + 'content/docs/plugins/plugin-form.mdx': { + minLength: 'ValidationRule discriminant under a field\'s `validation[]`, not a node type.', + maxLength: 'ValidationRule discriminant under a field\'s `validation[]`, not a node type.', + }, + 'content/docs/plugins/plugin-grid.mdx': { + count_unique: 'Column summary aggregation under `columns[].summary`, not a node type.', + }, + 'content/docs/plugins/plugin-report.mdx': { + matrix: 'ReportInput kind — a report definition\'s shape, sibling of `joined` / `summary`.', + joined: 'ReportInput kind — a report definition\'s shape, sibling of `matrix` / `summary`.', + }, + 'content/docs/utilities/runner.mdx': { + 'my-component': + 'Deliberate placeholder in the "load your own plugin" walkthrough — the reader registers it.', + 'your-component': + 'Deliberate placeholder in the "load your own plugin" walkthrough — the reader registers it.', + }, + 'content/docs/utilities/vscode-extension.mdx': { + ajax: 'ActionSchema discriminant under a form\'s `onSubmit`, not a node type.', + api: 'Data source kind under a node\'s `dataSource`, not a node type.', + }, +}; + +/** + * Floors. A refactor that quietly empties any of these walks would satisfy + * every assertion in this file while comparing nothing, so each input the + * verdict depends on has a size the tree is known to clear by a wide margin. + */ +const FLOORS = { + docFiles: 100, + codeBlocks: 400, + typeSites: 300, + registryKeys: 300, +}; + +// ── Source utilities ───────────────────────────────────────────────────────── + +/** + * Blank out `//` and block comments, preserving every byte position and line + * break so reported line numbers stay true, and return alongside it a mask + * marking which positions sit INSIDE a string literal. + * + * Both faces are needed and for opposite reasons. Comments must go because + * registrations are quoted verbatim inside JSDoc in several files + * (`Registry.ts`, `WidgetRegistry.ts`, `layout/src/index.ts`) and a comment is + * not a registration. String CONTENTS must stay, because that is where the keys + * are — but a receiver match that begins inside a string is prose, not a call: + * `packages/core/src/errors/index.ts:27` carries the sentence "Ensure the + * component is registered via registry.register() before rendering", which + * matches the receiver pattern and resolves to no key at all. + */ +function stripComments(text) { + let out = ''; + const inString = new Uint8Array(text.length); + let i = 0; + const n = text.length; + while (i < n) { + const ch = text[i]; + if (ch === '/' && text[i + 1] === '/') { + while (i < n && text[i] !== '\n') { + out += ' '; + i++; + } + continue; + } + if (ch === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + const stop = end < 0 ? n : end + 2; + for (; i < stop; i++) out += text[i] === '\n' ? '\n' : ' '; + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { + const quote = ch; + out += ch; + i++; + while (i < n) { + inString[i] = 1; + if (text[i] === '\\') { + out += text[i] + (text[i + 1] ?? ''); + if (i + 1 < n) inString[i + 1] = 1; + i += 2; + continue; + } + out += text[i]; + if (text[i] === quote) { + i++; + break; + } + i++; + } + continue; + } + out += ch; + i++; + } + return { source: out, inString }; +} + +/** + * Return the index just past the `)` matching the `(` at `open`, respecting + * nested brackets and string literals. Returns -1 when unbalanced. + */ +function spanEnd(text, open) { + let depth = 0; + let i = open; + const n = text.length; + while (i < n) { + const ch = text[i]; + if (ch === '"' || ch === "'" || ch === '`') { + const quote = ch; + i++; + while (i < n && text[i] !== quote) { + if (text[i] === '\\') i++; + i++; + } + i++; + continue; + } + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']' || ch === '}') { + depth--; + if (depth === 0) return i + 1; + } + i++; + } + return -1; +} + +const lineAt = (text, index) => text.slice(0, index).split('\n').length; + +function walkFiles(dir, predicate, out = []) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name.startsWith('.')) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) walkFiles(full, predicate, out); + else if (predicate(full)) out.push(full); + } + return out; +} + +const isTestFile = (rel) => + rel.includes(`${sep}__tests__${sep}`) || + rel.includes('/__tests__/') || + /\.(test|spec)\.[tj]sx?$/.test(rel) || + /\.(guard|regression)\.test\./.test(rel); + +// ── Registry derivation ────────────────────────────────────────────────────── + +/** + * Resolve the literal names a registration's first argument can take. + * Returns `null` when the argument is not statically knowable. + */ +function resolveKeyArgument(source, callOpen) { + const after = source.slice(callOpen + 1); + const literal = /^\s*(['"])([^'"]*)\1\s*,/.exec(after); + if (literal) return [literal[2]]; + + const identifier = /^\s*([A-Za-z_$][\w$]*)\s*,/.exec(after); + if (!identifier) return null; + const name = identifier[1]; + const before = source.slice(0, callOpen); + + // for (const v of ['a', 'b']) + const inline = [...before.matchAll(new RegExp(`for\\s*\\(\\s*const\\s+${name}\\s+of\\s+\\[([^\\]]*)\\]`, 'g'))].pop(); + if (inline) return [...inline[1].matchAll(/(['"])([^'"]+)\1/g)].map((m) => m[2]); + + // for (const v of ARR) | ARR.forEach(v => …) + const viaArray = [ + ...before.matchAll( + new RegExp( + `for\\s*\\(\\s*const\\s+${name}\\s+of\\s+([A-Za-z_$][\\w$]*)|([A-Za-z_$][\\w$]*)\\s*\\.\\s*forEach\\s*\\(\\s*\\(?${name}\\b`, + 'g', + ), + ), + ].pop(); + const arrayName = viaArray && (viaArray[1] || viaArray[2]); + if (!arrayName) return null; + const declaration = new RegExp( + `(?:const|let|var)\\s+${arrayName}\\s*(?::[^=]*)?=\\s*(?:new Set\\()?\\[([\\s\\S]*?)\\]`, + 'm', + ).exec(source); + if (!declaration) return null; + const names = [...declaration[1].matchAll(/(['"])([^'"]+)\1/g)].map((m) => m[2]); + return names.length ? names : null; +} + +function literalArray(source, name) { + const m = new RegExp(`(?:const|let|var)\\s+${name}\\s*(?::[^=]*)?=\\s*\\[([\\s\\S]*?)\\n\\];`, 'm').exec(source); + if (!m) return null; + return [...m[1].matchAll(/(['"])([^'"]+)\1/g)].map((x) => x[2]); +} + +function literalObjectKeys(source, name) { + const m = new RegExp(`(?:const|let|var)\\s+${name}\\b[\\s\\S]*?=\\s*\\{([\\s\\S]*?)\\n\\};`, 'm').exec(source); + if (!m) return null; + return [...m[1].matchAll(/^\s*(['"])([^'"]+)\1\s*:/gm)].map((x) => x[2]); +} + +function literalSet(source, name) { + const m = new RegExp(`(?:const|let|var)\\s+${name}\\s*(?::[^=]*)?=\\s*new Set\\(\\[([\\s\\S]*?)\\]\\)`, 'm').exec(source); + if (!m) return new Set(); + return new Set([...m[1].matchAll(/(['"])([^'"]+)\1/g)].map((x) => x[2])); +} + +/** + * The tables are injectable for the same reason the sibling gates' are: they are + * keyed by real repository paths, so a fixture tree can only exercise the + * MECHANISM if it can supply its own. The defaults are the live tables, which is + * what every caller outside the test suite wants. + */ +export function deriveRegistryKeys(root, options = {}) { + const indirect = options.indirectRegistrations ?? INDIRECT_REGISTRATIONS; + const openRegistrations = options.openRegistrationSites ?? OPEN_REGISTRATION_SITES; + const keys = new Map(); + const findings = []; + const counters = { sourceFiles: 0, callSites: 0, resolved: 0, open: 0, indirect: 0 }; + const openSeen = new Set(); + const indirectSeen = new Set(); + const indirectSites = new Set(indirect.map((entry) => entry.site)); + + const add = (key, site) => { + if (!key || key.includes('${')) return; + if (!keys.has(key)) keys.set(key, []); + keys.get(key).push(site); + }; + + const receiverPattern = new RegExp( + `(? /\.(ts|tsx)$/.test(f) && !isTestFile(relative(root, f)), sourceFiles); + } + + for (const abs of sourceFiles.sort()) { + const rel = relative(root, abs).split(sep).join('/'); + const raw = readFileSync(abs, 'utf8'); + if (!/\.register(Lazy)?\s*\(/.test(raw)) continue; + counters.sourceFiles++; + const { source, inString } = stripComments(raw); + receiverPattern.lastIndex = 0; + let match; + while ((match = receiverPattern.exec(source))) { + if (inString[match.index]) continue; + counters.callSites++; + const callOpen = match.index + match[0].length - 1; + const line = lineAt(source, match.index); + const site = `${rel}:${line}`; + const names = resolveKeyArgument(source, callOpen); + if (!names) { + if (indirectSites.has(rel)) { + // The key comes from a collection this file iterates; the collection + // itself is read below by INDIRECT_REGISTRATIONS. + indirectSeen.add(rel); + continue; + } + const open = openRegistrations[rel]; + if (open) { + counters.open++; + openSeen.add(rel); + continue; + } + findings.push({ + reason: 'unresolved-registration', + site, + detail: + `the key argument of this ${match[1]}() call is not a string literal and could not be ` + + 'resolved to one. Teach `resolveKeyArgument` this form, or declare the site in ' + + 'OPEN_REGISTRATION_SITES with the reason it cannot be known statically.', + }); + continue; + } + counters.resolved++; + const end = spanEnd(source, callOpen); + const span = end < 0 ? source.slice(callOpen, callOpen + 2000) : source.slice(callOpen, end); + const nsMatch = /namespace\s*:\s*(['"])([^'"]+)\1/.exec(span); + const namespace = nsMatch && !nsMatch[2].includes('${') ? nsMatch[2] : null; + const skipFallback = /skipFallback\s*:\s*true/.test(span); + for (const name of names) { + if (namespace) { + add(`${namespace}:${name}`, site); + if (!skipFallback) add(name, site); + } else { + add(name, site); + } + } + } + } + + for (const rel of Object.keys(openRegistrations)) { + if (!openSeen.has(rel)) { + findings.push({ + reason: 'stale-open-site', + site: rel, + detail: + 'OPEN_REGISTRATION_SITES names this file, but no unresolvable registration call was found ' + + 'in it. Re-confirm the site or delete the entry.', + }); + } + } + + for (const entry of indirect) { + if (!indirectSeen.has(entry.site)) { + findings.push({ + reason: 'stale-indirect-registration', + site: entry.site, + detail: + 'INDIRECT_REGISTRATIONS names this file, but it no longer contains a registration whose key ' + + 'comes from a collection. The helper was probably rewritten to register literals; drop the ' + + 'entry so the universe is not padded from a collection nothing reads.', + }); + } + const abs = join(root, entry.site); + let source; + try { + source = stripComments(readFileSync(abs, 'utf8')).source; + } catch { + findings.push({ + reason: 'stale-indirect-registration', + site: entry.site, + detail: 'file named in INDIRECT_REGISTRATIONS no longer exists.', + }); + continue; + } + const names = + entry.kind === 'array' ? literalArray(source, entry.collection) : literalObjectKeys(source, entry.collection); + if (!names || names.length === 0) { + findings.push({ + reason: 'stale-indirect-registration', + site: `${entry.site} (${entry.collection})`, + detail: + `the collection \`${entry.collection}\` no longer resolves to a non-empty list of literal ` + + 'keys. The derivation would silently lose these registrations, so this is reported rather ' + + 'than skipped.', + }); + continue; + } + const skip = entry.skipFallbackSet ? literalSet(source, entry.skipFallbackSet) : new Set(); + counters.indirect += names.length; + for (const name of names) { + // Same shape as a direct call: the namespaced key always, plus the bare + // fallback unless the helper's skip set holds it. `PROTOCOL_COMPONENTS` + // entries are ALREADY namespaced strings (`view:grid`, `field:text`), so + // the bare form there is the spelling the docs actually teach and the + // `protocol-placeholder:` prefix is the derived one — the reverse of the + // usual reading, but the same two keys either way. + if (entry.namespace) { + add(`${entry.namespace}:${name}`, entry.site); + if (!skip.has(name)) add(name, entry.site); + } else { + add(name, entry.site); + } + } + } + + return { keys, findings, counters }; +} + +// ── Docs scan ──────────────────────────────────────────────────────────────── + +/** + * 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. + */ +export function scanDocs(root) { + const docsDir = join(root, DOCS_ROOT); + const files = walkFiles(docsDir, (f) => f.endsWith('.mdx')).sort(); + const sites = []; + const counters = { files: files.length, codeBlocks: 0, typeSites: 0 }; + + for (const abs of files) { + const rel = relative(root, abs).split(sep).join('/'); + const lines = readFileSync(abs, 'utf8').split('\n'); + let inFence = false; + let lang = null; + for (let i = 0; i < lines.length; i++) { + const fence = /^\s*```(\S*)\s*$/.exec(lines[i]); + if (fence) { + if (inFence) { + inFence = false; + lang = null; + } else { + inFence = true; + lang = fence[1] || 'plaintext'; + counters.codeBlocks++; + } + continue; + } + if (!inFence) continue; + for (const m of lines[i].matchAll(/(?:"type"|'type'|(? JSON.stringify([file, value]); + +export function analyze(root, options = {}) { + const exemptions = options.exemptions ?? DOC_TYPE_EXEMPTIONS; + const registry = deriveRegistryKeys(root, options); + const docs = scanDocs(root); + const findings = [...registry.findings]; + const counters = { + ...docs.counters, + registryKeys: registry.keys.size, + ...registry.counters, + registered: 0, + exempted: 0, + }; + + const exemptionHits = new Map(); + + for (const site of docs.sites) { + if (site.unterminated) { + findings.push({ + reason: 'unterminated-code-fence', + site: `${site.file}:${site.line}`, + detail: 'a code fence is never closed, so the scan cannot tell code from prose in this file.', + }); + continue; + } + if (registry.keys.has(site.value)) { + counters.registered++; + continue; + } + const reason = exemptions[site.file]?.[site.value]; + if (typeof reason === 'string' && reason.trim().length > 0) { + counters.exempted++; + exemptionHits.set(exemptionKey(site.file, site.value), true); + continue; + } + findings.push({ + reason: 'unregistered-doc-type', + site: `${site.file}:${site.line}`, + value: site.value, + lang: site.lang, + text: site.text, + }); + } + + for (const [file, values] of Object.entries(exemptions)) { + for (const [value, why] of Object.entries(values)) { + if (typeof why !== 'string' || why.trim().length === 0) { + findings.push({ + reason: 'stale-exemption', + site: `${file} -> ${value}`, + detail: 'exemption carries no written reason.', + }); + continue; + } + if (!exemptionHits.has(exemptionKey(file, value))) { + findings.push({ + reason: 'stale-exemption', + site: `${file} -> ${value}`, + detail: + 'no code block in that file spells this type any more. Delete the entry, or re-point it ' + + 'at the file that now carries the snippet.', + }); + } + } + } + + return { findings, counters, registryKeys: registry.keys }; +} + +// ── CLI ────────────────────────────────────────────────────────────────────── + +const HINTS = { + 'unregistered-doc-type': + 'A documentation code block teaches a `type` that nothing in this repository registers. A reader ' + + 'who copies it gets the renderer\'s red "Unknown component type" panel (OBJUI-001) instead of a ' + + 'component. Either spell the registered key (grep `ComponentRegistry.register(` for the real ' + + 'name), or — if the value belongs to another vocabulary (an action schema, a validation rule, a ' + + 'field data type, a nav item kind) — declare it in DOC_TYPE_EXEMPTIONS with a reason naming that ' + + 'vocabulary. See objectui#4823.', + 'stale-exemption': + 'An entry in DOC_TYPE_EXEMPTIONS no longer matches the tree. Re-point it or delete it — an ' + + 'exemption whose site has gone silently widens the hole for the next snippet that lands there.', + 'unresolved-registration': + 'A ComponentRegistry registration takes a key this derivation cannot resolve to literals. Left ' + + 'unhandled it shrinks the universe, which turns CORRECT documentation red. Teach ' + + '`resolveKeyArgument` the form, or declare the site in OPEN_REGISTRATION_SITES.', + 'stale-open-site': + 'OPEN_REGISTRATION_SITES names a file that no longer has an unresolvable registration.', + 'stale-indirect-registration': + 'An INDIRECT_REGISTRATIONS entry no longer resolves to keys, so the universe lost them silently.', + 'unterminated-code-fence': + 'An mdx file has an unclosed ``` fence. The scan cannot separate code from prose past that point.', +}; + +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); + +if (invokedDirectly) { + const argOf = (name) => { + const index = process.argv.indexOf(name); + return index > -1 ? process.argv[index + 1] : null; + }; + const root = resolve(argOf('--root') ?? resolve(scriptDir, '..')); + + let result; + try { + result = analyze(root); + } catch (error) { + console.error( + `❌ ${error.message}\n\n` + + ' Reported as a failure rather than a pass: this gate decides whether the docs teach types ' + + 'that exist,\n so losing an input means it cannot decide, and a green verdict would have ' + + 'looked at nothing.', + ); + process.exit(1); + } + + const { findings, counters } = result; + + for (const [key, floor] of Object.entries(FLOORS)) { + if (counters[key] < floor) { + console.error( + `The scan collapsed: ${key} = ${counters[key]}, below the floor of ${floor}. The docs walk or ` + + 'the registry derivation is broken, and an empty comparison would pass while asserting nothing.', + ); + process.exit(1); + } + } + + console.log( + `Scanned ${counters.files} mdx file(s), ${counters.codeBlocks} code block(s), ` + + `${counters.typeSites} \`type\` literal(s) against ${counters.registryKeys} registered key(s) ` + + `derived from ${counters.sourceFiles} source file(s) (${counters.resolved} resolved call site(s), ` + + `${counters.indirect} indirect, ${counters.open} open): ` + + `${counters.registered} registered, ${counters.exempted} exempted.`, + ); + + if (findings.length === 0) { + console.log('✅ Every documented component type is registered.'); + process.exit(0); + } + + console.error(`\n❌ ${findings.length} problem(s):\n`); + for (const finding of findings) { + if (finding.reason === 'unregistered-doc-type') { + console.error(` ${finding.site} [${finding.reason}] type '${finding.value}' (${finding.lang})`); + console.error(` ${finding.text}`); + continue; + } + console.error(` ${finding.site} [${finding.reason}] ${finding.detail}`); + } + for (const reason of Object.keys(HINTS)) { + if (findings.some((f) => f.reason === reason)) console.error(`\n${reason}: ${HINTS[reason]}`); + } + console.error( + '\nThe teaching surface is not rendered by anything, so a wrong type there is invisible to every ' + + 'other check\nin the repo — which is why this is a gate and not a review note. See the header of ' + + 'scripts/check-doc-component-types.mjs (objectui#4823).', + ); + process.exit(1); +}