diff --git a/scripts/__tests__/check-doc-component-types.test.ts b/scripts/__tests__/check-doc-component-types.test.ts index 2070b0c3fc..9c891d6e05 100644 --- a/scripts/__tests__/check-doc-component-types.test.ts +++ b/scripts/__tests__/check-doc-component-types.test.ts @@ -8,7 +8,13 @@ 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'; +import { + analyze, + deriveRegistryKeys, + PACKAGE_READMES, + packageReadmePages, + scanDocs, +} from '../check-doc-component-types.mjs'; import { blank, scanSource } from '../js-comment-mask.mjs'; /** @@ -1728,13 +1734,24 @@ describe('objectui#5106 — plugin key tables are judged, on both halves', () => expect((findings as Finding[]).filter((f) => f.reason.includes('key-table'))).toEqual([]); }); - it('really reads the four plugin pages, not just some table somewhere', () => { + /** + * ⚠️ This pin named FOUR pages until objectui#8115 widened the walk onto + * `packages/NAME/README.md`, which brought a FIFTH key table with it — + * `packages/plugin-dashboard/README.md` had been carrying one, under the same + * header, outside every gate's reach. That is the widening working rather than + * a pin to relax: the new table's rows are judged on both halves like all the + * others, and the repo-level assertion above still reads + * `keyTableKeys === keyTableRegistered` with nothing exempted, because + * `DOC_TYPE_EXEMPTIONS` deliberately does not apply to table rows. + */ + it('really reads the plugin pages that carry a key table, 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', + 'packages/plugin-dashboard/README.md', ]); expect(tableRows.map((r) => r.namespaced)).toContain('`plugin-dashboard:dashboard`'); }); @@ -1917,6 +1934,167 @@ describe('objectui#7115 — the root README is inside the scan surface', () => { }); }); +/** + * objectui#7896 / objectui#8115 — the package READMEs are inside the scan surface. + * + * The same geometry objectui#7115 closed one directory up, and the same four ways + * it can quietly stop being real. A package README ships to npm inside that + * package's `files` list, and its `type` literals were read TWICE — by + * `check-doc-snippet-types` (it compiles their `ts` fences) and by + * `check-doc-fence-languages` (it labels every fence in them) — and judged by + * NOTHING, because the gate that asks whether a `type` names a component that + * exists walked past the whole tree. + * + * ⚠️ The measurement that made it a defect rather than a preference is the one + * pinned first: before the leg, a mutated component `type` in one of those files + * left this gate at `EXIT=0` with counters BYTE-IDENTICAL to the unmutated run, + * while the same mutation in the root `README.md` gave `EXIT=1`. Identical + * counters are the proof a file is outside the scan population altogether rather + * than judged and forgiven — which is why "the walk reaches it" and "the + * judgement applies to it" are two separate pins here, as they are for the root + * page above. + */ +describe('objectui#7896 — every package README is inside the scan surface', () => { + it('the leg is spelled as a stopping place, and it does not descend', () => { + // `recursive` is not a member here, and that is the claim: this leg stops at + // each package's own root. The sibling gate that DOES walk below a package + // root spells that as its own separate leg, for the reason its docblock + // states — under pnpm a recursive walk follows `node_modules` symlinks back + // into sibling packages and does not terminate. + expect(PACKAGE_READMES).toEqual({ dir: 'packages', name: 'README.md' }); + const collected = packageReadmePages(repoRoot).map((abs: string) => + path.relative(repoRoot, abs).split(path.sep).join('/'), + ); + expect(collected.length, 'the leg collected nothing, so every pin below is vacuous').toBeGreaterThan(10); + for (const rel of collected) expect(rel).toMatch(/^packages\/[^/]+\/README\.md$/); + }); + + it('the walk really reaches them — the widening, pinned', () => { + const { sites } = scanDocs(repoRoot); + const files = new Set(sites.map((s: { file: string }) => s.file)); + const reached = [...files].filter((f) => /^packages\/[^/]+\/README\.md$/.test(f)); + expect( + reached.length, + 'no `type` literal was scanned in any package README — the collector narrowed back', + ).toBeGreaterThan(5); + }); + + it('judges a package README by the same rule, so an unregistered type there is a finding', () => { + // Reaching the file and JUDGING it are two different things, and a widening + // that only did the first would pass the assertion above. Over a throwaway + // tree, with a clean sibling page so a leg that stops being walked costs a + // finding rather than nothing. + const findings = withTree((write) => { + write('packages/demo/src/index.tsx', "ComponentRegistry.register('statistic', S, { namespace: 'ui' });\n"); + write('packages/demo/README.md', ['```json', '{ "type": "stat-card" }', '```'].join('\n')); + write('packages/clean/README.md', ['```json', '{ "type": "statistic" }', '```'].join('\n')); + }, (dir) => analyze(dir, BARE).findings as Finding[]); + expect(findings.map((f) => `${f.reason} :: ${f.site} :: ${f.value ?? ''}`)).toEqual([ + 'unregistered-doc-type :: packages/demo/README.md:2 :: stat-card', + ]); + }); + + it('the counters MOVE for a package README — the byte-identical reading is what the leg removes', () => { + // The shape of the pre-leg defect, stated as an assertion rather than as + // prose: a page on this leg contributes to the population, so a tree that + // holds one and a tree that does not cannot print the same numbers. Before + // the leg they did, which is what proved the file was never in the scan. + const counted = (extra: boolean) => + withTree((write) => { + write('packages/demo/src/index.tsx', "ComponentRegistry.register('statistic', S, { namespace: 'ui' });\n"); + if (extra) write('packages/demo/README.md', ['```json', '{ "type": "statistic" }', '```'].join('\n')); + }, (dir) => analyze(dir, BARE).counters as Record); + const without = counted(false); + const with_ = counted(true); + expect(with_.files).toBe(without.files + 1); + expect(with_.codeBlocks).toBe(without.codeBlocks + 1); + expect(with_.typeSites).toBe(without.typeSites + 1); + expect(with_.registered).toBe(without.registered + 1); + }); + + it('refuses to run when the package-README leg collects nothing — the silent shrink', () => { + // The fourth way, and the one that looks healthiest: the leg is collected by + // WALK rather than by name, so it cannot dangle the way a ROOT_PAGES entry + // does. It just returns fewer files, and every count stays plausible. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-doc-component-types-pkgleg-')); + try { + fs.writeFileSync(path.join(dir, 'README.md'), '# Root\n'); + const run = spawnSync(process.execPath, [path.join(repoRoot, SCRIPT), '--root', dir], { + encoding: 'utf8', + }); + expect(run.status, 'an empty package-README leg must fail the run, not shrink the surface').toBe(1); + expect(run.stderr).toContain('packages/*/README.md'); + expect(run.stderr).toContain('objectui#7896'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + /** + * ⛔ The exemption table is not a switch for turning this first run green + * (objectui#8115, ruling `5556586208`). Every entry it carries for this leg is + * therefore held to the shape the table's own docblock demands: a reason that + * names the vocabulary AND where that vocabulary is declared. + * + * The pin is a floor on substance rather than an equality on wording — an + * equality would freeze the prose and say nothing about whether it teaches. + * What it can say mechanically is that no entry on this leg is the "not a + * component" one-liner the ruling rejected by name, and that each cites + * something a reader can go and read. + */ + it('every package-README exemption names a vocabulary and a declaration site', () => { + const source = fs.readFileSync(path.join(repoRoot, SCRIPT), 'utf8'); + // Bounded at BOTH ends. Read to end-of-file and this pin walks out of the + // table and into unrelated prose, which is how its first draft failed on a + // diagnostic string from the registry derivation. + const tableStart = source.indexOf('const DOC_TYPE_EXEMPTIONS = {'); + expect(tableStart, 'DOC_TYPE_EXEMPTIONS moved or was renamed').toBeGreaterThan(-1); + const table = source.slice(tableStart, source.indexOf('\n};\n', tableStart)); + const marker = table.indexOf("\n // ── `packages/NAME/README.md` (objectui#8115)"); + expect(marker, 'the packages group marker moved — re-point this pin').toBeGreaterThan(-1); + + const { sites } = scanDocs(repoRoot); + const { findings, counters } = analyze(repoRoot); + // Non-vacuity, the way this card's brief framed it: the sites are IN the + // scan, and the exempted counter accounts for every one of them that the + // registry does not. + const onLeg = sites.filter((s: { file: string }) => /^packages\/[^/]+\/README\.md$/.test(s.file)); + expect(onLeg.length).toBeGreaterThan(100); + expect( + findings.filter((f: Finding) => f.reason === 'stale-exemption'), + 'an exemption on this leg outlived its site', + ).toEqual([]); + expect(counters.exempted).toBeGreaterThan(0); + + // And the entries themselves teach. `not a component` alone is the failed + // form the ruling names; every reason must also point at source. + const group = table.slice(marker); + const reasons = [...group.matchAll(/^\s{4}'?[\w-]+'?:\n?((?:\s+'[\s\S]*?',)|(?:\s*'[^\n]*',))$/gm)].map( + (m) => m[1], + ); + expect(reasons.length, 'no exemption reasons were read — re-point this pin').toBeGreaterThan(20); + for (const reason of reasons) { + // ⛔ Not a length floor. A character count is a proxy for teaching, and the + // first draft of this pin failed a perfectly good sibling entry + // (`GanttLinkType`'s `ss`) for being 119 characters while passing any + // padded stub of 121. What the ruling actually rejects is a reason that + // names nothing a reader can go and read, so that is what is asserted: + // every reason cites at least two backticked things — the carrier the + // value hangs off and the vocabulary it belongs to — and every reason + // either names a DECLARING symbol or defers explicitly to the sibling + // entry that does. + const ticked = [...reason.matchAll(/`[^`]+`/g)].map((m) => m[0]); + expect(ticked.length, `this exemption cites nothing a reader can open: ${reason}`).toBeGreaterThanOrEqual(2); + const declares = /`[A-Z][A-Za-z]*(?:Schema|Type|TypeName|Action|Config|Name)[`.[]/.test(reason); + const defers = /Same vocabulary as/.test(reason); + expect( + declares || defers, + `this exemption names no declaring symbol and defers to no sibling entry: ${reason}`, + ).toBe(true); + } + }); +}); + 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'); diff --git a/scripts/__tests__/check-doc-expression-carriage.test.ts b/scripts/__tests__/check-doc-expression-carriage.test.ts index 75431269c2..1585e115ab 100644 --- a/scripts/__tests__/check-doc-expression-carriage.test.ts +++ b/scripts/__tests__/check-doc-expression-carriage.test.ts @@ -16,6 +16,8 @@ import { JSON_FENCE_LANGUAGES, listDocuments, loadCarriage, + PACKAGE_READMES, + packageReadmePages, parseFence, parseFenceDialect, RENDERER_SOURCE, @@ -29,6 +31,8 @@ import { import { APP_DOCS as TYPES_APP_DOCS, appDocsDirs as typesAppDocsDirs, + PACKAGE_READMES as TYPES_PACKAGE_READMES, + packageReadmePages as typesPackageReadmePages, ROOT_PAGES as TYPES_ROOT_PAGES, } from '../check-doc-component-types.mjs'; @@ -318,6 +322,9 @@ describe('check-doc-expression-carriage: the scan surface is check:doc-types’, expect(APP_DOCS).toBe(TYPES_APP_DOCS); expect(ROOT_PAGES).toBe(TYPES_ROOT_PAGES); expect(appDocsDirs).toBe(typesAppDocsDirs); + // objectui#7896's fourth leg, landed by objectui#8115. + expect(PACKAGE_READMES).toBe(TYPES_PACKAGE_READMES); + expect(packageReadmePages).toBe(typesPackageReadmePages); }); /** @@ -351,6 +358,14 @@ describe('check-doc-expression-carriage: the scan surface is check:doc-types’, }; const expected = walk(path.join(ROOT, DOCS_ROOT)); for (const dir of typesAppDocsDirs(ROOT)) expected.push(...walk(dir)); + // ⚠️ Rebuilt from the leg's own enumerator, in the slot the gate appends it + // in. This is the comparison objectui#7896's leg had to reach BOTH sides of: + // the pin is CONSTANTS versus this census's walk, so a leg added to + // `check-doc-component-types.mjs` alone would move neither and stay green + // while the two surfaces diverged. + expected.push( + ...typesPackageReadmePages(ROOT).map((abs: string) => path.relative(ROOT, abs).split(path.sep).join('/')), + ); expected.push(...TYPES_ROOT_PAGES.filter((name) => fs.existsSync(path.join(ROOT, name)))); expect([...census.documents].sort()).toEqual([...expected].sort()); @@ -361,7 +376,7 @@ describe('check-doc-expression-carriage: the scan surface is check:doc-types’, * resolved to the same empty set. A leg that reaches nothing is a surface that * shrank silently, which is objectui#7115's defect exactly. */ - it('reaches every leg of the walk: the guide tree, the app docs trees, the root pages', async () => { + it('reaches every leg of the walk: the guide tree, the app docs trees, the package READMEs, the root pages', async () => { const census = analyze(ROOT, { channels: deriveChannels(ROOT), carriage: await loadCarriage() }); const documents: string[] = census.documents; @@ -373,12 +388,19 @@ describe('check-doc-expression-carriage: the scan surface is check:doc-types’, expect(documents.some((f) => f.startsWith(`${dir}/`)), `${dir} contributed no document`).toBe(true); } + const packageReadmes = documents.filter((f) => /^packages\/[^/]+\/README\.md$/.test(f)); + expect( + packageReadmes.length, + 'no package README reached this census — objectui#7896’s leg stopped being walked', + ).toBeGreaterThan(10); + for (const name of ROOT_PAGES) expect(documents).toContain(name); }); it('names the surface it walked in the summary it prints', () => { expect(SURFACE_LABEL).toContain(DOCS_ROOT); expect(SURFACE_LABEL).toContain(`${APP_DOCS.dir}/*/${APP_DOCS.subdir}`); + expect(SURFACE_LABEL).toContain(`${PACKAGE_READMES.dir}/*/${PACKAGE_READMES.name}`); for (const name of ROOT_PAGES) expect(SURFACE_LABEL).toContain(name); // ⛔ No angle-bracket placeholder: this line is quoted into pull-request bodies // and issue comments, and GitHub's body sanitizer eats tag-shaped fragments. diff --git a/scripts/__tests__/check-doc-fence-languages.test.ts b/scripts/__tests__/check-doc-fence-languages.test.ts index 0aed9ef58e..b590918f8b 100644 --- a/scripts/__tests__/check-doc-fence-languages.test.ts +++ b/scripts/__tests__/check-doc-fence-languages.test.ts @@ -28,6 +28,8 @@ import { } from '../check-doc-snippet-types.mjs'; import { APP_DOCS as COMPONENT_APP_DOCS, + PACKAGE_READMES as COMPONENT_PACKAGE_READMES, + packageReadmePages as componentPackageReadmes, ROOT_PAGES as COMPONENT_ROOT_PAGES, } from '../check-doc-component-types.mjs'; @@ -313,8 +315,14 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types * `README.md` — the two gates its ruling named — and this file went red, * because a third gate is coupled to that surface by construction. The lists * are equal again, but list equality alone would not have said WHERE they - * diverged, and `check-doc-component-types`'s surface is deliberately narrower - * (it does not walk the package READMEs), so it cannot join that comparison. + * diverged. + * + * ⚠️ This comment used to add that `check-doc-component-types`'s surface is + * "deliberately narrower (it does not walk the package READMEs), so it cannot + * join that comparison". That stopped being true when objectui#7896's fourth + * leg landed (objectui#8115): all three gates now walk + * `packages/NAME/README.md`, and the leg gets its own cross-gate pin below + * rather than being left as a sentence. * * This is the piece all three DO share. Each carries its own copy for its own * install-free reason; comparing the copies is what keeps "copy freely" honest. @@ -330,6 +338,38 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types expect(fenceDocuments(ROOT)).toContain('README.md'); }); + /** + * objectui#7896 / objectui#8115 — the `packages/NAME/README.md` half, the leg + * that made the third gate joinable here at all. + * + * This gate and the snippet gate had walked those READMEs all along; + * `check-doc-component-types` — the one that asks whether a `type` literal + * names a component that EXISTS — walked past them, so their type literals + * were read twice and judged never. The pin is written as an equality against + * the population THIS gate already collects, because the two enumerations come + * from different files and neither is the other read twice. + * + * ⛔ Not folded into the whole-document equality above: that comparison is + * between this gate and the snippet gate, whose surface still carries four legs + * this one does not. A named leg is what says WHERE two walks agree. + */ + it('all three doc gates now walk packages/NAME/README.md — objectui#7896’s fourth leg', () => { + expect(COMPONENT_PACKAGE_READMES).toEqual({ dir: 'packages', name: 'README.md' }); + const componentLeg = componentPackageReadmes(ROOT) + .map((abs: string) => path.relative(ROOT, abs).split(path.sep).join('/')) + .sort(); + // Non-vacuous: an empty leg would make every comparison below pass. + expect(componentLeg.length, 'the component gate collected no package README').toBeGreaterThan(10); + const fenceLeg = fenceDocuments(ROOT) + .filter((f: string) => /^packages\/[^/]+\/README\.md$/.test(f)) + .sort(); + expect(componentLeg).toEqual(fenceLeg); + const snippetLeg = snippetDocuments(ROOT) + .filter((f: string) => /^packages\/[^/]+\/README\.md$/.test(f)) + .sort(); + expect(componentLeg).toEqual(snippetLeg); + }); + /** * objectui#6600 — the `apps//docs/**` half of the surface. * diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index c1730247e4..b0f9756f4f 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -298,12 +298,16 @@ const scriptDir = dirname(fileURLToPath(import.meta.url)); // ── Configuration ──────────────────────────────────────────────────────────── /** Where the teaching prose lives. This gate walks `content/docs`, every - * `apps//docs/**` tree (objectui#6600) and the root pages named below, and - * nothing else: not `skills/**`, not the package READMEs - * (`check-doc-snippet-types.mjs` covers those for its own question), not - * `docs/**`. The full ownership map for all three doc gates — including the - * trees NO gate reads, and why `skills/**` is deliberately not one of them — is - * stated once in `check-doc-snippet-types.mjs`, beside `UNGATED_DOCS`. */ + * `apps//docs/**` tree (objectui#6600), every `packages/NAME/README.md` + * (objectui#7896's fourth leg, landed by objectui#8115) and the root pages named + * below, and nothing else: not `skills/**`, not `docs/**`. ⚠️ This sentence used + * to name the package READMEs among the trees this gate does NOT walk, and + * pointed at `check-doc-snippet-types.mjs` as covering them — that gate covers + * them for ITS question (it compiles their `ts` fences), which is not this one, + * and the gap between the two questions is exactly what objectui#7896 was filed + * for. The full ownership map for all three doc gates — including the trees NO + * gate reads, and why `skills/**` is deliberately not one of them — is stated + * once in `check-doc-snippet-types.mjs`, beside `UNGATED_DOCS`. */ const DOCS_ROOT = 'content/docs'; /** @@ -350,79 +354,107 @@ export function appDocsDirs(root) { } /** - * `packages/NAME/README.md` — MEASURED, DELIBERATELY NOT WALKED (objectui#7896). - * - * This gate does not walk the package READMEs, while its two sibling doc gates - * both do: `check-doc-snippet-types.mjs` compiles their `ts` / `tsx` / - * `typescript` fences, and `check-doc-fence-languages.mjs` labels every fence in - * them (`join(pkgDir, entry, 'README.md')`). So a package README's `type` - * literals are read twice and judged never — objectui#7115's geometry rebuilt one - * directory over, on a surface that ships to npm inside each package's `files`. - * objectui#7896 measured the gap two ways, and it still reproduces on - * `59a3a233d`: a component `type` mutated in `packages/app-shell/README.md` - * leaves this gate at `EXIT=0` with byte-identical counters, while the same - * mutation in the root `README.md` gives `EXIT=1` at `README.md:272`. Identical - * counters are the proof the file is not in the scan population at all, rather - * than judged and forgiven. - * - * ⚠️ Why the leg is NOT here yet, and what has to happen first. The `domain:ui` - * ruling on objectui#7896 orders this move census-first: a change that moves a - * gate's scan population reports before it enforces, and the widening may land in - * the same pull request ONLY if the census reads zero. It does not. Re-derived on - * `c30026715` with this gate's own `deriveRegistryKeys`, fence walker and `type` - * matcher over all 39 `packages/NAME/README.md`, the census read - * **26 unregistered `type` literals across 8 files**, plus 4 blind spots (four - * unquoted YAML scalars in `packages/data-objectstack/README.md:603-606`, an - * object field-type vocabulary the same-line string-literal matcher cannot read). - * That reading is DATED, not a live claim: it belongs to the commit named above, - * and the instrument named beside it is how the next reader re-derives it rather - * than inheriting it. It is written that way because the sentence it replaces was - * not — it said `12 files`, a number contradicted by the per-file table of the - * very census PR that landed it (#8111, 8 files with a non-zero column), and it - * had already been quoted onward into objectui#8115's brief, which instructs its - * implementer not to re-derive the census. A file COUNT is what a later reader - * uses to judge whether a fix is complete, so `8 of 12` reads as unfinished work - * that does not exist (objectui#8484). This is the same class objectui#7448 ruled - * on one file over — a count in a header that nothing re-measures will rot — and - * the same remedy `check-doc-fence-languages.mjs` already uses for its own census - * (`273537957`: 83 files, 105 blocks): anchor the reading to a commit instead of - * restating a bare number and restarting the clock. - * Landing the leg today would turn `main` red on 26 sites this card is not - * authorised to touch. Twenty-five of the 26 are other vocabularies with real declaration sites - * — dashboard widget kinds (`DashboardRenderer.tsx`), flow-graph node kinds, - * gesture kinds, Gantt task and dependency kinds, grid selection modes and - * summary aggregates, report kinds, view actions and filter kinds — i.e. - * candidate `DOC_TYPE_EXEMPTIONS` entries, each owed the reason that table - * demands. One is a real defect of the shape this gate exists to catch: - * `packages/plugin-detail/README.md:168` teaches a detail tab whose - * `content.type` is `activity-timeline`, and that content goes through - * `SchemaRenderer` (`DetailTabs.tsx:72`) while nothing registers that key — the - * registry's `OBJUI-001` panel, the same failure as the `line-chart` widget - * objectui#7896 recorded in `packages/plugin-dashboard/README.md`. - * - * ⛔ Do NOT reach for the exemption table to make a first run of the widened walk - * green. `DOC_TYPE_EXEMPTIONS` records rulings, not a switch for turning red into - * green, and stuffing it here would bury the one real defect among 25 entries - * nobody read. - * - * ⚠️ And when the leg does land, it is not the precedent the ⛔ above refuses. - * That ⛔ refuses widening onto an ARBITRARY unscanned tree; - * `packages/NAME/README.md` is not one, it is the surface this gate's own two - * siblings already walk, so the move aligns the third gate to its family rather - * than reaching into a new tree. The distinction is the whole of the ruling, and - * the ⛔ stays where it is. - * - * Two things the implementing change owes, recorded here so they are not - * rediscovered: the leg belongs BEFORE the root pages in `scanDocs`, which is the - * slot the two sibling walks append it in and what keeps the three lists - * comparable element by element; and `check-doc-expression-carriage.mjs` IMPORTS - * this file's surface constants and pins its own walk as an EQUALITY against a - * walk rebuilt from them — that pin compares against the CONSTANTS, not against - * this gate's actual walk, so a fourth leg added here alone leaves the pin GREEN - * while the two surfaces silently diverge, which is objectui#7115's shape again. - * The carriage census must take the same leg, and its `SURFACE_LABEL` test - * enumerates every leg by name. + * `packages/NAME/README.md` — WALKED (objectui#7896's fourth leg, objectui#8115). + * + * A package README is a teaching surface that ships to npm inside that package's + * `files` list, and until this leg landed its `type` literals were read TWICE and + * judged NEVER: `check-doc-snippet-types.mjs` compiles their `ts` / `tsx` / + * `typescript` fences and `check-doc-fence-languages.mjs` labels every fence in + * them, while this gate — the only one that asks whether a `type` names a + * component that exists — walked past them. That is objectui#7115's geometry + * rebuilt one directory over, and objectui#7896 measured it twice the way a + * coverage claim has to be measured: a component `type` mutated inside + * `packages/app-shell/README.md` left this gate at `EXIT=0` with counters + * BYTE-IDENTICAL to the unmutated run, while the same mutation in the root + * `README.md` gave `EXIT=1`. Identical counters are the proof a file is outside + * the scan population altogether rather than judged and forgiven, and moving them + * is what this leg is owed. The same pair re-run with this leg in place is the + * evidence the widening is real; the pins below `objectui#7896` in + * `check-doc-component-types.test.ts` are where it is kept re-derivable. + * + * ⚠️ The leg is deliberately NOT recursive and stops at each package's own root. + * `packages/` is not an authored tree the way `content/docs` is — under pnpm every + * package carries a `node_modules/` of SYMLINKS back into its workspace siblings, + * so a recursive walk that follows them does not terminate. The sibling gate that + * does walk below a package root states the measured numbers beside its own + * `NESTED_PACKAGE_READMES`; this leg avoids the question structurally by not + * descending at all, which also keeps it the same shape as the surface + * `check-doc-snippet-types.mjs` collects in its own package-README leg. + * + * ## ⚠️ Why the leg could not land before objectui#8115, and what came with it + * + * The `domain:ui` ruling on objectui#7896 ordered this move census-first: a change + * that moves a gate's scan population reports before it enforces, and the widening + * may land in the same pull request ONLY if the census reads zero. It did not, and + * it does not now — this leg brings unregistered `type` literals in package + * READMEs into a walk that judges them, and every one of them is another + * vocabulary with a declaration site in source. Those are the + * `DOC_TYPE_EXEMPTIONS` entries keyed under `packages/` below, ruled one at a time + * under objectui#8115 (triage `5582405961`, option A; reading A₁ in `5594701696`: + * the ruled entries land WITH the leg, in one change, because an entry whose + * `(file, value)` the walk never reaches fails as `stale-exemption` — so there is + * no order in which they could land first). + * + * ⛔ The exemption table is not a switch for turning this first run green, and + * that fence is the reason each entry below names its vocabulary AND where that + * vocabulary is declared: stuffing the table here would bury a real defect among + * entries nobody read. Two sites of the original census were re-classified as + * DEFECTS rather than exempted and fixed on their own cards — the + * `activity-timeline` detail tab (objectui#8114) and the flow node type `action` + * (objectui#8483) — which is what this fence is for. + * + * ⚠️ And the census is DATED wherever it is written down. The reading this + * docblock used to carry (26 sites across 8 files, re-derived on `c30026715`) was + * already stale by the time the leg landed, because objectui#8114's fix removed + * one of the sites it counted. ⛔ Do not inherit a count from this file or from + * objectui#8115's card — re-derive it with this gate's own `deriveRegistryKeys`, + * its fence walker and its `type` matcher over `packageReadmePages`, and anchor + * whatever you write to the commit you measured it on. A file COUNT is what a + * later reader uses to judge whether a fix is complete, so a rotted one reads as + * unfinished work that does not exist (objectui#8484, and objectui#7448 one file + * over). + * + * ⚠️ What this is NOT: a precedent for widening onto an arbitrary unscanned tree, + * which is what the ⛔ beside `APP_DOCS` refuses. `packages/NAME/README.md` is the + * surface this gate's own two siblings already walk, so the move aligns the third + * gate to its family rather than reaching into a new tree. + * + * ## The two couplings this leg owes, and where they are kept + * + * The leg sits BEFORE the root pages in `scanDocs`, which is the slot the two + * sibling walks append it in and what keeps the three document lists comparable + * element by element. And `check-doc-expression-carriage.mjs` IMPORTS this file's + * surface constants and pins its own walk as an EQUALITY against a walk rebuilt + * from them — that pin compares against the CONSTANTS, not against this gate's + * actual walk, so a leg added here ALONE would leave the pin GREEN while the two + * surfaces silently diverge, which is objectui#7115's shape a third time. That + * census takes the same leg through `PACKAGE_READMES` / `packageReadmePages` + * below, and its `SURFACE_LABEL` names it. */ +export const PACKAGE_READMES = { dir: 'packages', name: 'README.md' }; + +/** + * Every package's own `README.md`, in a stable order. + * + * An absent `packages/` yields `[]` so a throwaway fixture tree stays scannable, + * exactly as `appDocsDirs` and the root-page leg do. A real run cannot rely on + * that: the CLI refuses to publish a verdict when this leg collects nothing, + * because a leg that reaches no file is a surface that shrank in silence, which + * is the defect this whole widening exists to close. + * + * Exported so the carriage census asks this gate what the leg contains instead of + * rebuilding it — one walk, not two arrays that agree today. + */ +export function packageReadmePages(root) { + const pkgDir = join(root, PACKAGE_READMES.dir); + if (!existsSync(pkgDir)) return []; + const out = []; + for (const entry of readdirSync(pkgDir).sort()) { + const readme = join(pkgDir, entry, PACKAGE_READMES.name); + if (existsSync(readme)) out.push(readme); + } + return out; +} /** * Pages at the repository ROOT that join the walk by name. @@ -921,6 +953,174 @@ const DOC_TYPE_EXEMPTIONS = { 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.', }, + + // ── `packages/NAME/README.md` (objectui#8115) ──────────────────────────────── + // + // These arrived WITH the fourth leg (`PACKAGE_READMES` above) because the gate + // admits no other order: an entry whose (file, value) the walk never reaches + // fails as `stale-exemption`, so none of them could have landed ahead of the + // walk that reaches it. Ruled one at a time against the source that declares + // each vocabulary, ⛔ not transcribed as a group from the census that found + // them — that census's vocabulary column was measured WRONG for + // `plugin-report`'s `bar` (a chart type, not a report kind) and cited weaker + // declaration sites than exist for the gesture kinds. ⛔ And two of the sites it + // counted are deliberately NOT here: they were re-classified as DEFECTS and + // fixed on their own cards (objectui#8114's `activity-timeline` detail tab, + // objectui#8483's flow node type `action`). That absence is what the table is + // worth — it is a record of rulings, ⛔ never a switch for turning a first run + // of a widened walk green. + + 'packages/app-shell/README.md': { + // TWO vocabularies four lines apart in ONE `flows/renewal_reminder.json` + // block — the flow DOCUMENT's `type` and its NODES' `type` are different + // enums. That is the case (file, value) keying exists for: a whole-file + // exemption here would silence both, and a value-only one would let any page + // in the tree teach `start` as a component. + autolaunched: + 'FlowSchema `type` — the flow PROCESS-KIND enum (`autolaunched` / `record_change` / `schedule` ' + + '/ `screen` / `api`), declared on `FlowSchema` in @objectstack/spec\'s automation flow schema ' + + 'and seeded as this repo\'s canonical default by the metadata-admin flow anchor\'s ' + + '`createDefaults`. It types the DOCUMENT; the four entries below type the nodes inside it.', + start: + 'Flow-graph node kind under `nodes[].type` — a member of `FlowNodeAction` (@objectstack/spec\'s ' + + 'automation flow schema). `start` is one of the two STRUCTURAL kinds the engine handles without ' + + 'a registered executor (`FLOW_STRUCTURAL_NODE_TYPES`: the start sentinel and the end ' + + 'terminator), which is why it is a member of the enum while being absent from the editor ' + + 'palette `NODE_PALETTE` in app-shell\'s flow canvas parts. ⚠️ Re-check this entry against the ' + + 'ENUM, not against the palette: absence from BOTH is what made the flow node type `action` a ' + + 'DEFECT rather than an exemption (objectui#8483).', + decision: + 'Flow-graph node kind under `nodes[].type` — a member of `FlowNodeAction` (@objectstack/spec\'s ' + + 'automation flow schema), and the `Logic` branch node the editor palette `NODE_PALETTE` offers ' + + 'as "Decision". Not a node type in the SDUI sense — a flow graph is data the flow engine ' + + 'executes, not a component tree `SchemaRenderer` walks.', + notify: + 'Flow-graph node kind under `nodes[].type` — a member of `FlowNodeAction` (@objectstack/spec\'s ' + + 'automation flow schema), offered by `NODE_PALETTE` under `Integration`. Same vocabulary as ' + + '`decision` above.', + end: + 'Flow-graph node kind under `nodes[].type` — a member of `FlowNodeAction` (@objectstack/spec\'s ' + + 'automation flow schema) and the second of the two STRUCTURAL kinds (`FLOW_STRUCTURAL_NODE_TYPES`), ' + + 'the terminator `start` above is the sentinel for. Same vocabulary as `decision` above.', + }, + 'packages/mobile/README.md': { + 'swipe-left': + '`useGesture` gesture kind — a member of `GestureType`, the direction-fused touch vocabulary ' + + 'declared in `@object-ui/types`\' `mobile` module, which this package owns outright since ' + + '@objectstack/spec deleted its `ui/touch` surface (objectui#3363). ⚠️ The census that found ' + + 'this site cited the `useGesture` / `useSpecGesture` hook pair as the declaration; the hooks ' + + 'CONSUME the union, they do not declare it, and the union is the thing to re-check.', + 'swipe-right': + '`useGesture` gesture kind — a member of `GestureType` (`@object-ui/types`\' `mobile` module). ' + + 'Same vocabulary as `swipe-left` above; the two are separate entries because the table is keyed ' + + 'by value, which is what keeps a ruling about one spelling from covering another.', + pinch: + '`useSpecGesture` gesture kind under `SpecGestureConfig.type` — a member of `SpecGestureType`, ' + + 'which is derived from the `SPEC_GESTURE_TYPES` runtime witness in `@object-ui/types`\' `mobile` ' + + 'module. ⚠️ A DIFFERENT union from the `GestureType` above, deliberately kept apart under the ' + + '`Spec` prefix: it is the retired @objectstack/spec touch vocabulary this package now owns, and ' + + '`pinch` happens to be a member of both. Two vocabularies sharing one spelling is exactly the ' + + 'coincidence a reader of this entry needs told about.', + }, + 'packages/plugin-dashboard/README.md': { + line: + 'Dashboard widget kind under `widgets[].type` — `DashboardWidgetTypeName` (`@object-ui/types`\' ' + + '`complex` module) declares that CLOSED vocabulary, and its spec half flows in BY REFERENCE ' + + 'from `ChartTypeSchema` (@objectstack/spec/ui), where `line` is a member. Not a node type: the ' + + 'node type is `dashboard`, which the enclosing snippet spells. This entry covers TWO sites in ' + + 'this file — the TypeScript `widgets[]` example and the dataset-bound JSONC one — which is the ' + + '(file, value) keying doing its job. Same vocabulary as the ' + + '`content/docs/plugins/plugin-dashboard.mdx` entry above.', + pie: + 'Dashboard widget kind under `widgets[].type` — a member of `ChartTypeSchema` ' + + '(@objectstack/spec/ui) reaching this repo by reference through `DashboardWidgetTypeName`. ' + + 'Same vocabulary as `line` above.', + bar: + 'Dashboard widget kind under `widgets[].type` — a member of `ChartTypeSchema` ' + + '(@objectstack/spec/ui) reaching this repo by reference through `DashboardWidgetTypeName`. ' + + 'Same vocabulary as `line` above, and ⚠️ NOT the same as `packages/plugin-report/README.md`\'s ' + + '`bar`, which is a chart type nested under a report section\'s `chart` — one spelling, two ' + + 'carriers, which is why neither is exempted tree-wide.', + }, + 'packages/plugin-gantt/README.md': { + milestone: + 'Gantt TASK kind under `GanttTask.type` — `GanttTaskType` (`task` / `summary` / `milestone` / ' + + '`group`), declared beside `GanttTask` in this plugin\'s `GanttView` module and normalised from ' + + 'record data by `normalizeTaskType` in `ObjectGantt`. Not a node type: the node type this ' + + 'plugin registers is `object-gantt`.', + fs: + 'Dependency LINK kind under `dependencies[].type` — `GanttLinkType` (`fs` / `ss` / `ff` / `sf`, ' + + 'finish-to-start, start-to-start, finish-to-finish, start-to-finish), declared beside ' + + '`GanttTaskType` in this plugin\'s `GanttView` module and re-declared for the scheduler as ' + + '`SchedLinkType` in its `scheduling` module. ⚠️ A DIFFERENT vocabulary from the task kind above ' + + 'in the same file — `type` here hangs off a dependency entry, not off a task.', + ss: 'Dependency LINK kind under `dependencies[].type` — `GanttLinkType` (start-to-start). Same vocabulary as `fs` above.', + ff: 'Dependency LINK kind under `dependencies[].type` — `GanttLinkType` (finish-to-finish). Same vocabulary as `fs` above.', + sf: 'Dependency LINK kind under `dependencies[].type` — `GanttLinkType` (start-to-finish). Same vocabulary as `fs` above.', + }, + 'packages/plugin-grid/README.md': { + multiple: + 'SelectionConfig mode under `selection.type` — the spec\'s `none` / `single` / `multiple` enum ' + + '(`SelectionConfigSchema`, @objectstack/spec/ui, re-exported as `SelectionConfig` from ' + + '`@object-ui/types`). Not a node type. This entry covers THREE sites in this file, one per ' + + 'grid example. Same vocabulary as the `content/docs/plugins/plugin-grid.mdx` entry above.', + count_unique: + 'Column summary aggregation under `columns[].summary.type` — a member of `ColumnSummarySchema` ' + + '(@objectstack/spec/ui), which `ColumnSummaryConfigSchema` REUSES for the object form so the ' + + 'shorthand (`summary: \'sum\'`) and the object form cannot drift into two vocabularies. This ' + + 'plugin dispatches the value in its `useColumnSummary` hook. Not a node type.', + }, + 'packages/plugin-kanban/README.md': { + kanban: + '⚠️ The one entry in this group whose value belongs to the vocabulary this gate JUDGES rather ' + + 'than to another one. It is the bare `kanban` node type key, RETIRED by objectui#8802, spelled ' + + 'deliberately inside a warning comment so a reader who still has it in a document recognises ' + + 'the refusal they will get. The declaration is the tombstone that performs that refusal — ' + + '`RetiredKanbanNodeSchema`, built by `retiredNodeType(\'kanban\', …)` in `@object-ui/types`\' ' + + '`zod/complex.zod.ts` — and `object-kanban`, the live key this plugin registers, is spelled in ' + + 'the same fence a few lines below. ⇒ What re-checks this entry is the retirement itself: ' + + 're-register the bare key and the site passes on the registry branch instead, which reports ' + + 'THIS entry as `stale-exemption`.', + }, + 'packages/plugin-report/README.md': { + matrix: + 'Report kind under `defineReport({ type })` — `SpecReportTypeName` (`tabular` / `summary` / ' + + '`matrix` / `joined`), declared in `@object-ui/types`\' `spec-report` module. Not a node type: ' + + 'a report definition is metadata the report renderer reads, not a component tree. Same ' + + 'vocabulary as the `content/docs/plugins/plugin-report.mdx` entry above.', + joined: + 'Report kind under `defineReport({ type })` — `SpecReportTypeName`, sibling of `matrix` above ' + + 'and the one that carries `blocks[]`. Same vocabulary as `matrix`.', + bar: + '⚠️ NOT the report-kind vocabulary its two neighbours in this file belong to, and the census ' + + 'that found this site filed it as one. It is a CHART TYPE under a report section\'s ' + + '`chart.type` — a member of `ChartTypeSchema` (@objectstack/spec/ui) — classified into a ' + + 'rendering plan by `planReportChart` in this plugin\'s `DatasetReportRenderer`. Recorded as a ' + + 'correction rather than inherited: an entry that mis-names its vocabulary sends the next reader ' + + 'to the wrong declaration and cannot be re-checked.', + }, + 'packages/plugin-view/README.md': { + share: + 'View-action id under `ObjectViewSchema.viewActions[].type` — the union `share` / `settings` / ' + + '`duplicate` / `delete`, declared inline on that member in `@object-ui/types`\' `objectql` ' + + 'module and mirrored on `ViewSwitcherSchema` in its `views` module. Not a node type. Same ' + + 'vocabulary as the `content/docs/components/complex/view-switcher.mdx` entry above.', + kanban: + 'ViewSwitcher `views[].type` — the VIEW-TYPE vocabulary (`ViewType`, `@object-ui/types`\' ' + + '`views` module), which is what a switcher tab names; the nested `schema` on the very same line ' + + 'carries the node type. Needed from objectui#8802, which retired the bare `kanban` NODE type ' + + 'key — until then the value passed by coincidence, the two vocabularies sharing one spelling. ' + + '⛔ The stored / view-type spelling is deliberately NOT retired (`ObjectView` maps a stored ' + + '`kanban` view onto the `object-kanban` node type). ⚠️ A DIFFERENT vocabulary from the retired ' + + 'NODE key exempted in `packages/plugin-kanban/README.md`, with which it shares its spelling and ' + + 'nothing else. Same vocabulary as the two `content/docs` entries above.', + 'date-range': + 'Filter control kind under `FilterUISchema.filters[].type` — the union `text` / `number` / ' + + '`select` / `multi-select` / `date` / `date-range` / `boolean`, declared inline on that member ' + + 'in `@object-ui/types`\' `views` module, alongside the CRUD filter enum that spells ' + + '`date-picker` / `number-range`. Not a node type. Same vocabulary as the ' + + '`content/docs/components/complex/filter-ui.mdx` entry above.', + }, }; /** @@ -1968,6 +2168,10 @@ export function scanDocs(root) { const files = walkFiles(docsDir, isDoc).sort(); // Per-app docs trees (objectui#6600), appended sorted after the content tree. for (const dir of appDocsDirs(root)) files.push(...walkFiles(dir, isDoc).sort()); + // Each package's own README (objectui#7896's fourth leg, landed by objectui#8115), + // in the slot the two sibling walks append it in — BEFORE the root pages — so the + // three gates' document lists stay comparable element by element. + files.push(...packageReadmePages(root)); // Root pages join by name rather than by walk. An absent one is dropped here so // a throwaway fixture tree stays scannable; the CLI refuses to publish a // verdict when one is missing from a real run, which is where that must bite. @@ -2285,6 +2489,22 @@ if (invokedDirectly) { } } + // Same check, same reason, for the package-README leg (objectui#7896): it is + // collected by WALK rather than by name, so a `packages/` that stops yielding + // READMEs does not dangle — it just returns fewer files, and every count this + // gate prints stays plausible while the surface shrinks back to what + // objectui#7896 measured. `FLOORS` cannot tell that apart from a docs edit + // either, because the floor it would trip is a whole-tree one. + if (packageReadmePages(root).length === 0) { + console.error( + `The ${PACKAGE_READMES.dir}/*/${PACKAGE_READMES.name} leg collected 0 file(s) under ${root}. That ` + + "leg is part of this gate's scan surface (objectui#7896), and a leg that reaches no file is a " + + 'surface that shrank in silence — the defect the widening was filed to close. Point the leg at ' + + 'the tree those READMEs live in, or remove it deliberately.', + ); + process.exit(1); + } + let result; try { result = analyze(root); diff --git a/scripts/check-doc-expression-carriage.mjs b/scripts/check-doc-expression-carriage.mjs index 124e36311c..4614974d8c 100644 --- a/scripts/check-doc-expression-carriage.mjs +++ b/scripts/check-doc-expression-carriage.mjs @@ -6,8 +6,9 @@ * * The scan surface is EXACTLY the one `check:doc-types` * (`check-doc-component-types.mjs`) walks — `content/docs`, every - * `apps//docs` tree and the root pages it names — and it is that surface by - * IMPORT rather than by copy; see "The scan surface" below. + * `apps//docs` tree, each package's own `README.md` and the root pages it + * names — and it is that surface by IMPORT rather than by copy; see "The scan + * surface" below. * * Run: node scripts/check-doc-expression-carriage.mjs * node scripts/check-doc-expression-carriage.mjs --list every fence, parsed or not @@ -169,9 +170,19 @@ * four times for as long as the example existed, for exactly one reason — * nothing read the file. * - * So the surface is not re-declared here. `APP_DOCS`, `appDocsDirs` and - * `ROOT_PAGES` are IMPORTED from `check-doc-component-types.mjs`, which makes the - * two walks the same object rather than two arrays a test hopes are equal. The + * So the surface is not re-declared here. `APP_DOCS`, `appDocsDirs`, + * `PACKAGE_READMES`, `packageReadmePages` and `ROOT_PAGES` are IMPORTED from + * `check-doc-component-types.mjs`, which makes the two walks the same object + * rather than arrays a test hopes are equal. + * + * ⚠️ objectui#7896's fourth leg (`packages/NAME/README.md`, landed by + * objectui#8115) is why the import alone is not the whole coupling. The surface + * pin below rebuilds its expected document list FROM THESE CONSTANTS and compares + * it against this census's own walk — so a leg added to that gate and NOT taken + * here moves neither side, and the pin stays GREEN while the two surfaces + * diverge. That is objectui#7115's geometry a third time, rebuilt inside the pin + * written to prevent it. ⇒ A leg added there is owed a leg here, in the same + * change, and `SURFACE_LABEL` is owed its name. The * three gates that carry copies of these constants do so for a stated reason that * does not apply here — importing `check-doc-snippet-types.mjs` pulls in its * `import ts from 'typescript'` at load, and `check-doc-fence-languages`' whole @@ -214,7 +225,13 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { APP_DOCS, appDocsDirs, ROOT_PAGES } from './check-doc-component-types.mjs'; +import { + APP_DOCS, + appDocsDirs, + PACKAGE_READMES, + packageReadmePages, + ROOT_PAGES, +} from './check-doc-component-types.mjs'; import { isEntrypoint } from './invoked-as.mjs'; import { closesFence, openFence } from './markdown-fence-scan.mjs'; @@ -230,11 +247,20 @@ const repoRoot = resolve(scriptDir, '..'); export const DOCS_ROOT = 'content/docs'; /** - * The other two legs of `check:doc-types`' surface, re-exported so this file's + * The other three legs of `check:doc-types`' surface, re-exported so this file's * scan surface is readable from one place and pinnable as ONE object rather than - * as two arrays that agree today. + * as arrays that agree today. + * + * `PACKAGE_READMES` / `packageReadmePages` joined with objectui#7896's fourth leg + * (landed by objectui#8115). ⚠️ That leg is the reason the import form matters + * rather than being a style preference: the surface pin in this file's test + * rebuilds the expected document list FROM THESE CONSTANTS, so a leg added to + * `check-doc-component-types.mjs` alone moves neither side of that comparison and + * the pin stays GREEN while the two walks diverge — objectui#7115's defect, in + * the instrument built to prevent it. Importing the constants is what makes the + * two walks one object; taking the leg here is what makes them one SURFACE. */ -export { APP_DOCS, appDocsDirs, ROOT_PAGES }; +export { APP_DOCS, appDocsDirs, PACKAGE_READMES, packageReadmePages, ROOT_PAGES }; /** * The surface in one phrase, so the printed summary and this file's prose cannot @@ -243,7 +269,9 @@ export { APP_DOCS, appDocsDirs, ROOT_PAGES }; * bodies and issue comments, and GitHub's body sanitizer eats tag-shaped * fragments (AGENTS.md, "GitHub 会改写你写进 issue/PR 正文的字节"). */ -export const SURFACE_LABEL = `${DOCS_ROOT}, ${APP_DOCS.dir}/*/${APP_DOCS.subdir} and ${ROOT_PAGES.join(', ')}`; +export const SURFACE_LABEL = + `${DOCS_ROOT}, ${APP_DOCS.dir}/*/${APP_DOCS.subdir}, ` + + `${PACKAGE_READMES.dir}/*/${PACKAGE_READMES.name} and ${ROOT_PAGES.join(', ')}`; /** The renderer whose evaluation legs define "carried". */ export const RENDERER_SOURCE = 'packages/react/src/SchemaRenderer.tsx'; @@ -356,7 +384,13 @@ function walkFiles(dir, out = []) { /** * Every document on the scan surface, absolute, in a stable order: the guide - * tree, then each `apps//docs` tree, then the root pages by name. + * tree, then each `apps//docs` tree, then each package's own README, then + * the root pages by name. + * + * ⚠️ The order is not cosmetic. The package-README leg sits BEFORE the root pages + * because that is the slot `check-doc-component-types.scanDocs` and the fence + * guard's walk both append it in, and element-by-element comparability across the + * three lists is what their coupling pins compare. * * A root page that does not resolve is DROPPED rather than fatal, which is the * same bargain `check-doc-component-types.scanDocs` strikes and for the same @@ -370,6 +404,7 @@ function walkFiles(dir, out = []) { export function listDocuments(root) { const files = walkFiles(join(root, DOCS_ROOT)); for (const dir of appDocsDirs(root)) files.push(...walkFiles(dir)); + files.push(...packageReadmePages(root)); files.push(...ROOT_PAGES.map((name) => join(root, name)).filter((abs) => existsSync(abs))); return files; } diff --git a/scripts/check-doc-snippet-types.mjs b/scripts/check-doc-snippet-types.mjs index ae23012253..e15e9da4df 100644 --- a/scripts/check-doc-snippet-types.mjs +++ b/scripts/check-doc-snippet-types.mjs @@ -527,7 +527,8 @@ export function appDocsDirs(root) { * Pages at the repository ROOT that join the scan set by name. * * objectui#7115. Between this gate's surface (`content/docs` + the package - * READMEs) and `check-doc-component-types.mjs`'s (`content/docs` alone), the + * READMEs) and `check-doc-component-types.mjs`'s (`content/docs` alone AT THE + * TIME — it has since taken the package-README leg too, objectui#8115), the * root `README.md` fell through: the most-read authored file in the repository — * the GitHub landing page and the npm page for the workspace — was read by NO * doc gate at all. It taught the unregistered type `stat-card` four times in its @@ -821,7 +822,7 @@ const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); * content/docs/** ✓ ✓ ✓ * apps//docs/** ✓ ✓ ✓ objectui#6600 * README.md ✓ ✓ ✓ objectui#7115 - * packages//README.md ✓ ✓ ✗ ships inside `files` + * packages//README.md ✓ ✓ ✓ objectui#7896 / #8115 * nested packages README.md ✗ ✓ ✗ objectui#7308 * docs/*.md (top level only) ✗ ✓ ✗ objectui#7856 card 1 * docs/adr/** ✗ ✓ ✗ objectui#7856 card 2 @@ -847,11 +848,19 @@ const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); * surface is not this card's to do, so the divergence is NAMED and every OTHER * drift between the two walks still fails that pin. * - * `check-doc-component-types` does not read the package READMEs — it asks - * whether a documented `type` literal is a registered component key, and a - * package README teaches its own package's API rather than the schema vocabulary. - * That is the ONE deliberate asymmetry, and it is why that gate cannot join the - * document-list equality pin the other two share. + * ⚠️ This paragraph used to state the opposite of the row above, and the reason + * it gave was measured FALSE rather than merely going stale: it said + * `check-doc-component-types` does not read the package READMEs because "a + * package README teaches its own package's API rather than the schema + * vocabulary". Those READMEs teach `type` literals in the schema vocabulary by + * the hundred, they ship to npm inside each package's `files`, and until + * objectui#7896's fourth leg landed (objectui#8115) their `type` literals were + * read twice by this gate and `check-doc-fence-languages` and judged by neither + * — objectui#7115's geometry one directory over, which that gate's own + * `PACKAGE_READMES` docblock records with the measurement that proved it. + * + * ⇒ The remaining asymmetries are the four rows carrying a `✗`, and they are + * legs THIS gate holds alone rather than a question about vocabularies. * * ⚠️ EVERYTHING ELSE authored in markdown is read by no doc gate at all. That is * a statement of what the roots are today, ⛔ not a plan and not a promise. In diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index f507557c53..4a422e4ce5 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -577,7 +577,16 @@ export const ADJUDICATED = new Map([ [ 'scripts/__tests__/check-doc-component-types.test.ts', { - reads: ['README.md', 'apps/console/docs/UI_IMPROVEMENT_PROPOSAL.md', 'apps/console/docs/deployment.md', 'apps/console/docs/error-tracking.md', 'content/docs/**'], + // `packages/**` joined with objectui#7896's fourth leg (objectui#8115): + // `check:doc-types` now walks every `packages/NAME/README.md`, so this test + // reads them and a change to one must start the shard that runs it. The + // entry is the same shape `check-doc-snippet-types.test.ts` below carries + // for the same surface — the ledger has no `dir/*/name` form, and + // over-declaring is the safe direction here: the failure this whole file + // exists to prevent (objectui#8857) was a pull request changing exactly + // ONE package README, `packages/plugin-dashboard/README.md`, whose shard + // reported success in ten seconds having run nothing. + reads: ['README.md', 'apps/console/docs/UI_IMPROVEMENT_PROPOSAL.md', 'apps/console/docs/deployment.md', 'apps/console/docs/error-tracking.md', 'content/docs/**', 'packages/**'], walker: 'not-markdown: `.github/workflows/*.yml`', }, ], @@ -605,7 +614,10 @@ export const ADJUDICATED = new Map([ [ 'scripts/__tests__/check-doc-expression-carriage.test.ts', { - reads: ['README.md', 'apps/console/docs/UI_IMPROVEMENT_PROPOSAL.md', 'apps/console/docs/deployment.md', 'apps/console/docs/error-tracking.md', 'content/docs/**'], + // `packages/**` for the same reason as the entry above: this census takes + // `check:doc-types`' surface by IMPORT, so objectui#7896's fourth leg + // reached it in the same change (objectui#8115). + reads: ['README.md', 'apps/console/docs/UI_IMPROVEMENT_PROPOSAL.md', 'apps/console/docs/deployment.md', 'apps/console/docs/error-tracking.md', 'content/docs/**', 'packages/**'], walker: 'markdown-tree', }, ],