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')) {