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