From f011733bfaa64fa6937f238025e6b5a293c501f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 21:20:25 +0000 Subject: [PATCH] feat(gate): bring the root docs/*.md pages into the doc-snippet walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#7856 card 1. The repository-root `docs/` tree was read by no documentation gate: not this one, not `check:doc-fences`, not `check:doc-types`, and `lint:root` ignores it by name. Three phantom-teaching sites had already been found in it by hand (objectui#7838, objectui#7854). `listDocuments()` now carries a `docs/*.md` leg, TOP LEVEL only, in the same slot pattern the root `README.md` took for objectui#7115 — a named, exported `ROOT_DOCS` constant plus an exported `rootDocsPages()` enumerator, filtered to files by `statSync`, so `docs/adr/**` (a governed surface) and `docs/audits/**` cannot arrive by accident. Those two are card 2, for a seat that can land a governed-surface pull request. `main()` refuses a verdict when the directory is missing, the way it already does for a dangling `ROOT_PAGES` name. The leg is REPAIRED, not ledgered: `UNGATED_DOCS` is untouched at 11 entries and declared fragments stay at 158. All 11 diagnostics measured under `docs/*.md` were in `docs/ARCHITECTURE.md`, all semantic, and each was resolved with a typed binding rather than a marker — `declare const` stand-ins annotated from the shipped `DataSourceProviderProps` / `AppShellProps`, a Next.js example split into the two files it always was (one fence per module, which is how the gate compiles them), and the example adapter's two parameters spelled with the types the page's own `DataSource` interface declares. `check-doc-fence-languages.test.ts` records the divergence rather than losing the comparison: it subtracts exactly `rootDocsPages()` — the gate's own export, not a hand-written list of today's filenames — and still fails on any other drift, plus a new pin that the leg stops above `docs/adr/` and `docs/audits/`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr --- .github/workflows/doc-snippet-types.yml | 4 +- docs/ARCHITECTURE.md | 33 ++++++- .../check-doc-fence-languages.test.ts | 59 +++++++++++- .../__tests__/check-doc-snippet-types.test.ts | 83 ++++++++++++++++ scripts/check-doc-snippet-types.mjs | 94 ++++++++++++++++++- 5 files changed, 262 insertions(+), 11 deletions(-) diff --git a/.github/workflows/doc-snippet-types.yml b/.github/workflows/doc-snippet-types.yml index d030970ea5..5b8c468a39 100644 --- a/.github/workflows/doc-snippet-types.yml +++ b/.github/workflows/doc-snippet-types.yml @@ -131,7 +131,9 @@ jobs: # REPORT-ONLY (objectui#7864). Code a generator EMITS from a template # literal under `packages/*/src/**` is compiled by nothing: `tsc` sees a # string, `tsup` copies it through, and this gate's own scan surface stops - # at `content/docs`, the per-app docs trees and the package READMEs. This + # at the authored pages — `content/docs`, the per-app docs trees, the + # package READMEs, the root `README.md` (objectui#7115) and the top level + # of the root `docs/` tree (objectui#7856 card 1). This # step censuses that class through the same `compileSnippets()` the doc # blocks go through, against the closure the step above just built. # diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 83b6e77358..9e97fd3ce2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -160,8 +160,19 @@ Third-Party App ```tsx import { AppShell } from '@object-ui/app-shell'; +import type { AppShellProps } from '@object-ui/app-shell'; import { ObjectView } from '@object-ui/plugin-view'; import { DataSourceProvider, useDataSource } from '@object-ui/providers'; +import type { DataSourceProviderProps } from '@object-ui/providers'; + +// The two things you bring. Both are typed from the surface these packages +// ship, so this snippet compiles on its own: `myAPI` is your backend adapter +// (the "Custom Data Source Interface" section below is the shape it needs at +// runtime — `DataSourceProvider` declares the prop `any`, so the annotation +// records where the value goes rather than checking it), and `MySidebar` is +// your own component, returning whatever `AppShell` accepts for `sidebar`. +declare const myAPI: DataSourceProviderProps['dataSource']; +declare function MySidebar(): AppShellProps['sidebar']; function MyConsole() { return ( @@ -183,24 +194,29 @@ function ContactList() { ### Example 2: Next.js Integration +`app/layout.tsx`: + ```tsx -// app/layout.tsx import { AppShell } from '@object-ui/app-shell'; +import type { AppShellProps } from '@object-ui/app-shell'; import { ThemeProvider } from '@object-ui/providers'; -export default function RootLayout({ children }) { +export default function RootLayout({ children }: { children: AppShellProps['children'] }) { return ( {children} ); } +``` -// app/[object]/page.tsx +`app/[object]/page.tsx`: + +```tsx import { ObjectView } from '@object-ui/plugin-view'; import { useDataSource } from '@object-ui/providers'; -export default function Page({ params }) { +export default function Page({ params }: { params: { object: string } }) { const dataSource = useDataSource(); return ; } @@ -211,6 +227,11 @@ export default function Page({ params }) { ```tsx import { ObjectView } from '@object-ui/plugin-view'; import { DataSourceProvider, useDataSource } from '@object-ui/providers'; +import type { DataSourceProviderProps } from '@object-ui/providers'; + +// Your backend adapter, as in Example 1 — declared here because every block on +// this page compiles on its own. +declare const myAPI: DataSourceProviderProps['dataSource']; function MyExistingApp() { return ( @@ -252,7 +273,9 @@ Example implementation: ```tsx const myDataSource = { - async find(objectName, params) { + // The parameter types are the ones the interface above declares; spelling + // them out is what lets this block be compiled rather than read. + async find(objectName: string, params?: any) { return fetch(`/api/${objectName}`, { method: 'POST', body: JSON.stringify(params), diff --git a/scripts/__tests__/check-doc-fence-languages.test.ts b/scripts/__tests__/check-doc-fence-languages.test.ts index 330933d04d..fca0adf33a 100644 --- a/scripts/__tests__/check-doc-fence-languages.test.ts +++ b/scripts/__tests__/check-doc-fence-languages.test.ts @@ -15,6 +15,8 @@ import { import { APP_DOCS as SNIPPET_APP_DOCS, listDocuments as snippetDocuments, + ROOT_DOCS as SNIPPET_ROOT_DOCS, + rootDocsPages as snippetRootDocsPages, ROOT_PAGES as SNIPPET_ROOT_PAGES, TS_FENCE_LANGUAGES as GATE_TS_FENCES, } from '../check-doc-snippet-types.mjs'; @@ -114,8 +116,61 @@ const MIN_HEADER_PROSE = 400; * place for those numbers. */ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types’s', () => { - it('walks exactly the documents the snippet gate walks', () => { - expect(fenceDocuments(ROOT)).toEqual(snippetDocuments(ROOT)); + /** + * objectui#7856 card 1 — the ONE place the two walks are allowed to differ, + * named rather than tolerated. + * + * That card brought the repository-root `docs/` tree, TOP LEVEL only, into + * `check-doc-snippet-types`' walk: an authored-documentation directory that no + * doc gate read, where three phantom-teaching sites (objectui#7838, + * objectui#7854) had already been found by hand. It moved THAT gate's + * population and no other, for a stated reason: the rest of the tree — + * `docs/adr/**`, a GOVERNED surface, and `docs/audits/**` — is card 2, whose + * pull request stops in draft for a human to merge, and `check:doc-fences`' + * own surface was not that card's to move. + * + * So the equality below subtracts exactly what the snippet gate exports as its + * leg, `rootDocsPages()`, rather than a hand-written list of today's two + * filenames: a page added to `docs/` tomorrow travels into BOTH sides of this + * comparison by itself, and a page added under `docs/adr/` travels into + * NEITHER. A hand-written list would have to be re-typed for the first case and + * would stay silently green for the second. + * + * ⛔ What this is not: a licence for the two walks to drift anywhere else. Any + * OTHER divergence still fails here, which is the whole point of keeping the + * comparison rather than deleting it. + */ + it('walks exactly the documents the snippet gate walks, minus that gate’s docs/*.md leg', () => { + const legOnly = new Set(snippetRootDocsPages(ROOT)); + 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', () => { + const leg = snippetRootDocsPages(ROOT); + expect(leg.length).toBeGreaterThan(0); + // Every subtracted document really is on the snippet gate's side only. + for (const doc of leg) { + expect(snippetDocuments(ROOT), `${doc} is not in the snippet gate's walk`).toContain(doc); + expect(fenceDocuments(ROOT), `${doc} reached the fence guard's walk`).not.toContain(doc); + } + }); + + /** + * The card-2 boundary, pinned on the leg itself. `recursive: false` is a claim + * about where this surface stops, and a claim about a walk is only worth what + * a test that reads the tree says about it. + */ + it('the docs/*.md leg stops at the top level — the governed subtrees stay out of both walks', () => { + expect(SNIPPET_ROOT_DOCS).toEqual({ dir: 'docs', recursive: false }); + const nested = (docs: string[]) => + docs.filter((d) => d.startsWith(`${SNIPPET_ROOT_DOCS.dir}/`) && d.slice(`${SNIPPET_ROOT_DOCS.dir}/`.length).includes('/')); + expect(nested(snippetDocuments(ROOT))).toEqual([]); + expect(nested(fenceDocuments(ROOT))).toEqual([]); + // Non-vacuous: the subdirectories this asserts are absent do hold pages. + expect( + fs.existsSync(path.join(ROOT, SNIPPET_ROOT_DOCS.dir, 'adr')), + 'docs/adr no longer exists, so the exclusion above pins nothing', + ).toBe(true); }); it('…and that is a non-empty set, so the comparison is not vacuous', () => { diff --git a/scripts/__tests__/check-doc-snippet-types.test.ts b/scripts/__tests__/check-doc-snippet-types.test.ts index 164f9edfe5..b807369a41 100644 --- a/scripts/__tests__/check-doc-snippet-types.test.ts +++ b/scripts/__tests__/check-doc-snippet-types.test.ts @@ -26,7 +26,9 @@ import { moduleSpecifiersOf, moduleSpecifiersOfBlock, resolvesOnlyThroughRootManifest, + ROOT_DOCS, rootDeclaredSpecifiers, + rootDocsPages, scanFences, scopedBuildNotice, specifierRoot, @@ -622,6 +624,87 @@ describe('objectui#7115 — the root README is in the scan set', () => { }); }); +/** + * objectui#7856 card 1 — the repository-root `docs/` tree was in NO doc gate's + * scan set, and `lint:root` ignores it by name (`--ignore-pattern 'docs/**'`). + * The card measured what that bought: three phantom-teaching sites found in it by + * hand (objectui#7838, objectui#7854) and 11 diagnostics under `docs/*.md` that + * nothing reported. + * + * The rule this file's sibling states — "Widening a scan surface is the change + * that can be GREEN ABOUT NOTHING… Anything added here later is owed the same + * proof" — is why membership is pinned by name below, and why the leg's + * BOUNDARY is pinned too. `recursive: false` is not a performance note: the + * subtree it excludes is `docs/adr/**`, a governed surface whose pull requests + * stop in draft for a human, plus `docs/audits/**`, and both are card 2. A leg + * that grew into them by accident would put a governed-surface failure in front + * of a pull request that cannot land it. + */ +describe('objectui#7856 — the root docs/*.md pages are in the scan set, and only those', () => { + it('listDocuments reaches them', () => { + const documents = listDocuments(repoRoot); + for (const doc of rootDocsPages(repoRoot)) expect(documents).toContain(doc); + // Non-vacuous: the leg reaches a real page, not an empty directory. + expect(rootDocsPages(repoRoot)).toContain('docs/ARCHITECTURE.md'); + }); + + it('the widening judges something — the leg contributes blocks to the compiled tier', () => { + // Being IN the walk is one fact; being compiled is the other, and this card + // delivered both (no `UNGATED_DOCS` entry was needed — the blocks were + // repaired). A leg whose pages all sat on the ledger would be visible to the + // accounting and judged by nothing, which is a weaker claim than this test + // makes. + const state = analyze({}); + const leg = new Set(rootDocsPages(repoRoot)); + expect((state.covered as string[]).filter((d) => leg.has(d)).sort()).toEqual([...leg].sort()); + expect((state.compiled as { doc: string }[]).some((b) => leg.has(b.doc))).toBe(true); + }); + + it('stops at the top level: a page in a subdirectory is NOT collected', () => { + const root = tempTree({ + 'docs/PAGE.md': '# top level\n', + 'docs/adr/0001-decision.md': '# governed, card 2\n', + 'docs/audits/2026-07-audit.md': '# card 2\n', + }); + try { + expect(rootDocsPages(root)).toEqual(['docs/PAGE.md']); + expect(listDocuments(root)).toEqual(['docs/PAGE.md']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('collects files only, and only page extensions', () => { + const root = tempTree({ + 'docs/b.mdx': '# b\n', + 'docs/a.md': '# a\n', + 'docs/notes.txt': 'not a page\n', + 'docs/screenshots/shot.png': 'not a page\n', + }); + try { + expect(rootDocsPages(root)).toEqual(['docs/a.md', 'docs/b.mdx']); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('an absent docs/ tree yields nothing here, and a verdict is refused in main', () => { + const root = tempTree({ 'README.md': '# root\n' }); + try { + // A throwaway fixture tree stays listable… + expect(rootDocsPages(root)).toEqual([]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + // …while a REAL run refuses, the same way a dangling ROOT_PAGES name does. + // `main()` takes no `--root`, so this is pinned against the source for the + // same reason the ROOT_PAGES guard above is. + const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-snippet-types.mjs'), 'utf8'); + expect(source).toMatch(/if \(!existsSync\(join\(repoRoot, ROOT_DOCS\.dir\)\)\) \{/); + expect(ROOT_DOCS).toEqual({ dir: 'docs', recursive: false }); + }); +}); + 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 fe7e896c25..56aca37a18 100644 --- a/scripts/check-doc-snippet-types.mjs +++ b/scripts/check-doc-snippet-types.mjs @@ -370,7 +370,9 @@ * be read off the collector: * * every `.mdx` and `.md` page under `content/docs`, every - * `packages//README.md`, and the root `README.md`. + * `packages//README.md`, every `.md` / `.mdx` page at the TOP LEVEL of + * the repository-root `docs/` tree (objectui#7856 card 1 — not its + * subdirectories), and the root `README.md`. * * Stating it here is objectui#5174's finding, and the finding was not the missing * extension — it was that a reader had to open `listDocuments` to learn that @@ -484,6 +486,62 @@ export const ROOT_PAGES = ['README.md']; * sidecars) holds no prose and is not a page. */ const DOC_EXTENSIONS = ['.mdx', '.md']; +/** + * The repository-root `docs/` tree, at its TOP LEVEL only (objectui#7856, card 1). + * + * objectui#7856 measured the hole the same way objectui#7115 measured the root + * `README.md`'s: `docs/` is an authored-documentation directory that NO doc gate + * read — not this one, not `check-doc-fence-languages`, not + * `check-doc-component-types`, and not `lint:root`, whose script literally passes + * `--ignore-pattern 'docs/**'`. Three phantom-teaching sites (objectui#7838, + * objectui#7854) were found in it by hand, which is the only instrument that was + * ever pointed at it. + * + * `recursive: false` is the whole design of this leg, and it is a boundary rather + * than an optimisation. The tree's SUBDIRECTORIES are a different review route: + * `docs/adr/**` is a GOVERNED surface (`GOVERNED_SURFACES` id `adr` in + * `check-governed-queue-guard.mjs`, so a pull request touching it stops in draft + * for a human to merge) and `docs/audits/**` travels with it as objectui#7856's + * card 2. A `**`-shaped walk here would pull 29 more diagnostics from those two + * trees into a gate whose failures a non-governed pull request is expected to + * fix (26 + 3, as objectui#7856 measured them on `8507a2283`) — which is how a + * widening turns into a change nobody can land. The same + * reasoning, in the same words, as `APP_DOCS`' "one level of app directory and no + * deeper": a scan surface says where it stops. + * + * So the enumeration below is by DIRECTORY ENTRY and filtered to FILES. Adding + * `docs/adr/**` later is then an edit to this file that a reviewer sees, never a + * side effect of a page being moved into a subdirectory. + * + * Exported — the constant and the enumerator both — so a sibling census can ask + * this gate what its leg contains instead of re-spelling it. That is what + * `check-doc-fence-languages.test.ts` does: `check-doc-fence-languages` does NOT + * carry this leg (its walk is `check:doc-fences`' own surface, and moving it is + * not objectui#7856 card 1), and that pin subtracts exactly this set rather than + * a hand-written list of the two filenames. + */ +export const ROOT_DOCS = { dir: 'docs', recursive: false }; + +/** + * Every page at the top level of `ROOT_DOCS.dir`, in a stable order. + * + * An absent directory yields `[]` here so a throwaway fixture tree stays + * listable, exactly as `ROOT_PAGES` does; `main` refuses to publish a verdict + * when the directory is missing from a REAL run, because a leg that silently + * collects nothing is objectui#7115's defect one level up. + */ +export function rootDocsPages(root) { + const dir = join(root, ROOT_DOCS.dir); + if (!existsSync(dir) || !statSync(dir).isDirectory()) return []; + return readdirSync(dir) + .sort() + .filter( + (entry) => + DOC_EXTENSIONS.some((ext) => entry.endsWith(ext)) && statSync(join(dir, entry)).isFile(), + ) + .map((entry) => `${ROOT_DOCS.dir}/${entry}`); +} + /** 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. */ @@ -502,6 +560,17 @@ const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); * apps//docs/** ✓ ✓ ✓ objectui#6600 * README.md ✓ ✓ ✓ objectui#7115 * packages//README.md ✓ ✓ ✗ ships inside `files` + * docs/*.md (top level only) ✗ ✓ ✗ objectui#7856 card 1 + * + * The `docs/*.md` row is the one leg 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 + * flat — it subtracts exactly `rootDocsPages()` and compares the rest, so the + * divergence is named and bounded instead of being a list that silently drifted. + * ⛔ The subdirectories are NOT this row: `docs/adr/**` is governed and + * `docs/audits/**` travels with it (objectui#7856 card 2). * * `check-doc-component-types` does not read the package READMEs — it asks * whether a documented `type` literal is a registered component key, and a @@ -512,7 +581,9 @@ 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); `docs/**` (ADRs and audits); the PUBLISHED + * `packages/**` (by far the largest); `docs/adr/**` and `docs/audits/**` — the + * root `docs/` tree BELOW its top level, which objectui#7856 card 2 holds and + * card 1 deliberately left where it was; 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/**`; @@ -532,7 +603,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$|\.changeset/)' + * | grep -vE '^(content/docs/|apps/[^/]+/docs/|packages/[^/]+/README\.md$|README\.md$|docs/[^/]+\.mdx?$|\.changeset/)' * * ⛔ `skills/objectui/**` is NOT claimed by any gate here, and this line is the * opposite of a claim on it: it is a governed, published surface with its own @@ -924,6 +995,10 @@ export function listDocuments(root = repoRoot) { if (existsSync(readme)) out.push(relative(root, readme).split(sep).join('/')); } } + // 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/**` (card 2) cannot arrive here by accident. + out.push(...rootDocsPages(root)); // Root pages last, by name. An absent one is dropped here so a throwaway // fixture tree stays listable; `main` refuses to publish a verdict when one is // missing from a real run, which is the only place that can bite. @@ -2138,6 +2213,19 @@ function main() { } } + // The same check for the other root leg, for the same reason (objectui#7856 + // card 1): `rootDocsPages` returns [] for a directory that is not there, which + // keeps a fixture tree listable but would let a rename shrink the real surface + // back to what objectui#7856 found, with every count below still healthy. + if (!existsSync(join(repoRoot, ROOT_DOCS.dir))) { + console.error( + `ROOT_DOCS names \`${ROOT_DOCS.dir}/\`, which does not exist under ${repoRoot}. That directory is ` + + "part of this gate's stated scan surface (objectui#7856), so its absence silently narrows the " + + 'surface. Re-point it at the tree\'s new path, or remove the leg deliberately.', + ); + return EXIT_CODES.couldNotRun; + } + const state = analyze({}); if (argv.includes('--build-filter')) {