diff --git a/.changeset/7308-doc-snippet-nested-package-readmes.md b/.changeset/7308-doc-snippet-nested-package-readmes.md new file mode 100644 index 0000000000..495f079050 --- /dev/null +++ b/.changeset/7308-doc-snippet-nested-package-readmes.md @@ -0,0 +1,34 @@ +--- +--- + +Widen `check:doc-snippets`' package-README walk to every depth (objectui#7308). +Tooling only; no package is released by this change. + +The gate collected `packages//README.md` and nothing deeper — and it stated +that one level in its own header's SCAN SURFACE paragraph, so this was a +specification drawn too small rather than drift from one. Four tracked pages were +therefore neither compiled nor named on `UNGATED_DOCS`: "neither covered NOR +declared ungated", objectui#5174's phrase for the state that is strictly worse +than a named debt, arriving one directory down instead of one level up. The +sibling gate `check-doc-links` had already closed the identical hole on the +identical four files (objectui#6026). + +Sized before it was changed, which is the only order in which the numbers are +readings. Re-derived on `9ba7e9c3`: of the 43 tracked files under `packages/` +whose basename is `README.md`, 39 sit at a package root and 4 sit deeper. With +the surface widened and no ledger row yet written, those 4 documents bring 20 +`ts`/`tsx` blocks, 13 of which fail — 3 in the syntax phase, 10 in the semantic +phase — for 37 diagnostics. + +So the ledger GROWS, by three rows carrying those measured counts and what would +have to change on each page. It grows by three and not four because +`packages/plugin-gantt/docs/verification/README.md` holds no `ts`/`tsx` block at +all: it joins the covered tier at zero blocks, and a row naming it would fail the +gate's own re-derivation as a stale entry. Leaving any of the four outside the +surface to keep the ledger short was refused — that is the defect this card +reported, committed a second time. + +One page was worth the widening on its own: `packages/core/src/adapters/README.md` +teaches a custom-adapter template that declares `implements DataSource` while +omitting the interface's required `getObjectSchema`, so a reader who copies it +gets a class that does not satisfy the interface it claims. diff --git a/scripts/__tests__/check-doc-fence-languages.test.ts b/scripts/__tests__/check-doc-fence-languages.test.ts index 68ec775246..0aed9ef58e 100644 --- a/scripts/__tests__/check-doc-fence-languages.test.ts +++ b/scripts/__tests__/check-doc-fence-languages.test.ts @@ -19,6 +19,8 @@ import { AUDIT_DOCS as SNIPPET_AUDIT_DOCS, auditDocsPages as snippetAuditDocsPages, listDocuments as snippetDocuments, + NESTED_PACKAGE_READMES as SNIPPET_NESTED_PACKAGE_READMES, + nestedPackageReadmePages as snippetNestedPackageReadmes, ROOT_DOCS as SNIPPET_ROOT_DOCS, rootDocsPages as snippetRootDocsPages, ROOT_PAGES as SNIPPET_ROOT_PAGES, @@ -167,22 +169,40 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types * OTHER divergence still fails here, which is the whole point of keeping the * comparison rather than deleting it. */ - /** The snippet gate's three root-`docs/` legs, taken from the gate itself. */ + /** + * The snippet gate's own legs, taken from the gate itself: its three + * root-`docs/` legs, plus objectui#7308's nested package READMEs. + * + * ⭐ objectui#7308 is the fourth enumerator and it arrives the way card 1 built + * this for: `check-doc-links` had already closed the same hole on the same four + * files (objectui#6026), but moving `check:doc-fences`' own surface is not that + * card's to do — so the divergence is SUBTRACTED BY IMPORT and every other + * drift between the two walks still fails here. A fifth nested README landing + * under `packages/` tomorrow travels into the snippet gate's side and into this + * subtraction by itself; a hand-written list of today's four filenames would + * have had to be re-typed for it. + */ const snippetDocsLegs = () => [ ...snippetRootDocsPages(ROOT), ...snippetAdrDocsPages(ROOT), ...snippetAuditDocsPages(ROOT), + ...snippetNestedPackageReadmes(ROOT), ]; - it('walks exactly the documents the snippet gate walks, minus that gate’s three docs/ legs', () => { + it('walks exactly the documents the snippet gate walks, minus that gate’s own legs', () => { const legOnly = new Set(snippetDocsLegs()); expect(fenceDocuments(ROOT)).toEqual(snippetDocuments(ROOT).filter((d: string) => !legOnly.has(d))); }); it('…and that subtraction is non-empty, so it is not silently subtracting nothing', () => { // Each leg separately: a union that is non-empty overall would stay green - // with one of its three members returning nothing at all. - for (const leg of [snippetRootDocsPages(ROOT), snippetAdrDocsPages(ROOT), snippetAuditDocsPages(ROOT)]) { + // with one of its four members returning nothing at all. + for (const leg of [ + snippetRootDocsPages(ROOT), + snippetAdrDocsPages(ROOT), + snippetAuditDocsPages(ROOT), + snippetNestedPackageReadmes(ROOT), + ]) { expect(leg.length).toBeGreaterThan(0); } // Every subtracted document really is on the snippet gate's side only. @@ -192,6 +212,53 @@ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types } }); + /** + * objectui#7308's leg, pinned where it stops rather than only that it is + * non-empty. + * + * Two claims a reading of the code cannot make: that the leg collects a README + * BELOW a package root and never the package's own top-level one (the + * no-double-collect guarantee objectui#6026 got structurally, by rooting the + * walk at each package's SUBdirectories), and that it does not follow pnpm's + * workspace symlinks out of the authored tree. The second is not hypothetical: + * `statSync` follows symlinks, and `packages/a/node_modules/@object-ui/b` leads + * back into `packages/b`, so an unguarded walk does not terminate. + */ + it('the nested-README leg collects below package roots only, and the tracked tree exactly', () => { + expect(SNIPPET_NESTED_PACKAGE_READMES).toEqual({ + dir: 'packages', + name: 'README.md', + recursive: true, + }); + const nested = snippetNestedPackageReadmes(ROOT); + // Below a package root, every one of them — never `packages//README.md`. + for (const doc of nested) { + expect(doc, `${doc} is not a README.md`).toMatch(/\/README\.md$/); + expect(doc, `${doc} sits at a package root, so the leg above already has it`).not.toMatch( + /^packages\/[^/]+\/README\.md$/, + ); + expect(doc.split('/'), `${doc} left the authored tree`).not.toContain('node_modules'); + } + // The leg plus the top-level README leg is EXACTLY the tracked population — + // measured against git, not against a hand-written list, so the walk cannot + // quietly gain a generated page or lose an authored one. + const tracked = execFileSync('git', ['ls-files', '--', 'packages/'], { cwd: ROOT, encoding: 'utf8' }) + .split('\n') + .filter((f) => /(^|\/)README\.md$/.test(f)) + .sort(); + const walked = snippetDocuments(ROOT) + .filter((d: string) => d.startsWith('packages/') && d.endsWith('/README.md')) + .sort(); + expect(walked).toEqual(tracked); + // Non-vacuous on both halves: the tracked population is not empty, and it + // really does contain pages at both depths. + expect(tracked.length).toBeGreaterThan(0); + expect(nested.length).toBeGreaterThan(0); + expect(tracked.filter((f) => /^packages\/[^/]+\/README\.md$/.test(f)).length).toBeGreaterThan(0); + // No document is collected twice by the two package legs together. + expect(new Set(walked).size).toBe(walked.length); + }); + /** * The boundary, pinned on the legs themselves. `recursive: false` on one and * `recursive: true` on the other two are claims about where each surface stops, diff --git a/scripts/__tests__/check-doc-snippet-types.test.ts b/scripts/__tests__/check-doc-snippet-types.test.ts index bdbfc96fe0..557cf6ff62 100644 --- a/scripts/__tests__/check-doc-snippet-types.test.ts +++ b/scripts/__tests__/check-doc-snippet-types.test.ts @@ -12,6 +12,7 @@ import ts from 'typescript'; import { ADR_DOCS, AUDIT_DOCS, + NESTED_PACKAGE_READMES, EXIT_CODES, FRAGMENT_MARKER_EXAMPLES, ROOT_DECLARED_CONTROL_PACKAGE, @@ -27,6 +28,7 @@ import { listDocuments, moduleSpecifiersOf, moduleSpecifiersOfBlock, + nestedPackageReadmePages, resolvesOnlyThroughRootManifest, ROOT_DOCS, adrDocsPages, @@ -905,6 +907,226 @@ describe('objectui#7856 card 2 — docs/adr/** and docs/audits/** are in the sca }); }); +/** + * objectui#7308 — every `README.md` under `packages/`, at any depth, ledger-first. + * + * The defect this closes was a SPECIFICATION defect rather than drift: the + * header's SCAN SURFACE paragraph said `every packages//README.md`, one + * level, literally, and `listDocuments` implemented that sentence faithfully. So + * four nested pages were neither compiled nor ledgered — objectui#5174's "neither + * covered NOR declared ungated", one directory down — and `check-doc-links` had + * already closed the identical hole on the identical four files (objectui#6026). + * + * The proof this block owes is card 2's shape rather than card 1's, because this + * widening also lands LEDGER-FIRST: three of the four pages carry blocks that do + * not compile today, so showing them in the compiled tier is a claim this card + * may not make. What it asserts instead: + * + * 1. the pages are really in the walk, and the walk is EXACTLY the tracked + * population under `packages/` — measured against `git ls-files` rather than + * a hand-written list, which is the only version of this assertion that + * notices a fifth page landing tomorrow; + * 2. every one of them that holds a `ts` / `tsx` block is on the ledger, and + * every one that holds none is COVERED and ledgered nowhere — the second + * half is not decoration, it is why this card writes THREE rows for FOUR + * pages, and it is the mechanical refutation of "keep the ledger short by + * leaving the page outside the surface"; + * 3. the leg cannot double-collect a package's own top-level `README.md`, which + * objectui#6026 got structurally by rooting the walk one directory down; + * 4. ⚠️ the walk does not follow pnpm's workspace symlinks out of the authored + * tree. This is the one hazard no other leg in this file has: every other + * recursive walk here crosses an authored tree with nothing generated inside + * it, while `packages/` has a `node_modules/` per package whose entries are + * SYMLINKS to sibling workspace packages — and `statSync` follows symlinks, + * so `packages/a/node_modules/@object-ui/b` leads back into `packages/b` and + * onward forever. Measured on `9ba7e9c3` with the workspace installed: an + * unguarded walk does not merely overshoot, it does not terminate; capped at + * depth 12 it had already reached 17,354 files named `README.md` against the + * 43 the repository tracks. The fixture below reproduces that cycle in + * miniature, so the guard is asserted rather than trusted. + * + * ⛔ What is deliberately NOT asserted: a `main()` refusal when `packages/` is + * missing, of the kind `ROOT_DOCS` and the two subtree legs carry. This leg walks + * the SAME directory the top-level package-README leg has always walked, and that + * leg has never had one — introducing a new precondition on the shared directory + * is a different change from widening the depth this one reads. The vacuity floor + * that does apply is this file's own "scans a plausible number of documents". + */ +describe('objectui#7308 — the nested package READMEs are in the scan set, ledger-first', () => { + const trackedPackageReadmes = () => + spawnSync('git', ['ls-files', '--', 'packages/'], { cwd: repoRoot, encoding: 'utf8' }) + .stdout.split('\n') + .filter((f) => /(^|\/)README\.md$/.test(f)) + .sort(); + + it('listDocuments reaches the nested pages, and the walk IS the tracked population', () => { + const documents = listDocuments(repoRoot); + const nested = nestedPackageReadmePages(repoRoot); + // Non-vacuous: the leg really finds pages, and they really are in the walk. + expect(nested.length).toBeGreaterThan(0); + for (const doc of nested) expect(documents).toContain(doc); + // Exactly the tracked population — no generated page gained, none lost. + const walked = documents.filter((d) => d.startsWith('packages/') && d.endsWith('/README.md')).sort(); + const tracked = trackedPackageReadmes(); + expect(tracked.length).toBeGreaterThan(0); + expect(walked).toEqual(tracked); + // Both depths are really represented, so the equality above is not green on + // a population that happens to be flat. + expect(tracked.some((f) => /^packages\/[^/]+\/README\.md$/.test(f))).toBe(true); + expect(tracked.some((f) => !/^packages\/[^/]+\/README\.md$/.test(f))).toBe(true); + }); + + it('collects BELOW a package root only, so the top-level leg cannot double-collect', () => { + for (const doc of nestedPackageReadmePages(repoRoot)) { + expect(doc, `${doc} sits at a package root`).not.toMatch(/^packages\/[^/]+\/README\.md$/); + } + const walked = listDocuments(repoRoot).filter((d) => d.startsWith('packages/')); + expect(new Set(walked).size).toBe(walked.length); + }); + + it('the widening is VISIBLE to the accounting: block-bearing pages are ledgered, block-free ones are covered', () => { + const state = analyze({}) as { + scans: Map; + covered: string[]; + }; + const nested = nestedPackageReadmePages(repoRoot); + const withBlocks = nested.filter((doc) => (state.scans.get(doc)?.blocks.length ?? 0) > 0); + const withoutBlocks = nested.filter((doc) => (state.scans.get(doc)?.blocks.length ?? 0) === 0); + // Non-vacuous on BOTH halves — this is the assertion that says why three rows + // were written for four pages. + expect(withBlocks.length).toBeGreaterThan(0); + expect(withoutBlocks.length).toBeGreaterThan(0); + expect([...withBlocks].sort()).toEqual( + Object.keys(UNGATED_DOCS as Record) + .filter((doc) => nested.includes(doc)) + .sort(), + ); + // A page with no ts/tsx block is COVERED at zero blocks and may not be + // ledgered: the stale-entry check would refuse it, which is exactly why + // leaving it out of the surface to keep the ledger short is not available. + for (const doc of withoutBlocks) expect(state.covered).toContain(doc); + }); + + it('every new ledger row carries a measured count, the phases, and what would have to change', () => { + const nested = new Set(nestedPackageReadmePages(repoRoot)); + const entries = Object.entries(UNGATED_DOCS as Record).filter(([doc]) => nested.has(doc)); + expect(entries.length).toBeGreaterThan(0); + for (const [doc, reason] of entries) { + expect(reason, `${doc}: names no block count`).toMatch(/\d+ `tsx?` blocks?/); + expect(reason, `${doc}: names no diagnostic count`).toMatch(/\d+ diagnostics/); + expect(reason, `${doc}: names no diagnostic code`).toMatch(/TS\d{4}/); + expect(reason, `${doc}: does not say which phase was measured`).toMatch(/syntax-phase|semantic-phase/); + expect(reason, `${doc}: does not say what would have to change`).toMatch(/What would have to change|would have to change/); + } + }); + + it('descends below a package root, and stops at the directories that hold no prose', () => { + const root = tempTree({ + 'packages/alpha/README.md': '# top level, the OTHER leg has this one\n', + 'packages/alpha/src/zod/README.md': '# nested\n', + 'packages/alpha/docs/verification/README.md': '# nested, deeper\n', + 'packages/alpha/src/NOTES.md': '# not a README, in no leg\n', + 'packages/alpha/dist/README.md': '# build output\n', + 'packages/alpha/node_modules/dep/README.md': '# an installed dependency\n', + 'packages/beta/README.md': '# another package root\n', + }); + try { + expect(nestedPackageReadmePages(root)).toEqual([ + 'packages/alpha/docs/verification/README.md', + 'packages/alpha/src/zod/README.md', + ]); + const documents = listDocuments(root); + // The top-level leg still has the package roots, exactly once each. + expect(documents.filter((d) => d === 'packages/alpha/README.md')).toEqual([ + 'packages/alpha/README.md', + ]); + expect(documents).toContain('packages/beta/README.md'); + // Not a README, and therefore in no leg — this card widened the depth the + // README rows read, and nothing else. + expect(documents).not.toContain('packages/alpha/src/NOTES.md'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('does not follow a workspace symlink out of the authored tree — the walk terminates', () => { + const root = tempTree({ + 'packages/alpha/README.md': '# alpha\n', + 'packages/alpha/src/zod/README.md': '# the one real nested page\n', + 'packages/beta/README.md': '# beta\n', + 'packages/beta/src/adapters/README.md': '# beta nested\n', + }); + try { + // pnpm's shape, in miniature: each package's node_modules links to its + // sibling, so an unguarded walk loops alpha -> beta -> alpha forever. + fs.mkdirSync(path.join(root, 'packages/alpha/node_modules/@object-ui'), { recursive: true }); + fs.mkdirSync(path.join(root, 'packages/beta/node_modules/@object-ui'), { recursive: true }); + fs.symlinkSync( + path.join(root, 'packages/beta'), + path.join(root, 'packages/alpha/node_modules/@object-ui/beta'), + 'dir', + ); + fs.symlinkSync( + path.join(root, 'packages/alpha'), + path.join(root, 'packages/beta/node_modules/@object-ui/alpha'), + 'dir', + ); + // ⚠️ The guard is written at TWO levels — once on each package's own + // directory entries, once inside the recursive descent — and only the + // second one covers a `node_modules` that is not a package's own. Without + // this deeper cycle the fixture ablates green when the inner guard is + // removed, which would make this assertion a pin on half the guard. + fs.mkdirSync(path.join(root, 'packages/alpha/src/node_modules/@object-ui'), { recursive: true }); + fs.symlinkSync( + path.join(root, 'packages/beta'), + path.join(root, 'packages/alpha/src/node_modules/@object-ui/beta'), + 'dir', + ); + // Terminates, and yields the authored pages only — each exactly once. + expect(nestedPackageReadmePages(root)).toEqual([ + 'packages/alpha/src/zod/README.md', + 'packages/beta/src/adapters/README.md', + ]); + // The control that makes the assertion above a reading: with the guard + // removed the SAME tree is a cycle, so an unguarded walk cannot finish. It + // is shown here bounded by depth rather than run to exhaustion. + const unguarded = (dir: string, depth: number): number => { + if (depth > 8) return 1; + let hits = 0; + for (const entry of fs.readdirSync(dir).sort()) { + const full = path.join(dir, entry); + if (fs.statSync(full).isDirectory()) hits += unguarded(full, depth + 1); + else if (entry === 'README.md') hits += 1; + } + return hits; + }; + expect(unguarded(path.join(root, 'packages'), 0)).toBeGreaterThan( + nestedPackageReadmePages(root).length, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('an absent packages/ tree yields nothing here rather than throwing', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-snippet-gate-nopkg-')); + try { + expect(nestedPackageReadmePages(root)).toEqual([]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + expect(NESTED_PACKAGE_READMES).toEqual({ dir: 'packages', name: 'README.md', recursive: true }); + }); + + it('the header states the widened surface, so the specification cannot drift back', () => { + const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-snippet-types.mjs'), 'utf8'); + // The SCAN SURFACE paragraph is where objectui#7308's defect lived: it said + // `every packages//README.md`, and the walk implemented that sentence. + expect(source).toMatch(/every `README\.md` under `packages\/` AT ANY DEPTH/); + expect(source).not.toMatch(/every `packages\/\/README\.md`, every/); + }); +}); + describe('third-party resolution reaches exactly as far as the imported packages declare', () => { /** A workspace package with its own `node_modules`, the way pnpm links one. */ function treeWithDependency(files: Record = {}): string { diff --git a/scripts/check-doc-snippet-types.mjs b/scripts/check-doc-snippet-types.mjs index a984be9f4e..8265663f7d 100644 --- a/scripts/check-doc-snippet-types.mjs +++ b/scripts/check-doc-snippet-types.mjs @@ -424,11 +424,20 @@ * be read off the collector: * * every `.mdx` and `.md` page under `content/docs`, every page under an - * `apps//docs` tree, every `packages//README.md`, every `.md` / - * `.mdx` page at the TOP LEVEL of the repository-root `docs/` tree - * (objectui#7856 card 1), every page under `docs/adr/**` and under - * `docs/audits/**` (objectui#7856 card 2, recursively), and the root - * `README.md`. + * `apps//docs` tree, every `README.md` under `packages/` AT ANY DEPTH + * (objectui#7308), every `.md` / `.mdx` page at the TOP LEVEL of the + * repository-root `docs/` tree (objectui#7856 card 1), every page under + * `docs/adr/**` and under `docs/audits/**` (objectui#7856 card 2, + * recursively), and the root `README.md`. + * + * ⚠️ The package-README clause used to read `every packages//README.md`, + * one level, literally — and that is what objectui#7308 reported: the sentence + * above is where the surface is SPECIFIED, so a walk that stopped at the package + * root was not drift from this paragraph, it was this paragraph drawn too small. + * Four nested pages were therefore neither compiled nor ledgered, i.e. neither + * covered NOR declared ungated, one directory down from where objectui#5174 + * found the same state one level up. The sibling gate `check-doc-links` had + * already closed the identical hole on the identical four files (objectui#6026). * * ⚠️ Card 2 is the widening whose whole delivery is the LEDGER, and reading it * as coverage would be reading it backwards. Every page in those two subtrees @@ -700,6 +709,101 @@ export function auditDocsPages(root) { return subtreeDocPages(root, AUDIT_DOCS); } +/** + * The package-README leg, at EVERY depth (objectui#7308). + * + * The leg above this one — `packages//README.md` — was stated in this + * header's SCAN SURFACE paragraph as if the one level were the specification, + * so this was never implementation drift: the specification itself was drawn + * too small, and a page one directory deeper was "neither covered NOR declared + * ungated" — objectui#5174's phrase for the state that is strictly worse than a + * named debt, arriving here one directory down instead of one level up. + * + * Re-derived on `9ba7e9c3`, naming the population in words each time: of the 43 + * tracked files under `packages/` whose basename is `README.md`, 39 sit at a + * package root and 4 sit deeper, so 4 pages were in no gate's accounting. They + * are not private notes — `packages/types` lists the whole of `src/` in its + * manifest `files`, so `packages/types/src/zod/README.md` is inside the npm + * tarball a reader downloads. + * + * objectui#6026 closed exactly this hole, on exactly these four files, in the + * sibling gate `check-doc-links` — same defect, same count, different gate. This + * is that fix arriving here. + * + * ### Why this is its own leg rather than a `recursive` flag on the one above + * + * Two reasons, and the first is the same one `ADR_DOCS` / `AUDIT_DOCS` carry: a + * divergence between this gate's walk and `check-doc-fence-languages`' has to be + * something a pin can SUBTRACT by import rather than a hand-written list of + * today's four filenames, so the guard's walk-equality test keeps failing on any + * OTHER drift. The second is objectui#6026's own no-double-parse guarantee, + * inherited structurally: this leg is rooted at the SUBdirectories of each + * package, so a package's own top-level `README.md` is not inside any of them + * and cannot be collected twice. Nothing here has a notion of "top level". + * + * ### ⚠️ Why the skip set is load-bearing, measured rather than assumed + * + * Every other recursive leg in this file walks an authored tree with nothing + * generated inside it. `packages/` is not that tree. Under pnpm each package has + * its own `node_modules/` holding SYMLINKS to its workspace siblings, and + * `statSync` follows symlinks — so `packages/a/node_modules/@object-ui/b` leads + * back into `packages/b`, whose own `node_modules` leads onward. Measured on + * `9ba7e9c3` with the workspace installed: an unguarded recursive walk does not + * merely overshoot, it does not TERMINATE; capped at depth 12 it had already + * reached 17,354 files named `README.md`, against the 43 the repository tracks. + * With `UNSCANNED_DIRS` applied the walk yields exactly 43 — the tracked + * population, to the file. + */ +export const NESTED_PACKAGE_READMES = { dir: PACKAGES_DIR, name: 'README.md', recursive: true }; + +/** + * Directory names this file's `packages/` walk never enters. + * + * The same set, for the same reason, that `check-doc-links.mjs` walks its own + * disk surfaces with (`UNSCANNED_DIRS` there): none of these holds authored + * prose — they hold installed dependencies and build output — and the first of + * them is what makes the walk above terminate at all. + */ +const UNSCANNED_DIRS = new Set(['node_modules', 'dist', 'build', '.next', '.turbo', '.git']); + +/** + * Every `README.md` strictly BELOW a package's own root, in a stable order. + * + * An absent `packages/` yields `[]` so a throwaway fixture tree stays listable, + * exactly as the legs above do. + * + * Exported so a sibling census can ask this gate what this leg contains instead + * of re-spelling it — which is what `check-doc-fence-languages.test.ts` does: the + * fence guard does NOT carry this leg (moving `check:doc-fences`' surface is not + * this card), and its walk-equality pin subtracts this enumerator BY IMPORT. + */ +export function nestedPackageReadmePages(root) { + const base = join(root, NESTED_PACKAGE_READMES.dir); + if (!existsSync(base) || !statSync(base).isDirectory()) return []; + const out = []; + const walk = (dir) => { + for (const entry of readdirSync(dir).sort()) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) { + if (!UNSCANNED_DIRS.has(entry)) walk(p); + continue; + } + if (entry === NESTED_PACKAGE_READMES.name) out.push(relative(root, p).split(sep).join('/')); + } + }; + for (const entry of readdirSync(base).sort()) { + const pkg = join(base, entry); + if (!statSync(pkg).isDirectory()) continue; + // Rooted at each package's SUBdirectories: the package's own README.md is + // not inside any of them, so the leg above cannot double-collect it. + for (const sub of readdirSync(pkg).sort()) { + const p = join(pkg, sub); + if (statSync(p).isDirectory() && !UNSCANNED_DIRS.has(sub)) walk(p); + } + } + return out; +} + /** Fence languages treated as compilable TypeScript. `js` / `jsx` are NOT in the * set: they are not type-annotated, so a strict program judges them on rules * their authors never opted into. */ @@ -718,12 +822,14 @@ const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); * apps//docs/** ✓ ✓ ✓ objectui#6600 * README.md ✓ ✓ ✓ objectui#7115 * packages//README.md ✓ ✓ ✗ ships inside `files` + * nested packages README.md ✗ ✓ ✗ objectui#7308 * docs/*.md (top level only) ✗ ✓ ✗ objectui#7856 card 1 * docs/adr/** ✗ ✓ ✗ objectui#7856 card 2 * docs/audits/** ✗ ✓ ✗ objectui#7856 card 2 * - * The three `docs/` rows are the legs THIS gate carries alone, and the asymmetry - * is deliberate rather than an oversight to be tidied up later: objectui#7856 + * The three `docs/` rows and the nested-README row are the legs THIS gate carries + * alone, and the asymmetry is deliberate rather than an oversight to be tidied up + * later: objectui#7856 * card 1 moves this gate's population only, so `check-doc-fence-languages` and * `check-doc-component-types` keep the surface they had. `check-doc-fence- * languages.test.ts` therefore no longer compares the two walks for equality @@ -735,6 +841,12 @@ const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); * where it stops, and `docs/adr/**` being GOVERNED is the reason the boundary * between the rows is worth a line of code rather than a comment. * + * objectui#7308's nested-README row extends that same subtraction with + * `nestedPackageReadmePages()` — a fourth enumerator, imported rather than + * re-spelled — for the reason card 1 exported its own: moving `check:doc-fences`' + * 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. @@ -744,7 +856,8 @@ const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); * ⚠️ 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 * descending order of size, the unscanned population is: non-README `.md` under - * `packages/**` (by far the largest); the PUBLISHED + * `packages/**`, at any depth, which stays the largest of them — objectui#7308 + * brought the nested `README.md` files in and NOTHING else; the PUBLISHED * `skills/objectui/**`; the root pages that are not `README.md` (`AGENTS.md`, * `CONTRIBUTING.md`, `ROADMAP.md` and the rest); `examples/**`; the `apps/**` * pages that are not under an `apps//docs/` tree; `.claude/**`; @@ -764,7 +877,7 @@ const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); * "which": * * git ls-files '*.md' '*.mdx' \ - * | grep -vE '^(content/docs/|apps/[^/]+/docs/|packages/[^/]+/README\.md$|README\.md$|docs/[^/]+\.mdx?$|docs/adr/|docs/audits/|\.changeset/)' + * | grep -vE '^(content/docs/|apps/[^/]+/docs/|packages/.*README\.md$|README\.md$|docs/[^/]+\.mdx?$|docs/adr/|docs/audits/|\.changeset/)' * * ⚠️ A subdirectory of `docs/` that is NEITHER `adr/` NOR `audits/` is in no leg * and therefore still in that population — the exclusion above names the two @@ -944,6 +1057,66 @@ const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); * @type {Record} */ const UNGATED_DOCS = { + // objectui#7308 — the nested package READMEs, LEDGER-FIRST. Measured on + // `9ba7e9c3` with this gate's own analyzer against the closure `--build-filter` + // names (35/35 turbo tasks successful): `analyze({ ungated: {} })` for the + // population, `compileSnippets()` for the phases, with the surface widened and + // these rows NOT yet written — which is the only order in which the numbers + // below are readings rather than justifications. + // + // The census, with each population named: the widening adds 4 documents + // (245 -> 249 in the scan set) carrying 20 `ts`/`tsx` blocks, of which 13 fail + // — 3 in the syntax phase and 10 in the semantic phase — for 37 diagnostics. + // + // ⚠️ Only THREE rows appear below for those four documents, and the missing + // fourth is mechanical rather than an exclusion: `packages/plugin-gantt/docs/ + // verification/README.md` holds no `ts`/`tsx` fenced block at all (its fences + // are `sh`), so it joins the COVERED tier at zero blocks, and a row naming it + // here would fail this gate's own re-derivation as a stale entry — "an entry + // naming a file that ... holds no `ts` / `tsx` block at all, fails as a stale + // entry". ⛔ That is the opposite of keeping the ledger short by quietly leaving + // a page outside the surface, which is the defect objectui#7308 reported. + // + // ⚠️ These three rows are DEBT, not a terminal state — unlike the objectui#7856 + // card 2 rows below, which are records nobody may repair. A package README is a + // page a reader copies from, so every row here can and should leave by the page + // compiling. Paying it down is its own card; ⛔ softening this gate is not a way + // to pay it. + 'packages/components/src/__tests__/README.md': + '1 `ts` block (fence 104), 9 diagnostics, ALL semantic-phase: TS2593 x3, TS2304 x5, TS2552 x1. The ' + + "block is the page's \"Adding New Tests\" pattern: a bare `describe`/`it` body with no imports at all, " + + 'so the Vitest globals (`describe`, `it`, `expect`) and the three local helpers it calls ' + + '(`validateComponentRegistration`, `renderComponent`, `getAllDisplayIssues`) are all undefined names. ' + + 'What would have to change: the block imports `describe`/`it`/`expect` from `vitest` and the three ' + + 'helpers from wherever this test suite ships them — or, if the helpers are suite-local and have no ' + + 'importable home, the block gets a `FRAGMENT_MARKER` saying so.', + 'packages/core/src/adapters/README.md': + '5 `ts` blocks (fences 36, 70, 191, 216, 239); 2 of them compile untouched. 3 fail with 9 diagnostics, ' + + 'ALL semantic-phase: TS2304 x2, TS2355 x6, TS2420 x1. Two are excerpts naming a value the prose ' + + 'introduces but the block never declares (`contextDataSource` at fence 191, `dataSource` at fence 216). ' + + "⚠️ The third is a DOCUMENTED-API defect rather than a snippet-hygiene one, and it is the first thing " + + 'this widening found: the custom-adapter template at fence 239 declares `class MyCustomAdapter ' + + "implements DataSource` while omitting `getObjectSchema`, which `DataSource` requires (TS2420), " + + 'and its six method bodies are `// Your implementation` comments under non-`void` return annotations ' + + '(TS2355 x6). A reader who copies it gets a class that does not satisfy the interface it claims. What ' + + 'would have to change: the template gains `getObjectSchema` and returns a value from each body (or ' + + 'declares the bodies elided), and the two excerpts declare the value they use.', + 'packages/types/src/zod/README.md': + '14 `ts` blocks (fences 88, 110, 140, 151, 220, 245, 266, 281, 289, 301, 324, 334, 354, 368); 5 compile ' + + 'untouched. 9 fail with 19 diagnostics: 3 fail in the syntax-phase (fences 220, 301, 368 — TS1109 x10, ' + + 'so their semantic half is UNMEASURED, not clean) and 6 fail in the semantic-phase (TS2304 x8, ' + + 'TS2307 x1). Three ' + + 'classes, each with its own remedy. (1) Fence 220 is a SHAPE SKETCH — a bare object literal at ' + + "statement position with `?:` optionality markers written on keys and `type: string` standing where a " + + 'value goes; its fence language should be one this gate does not compile. (2) Fences 301 and 368 write ' + + 'the elision `{ ... }` literally, which TypeScript reads as a spread with no operand. (3) The six ' + + 'semantic failures are EXCERPTS that continue an earlier block’s imports — `ButtonSchema`, ' + + '`CardSchema`, `userInput`, `internalConfig`, `useForm`, `registry` — plus one specifier no imported ' + + 'package declares (`@hookform/resolvers/zod`, TS2307), which is the bound this header states rather ' + + 'than a page defect. What would have to change: each excerpt made self-contained against the built ' + + '`dist/*.d.ts`, the two elisions written as real initialisers, and the sketch’s fence relabelled. ' + + '⭐ This page SHIPS: `packages/types` lists the whole of `src/` in its manifest `files`, so it is ' + + 'inside the npm tarball a reader downloads — which is why objectui#7308 filed it first.', // objectui#7856 card 2. Measured on `fedfa3e4` with this gate's own analyzer // against the closure `--build-filter` names (35/35 turbo tasks successful): // `analyze({ ungated: {} })` for the population, `compileSnippets()` for the @@ -1205,6 +1378,9 @@ export function listDocuments(root = repoRoot) { if (existsSync(readme)) out.push(relative(root, readme).split(sep).join('/')); } } + // Every README.md BELOW a package root (objectui#7308), its own leg so the + // fence guard's walk-equality pin can subtract exactly this set by import. + out.push(...nestedPackageReadmePages(root)); // The root `docs/` tree, TOP LEVEL only (objectui#7856 card 1). Enumerated by // directory entry and filtered to files by `rootDocsPages`, so `docs/adr/**` // (governed) and `docs/audits/**` cannot arrive through THIS leg by accident —