From 4e341a5b813ad022e737182f75ce5119ccd3cad1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:28:34 +0000 Subject: [PATCH] docs-gates: bring docs/adr/** and docs/audits/** into the doc-snippet walk, ledger-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#7856 card 2. Card 1 brought the top level of the root `docs/` tree into `check-doc-snippet-types`' walk and stopped there deliberately: the two subdirectories were a different review route. This lands them, as two exported constants (`ADR_DOCS`, `AUDIT_DOCS`) plus one `listDocuments()` call site each, in the `ROOT_DOCS` pattern card 1 established. `main()` refuses a verdict when either directory is missing from a real run, exactly as it does for `docs/`. LEDGER-FIRST, and that is the delivery rather than a step toward one. The 2026-09-07 triage ruling on the card is a statement about the documents, not about cost: an ADR states what was decided on a date and an audit states what was true on a date, so repairing a code block inside one falsifies the record. Every page in the two subtrees that carries a ts/tsx block is therefore named in `UNGATED_DOCS` with its measured count, and nothing inside either subtree is edited. Measured on `fedfa3e4` with the gate's own analyzer against the closure `--build-filter` names: four records, five `ts` blocks, 29 diagnostics — 21 syntax-phase and 8 semantic — split 26 under `docs/adr/**` and 3 under `docs/audits/**`. Four of the five blocks do not parse, so their semantic half is unmeasured rather than clean, and each entry says so. No fragment marker was written. An ungated document is never compiled, so a marker inside one of these records would declare a block this gate already does not read — debt with nothing to prompt its removal — while being an edit inside a dated record. The ledger alone keeps the block accounted for. The scan population moves 229 -> 244. Nothing from either subtree reaches the compiled tier, so the build filter is byte-identical and this widening costs the gate's job no build time. `check-doc-fence-languages` and `check-doc-component-types` do not gain the legs; the walk-equality pin extends its by-import subtraction with the two new enumerators rather than a filename list. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_018dxq7YqsLDMeZDZ5AzsgJX --- .github/workflows/doc-snippet-types.yml | 7 +- .../check-doc-fence-languages.test.ts | 89 ++++-- .../__tests__/check-doc-snippet-types.test.ts | 183 ++++++++++++- scripts/check-doc-snippet-types.mjs | 254 ++++++++++++++++-- 4 files changed, 484 insertions(+), 49 deletions(-) diff --git a/.github/workflows/doc-snippet-types.yml b/.github/workflows/doc-snippet-types.yml index 5b8c468a39..ae3b6ddfde 100644 --- a/.github/workflows/doc-snippet-types.yml +++ b/.github/workflows/doc-snippet-types.yml @@ -132,8 +132,11 @@ jobs: # 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 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 + # package READMEs, the root `README.md` (objectui#7115), the top level of + # the root `docs/` tree (objectui#7856 card 1) and, recursively, + # `docs/adr/**` and `docs/audits/**` (objectui#7856 card 2, which brought + # them in LEDGER-FIRST: they are records, so their blocks are declared on + # `UNGATED_DOCS` rather than compiled). 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/scripts/__tests__/check-doc-fence-languages.test.ts b/scripts/__tests__/check-doc-fence-languages.test.ts index fca0adf33a..913887c506 100644 --- a/scripts/__tests__/check-doc-fence-languages.test.ts +++ b/scripts/__tests__/check-doc-fence-languages.test.ts @@ -13,7 +13,11 @@ import { TS_FENCE_LANGUAGES as GUARD_TS_FENCES, } from '../check-doc-fence-languages.mjs'; import { + ADR_DOCS as SNIPPET_ADR_DOCS, + adrDocsPages as snippetAdrDocsPages, APP_DOCS as SNIPPET_APP_DOCS, + AUDIT_DOCS as SNIPPET_AUDIT_DOCS, + auditDocsPages as snippetAuditDocsPages, listDocuments as snippetDocuments, ROOT_DOCS as SNIPPET_ROOT_DOCS, rootDocsPages as snippetRootDocsPages, @@ -117,60 +121,95 @@ const MIN_HEADER_PROSE = 400; */ describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types’s', () => { /** - * objectui#7856 card 1 — the ONE place the two walks are allowed to differ, - * named rather than tolerated. + * objectui#7856 — the ONE place the two walks are allowed to differ, named + * rather than tolerated. Card 1 opened it; card 2 widened it to three legs and + * did NOT widen the way it is expressed. * * 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 + * `docs/adr/**`, a GOVERNED surface, and `docs/audits/**` — was 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. + * own surface was not either 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. + * ⭐ Card 2 has now landed those two subtrees on the snippet gate — LEDGER-FIRST, + * because an ADR and a dated audit are records rather than pages to repair — and + * the whole cost of it here is three enumerators in the subtraction instead of + * one. That is the payoff of how card 1 wrote this: the equality subtracts + * exactly what the snippet gate EXPORTS as its legs rather than a hand-written + * list of today's seventeen filenames. A page added to `docs/` tomorrow travels + * into BOTH sides of this comparison by itself, a page added under `docs/adr/` + * travels into the snippet gate's side and is subtracted by itself, and a page + * added under a THIRD subdirectory of `docs/` travels into NEITHER. A + * hand-written list would have to be re-typed for the first two cases and would + * stay silently green for the third. * * ⛔ 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)); + /** The snippet gate's three root-`docs/` legs, taken from the gate itself. */ + const snippetDocsLegs = () => [ + ...snippetRootDocsPages(ROOT), + ...snippetAdrDocsPages(ROOT), + ...snippetAuditDocsPages(ROOT), + ]; + + it('walks exactly the documents the snippet gate walks, minus that gate’s three docs/ 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', () => { - const leg = snippetRootDocsPages(ROOT); - expect(leg.length).toBeGreaterThan(0); + // 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)]) { + expect(leg.length).toBeGreaterThan(0); + } // Every subtracted document really is on the snippet gate's side only. - for (const doc of leg) { + for (const doc of snippetDocsLegs()) { 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. + * 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, + * and a claim about a walk is only worth what a test that reads the tree says + * about it. + * + * Card 1 could state this as "no nested page reaches either walk". Card 2 makes + * that false for the snippet gate on purpose, so the pin says the stronger + * thing it can still say: the snippet gate's nested pages are EXACTLY the two + * subtree legs, and the fence guard's are still none. A third subdirectory of + * `docs/` appearing tomorrow lands in no leg and fails the first half here — + * which is the alarm card 1's version could not have rung, because it read + * every nested page as a violation and would have gone red for card 2 itself. */ - it('the docs/*.md leg stops at the top level — the governed subtrees stay out of both walks', () => { + it('the three docs/ legs stop where they say they do, and only the snippet gate has them', () => { expect(SNIPPET_ROOT_DOCS).toEqual({ dir: 'docs', recursive: false }); + expect(SNIPPET_ADR_DOCS).toEqual({ dir: 'docs/adr', recursive: true }); + expect(SNIPPET_AUDIT_DOCS).toEqual({ dir: 'docs/audits', recursive: true }); 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(snippetDocuments(ROOT))].sort()).toEqual( + [...snippetAdrDocsPages(ROOT), ...snippetAuditDocsPages(ROOT)].sort(), + ); + // Card 2 moved ONE gate's population; `check:doc-fences` still stops at the + // top level of `docs/`. 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); + // Non-vacuous: both subtrees exist and really hold pages. + for (const tree of [SNIPPET_ADR_DOCS, SNIPPET_AUDIT_DOCS]) { + expect( + fs.existsSync(path.join(ROOT, tree.dir)), + `${tree.dir} no longer exists, so the equality above pins nothing`, + ).toBe(true); + } + expect(snippetAdrDocsPages(ROOT).length).toBeGreaterThan(0); + expect(snippetAuditDocsPages(ROOT).length).toBeGreaterThan(0); }); 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 dd9d620866..2673ec0aa0 100644 --- a/scripts/__tests__/check-doc-snippet-types.test.ts +++ b/scripts/__tests__/check-doc-snippet-types.test.ts @@ -10,6 +10,8 @@ import { parse as parseYaml } from 'yaml'; // `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here. import ts from 'typescript'; import { + ADR_DOCS, + AUDIT_DOCS, EXIT_CODES, FRAGMENT_MARKER_EXAMPLES, ROOT_DECLARED_CONTROL_PACKAGE, @@ -27,6 +29,8 @@ import { moduleSpecifiersOfBlock, resolvesOnlyThroughRootManifest, ROOT_DOCS, + adrDocsPages, + auditDocsPages, rootDeclaredSpecifiers, rootDocsPages, scanFences, @@ -674,15 +678,28 @@ describe('objectui#7856 — the root docs/*.md pages are in the scan set, and on 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', () => { + it('stops at the top level: a page in a subdirectory is NOT collected BY THIS LEG', () => { 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', + 'docs/rfcs/0001-proposal.md': '# a THIRD subdirectory, in no leg\n', }); try { + // The leg itself is unchanged by card 2 — this is the assertion that has to + // keep holding, because `rootDocsPages` is the only place non-recursion is + // decided and both later legs were built beside it rather than into it. expect(rootDocsPages(root)).toEqual(['docs/PAGE.md']); - expect(listDocuments(root)).toEqual(['docs/PAGE.md']); + // The WALK has moved, and saying so here is the point: card 2 gave the two + // named subtrees their own legs, so they are collected — by `adrDocsPages` + // and `auditDocsPages`, never by this one. `docs/rfcs/` has no leg and is + // collected by nothing, which is where this gate's `docs/` surface stops + // today. + expect(listDocuments(root)).toEqual([ + 'docs/PAGE.md', + 'docs/adr/0001-decision.md', + 'docs/audits/2026-07-audit.md', + ]); } finally { fs.rmSync(root, { recursive: true, force: true }); } @@ -719,6 +736,168 @@ describe('objectui#7856 — the root docs/*.md pages are in the scan set, and on }); }); +/** + * objectui#7856 card 2 — the two subtrees BELOW that top level, and the one + * widening in this family whose whole delivery is the LEDGER. + * + * The sibling rule this file's header states — "Widening a scan surface is the + * change that can be GREEN ABOUT NOTHING… Anything added here later is owed the + * same proof" — lands differently here than it did for card 1, and the difference + * is the reason this block exists rather than a copy of the one above it. + * + * Card 1 could prove its widening by showing the leg's blocks in the COMPILED + * tier: its eleven diagnostics were repaired, so the pages are judged on every + * commit. Card 2 may not make that claim and must not fake it. `docs/adr/**` and + * `docs/audits/**` are RECORDS — an ADR states what was decided on a date, an + * audit states what was true on a date — and the 2026-09-07 triage ruling on + * objectui#7856 drew the line for both: "Repairing a code block inside one + * falsifies the record, exactly as it would inside an ADR." ⇒ Every block-bearing + * page in these two legs is on `UNGATED_DOCS`, which means NOTHING in them is + * compiled. + * + * So the proof this block owes is the opposite shape, and it is a stricter one: + * + * 1. the pages really are in the walk (membership, by name, from the legs + * themselves — the objectui#5174 distinction between a NAMED debt and a tree + * no accounting can mention); + * 2. every one of them that holds a `ts` / `tsx` block really is on the ledger, + * because a page in the walk, off the ledger and silently compiling nothing + * is the counterfeit; + * 3. NOTHING from either leg reaches the compiled tier — stated as an assertion + * rather than left as an inference, so the day one of them is repaired this + * test is what asks whether the record survived it; + * 4. each ledger entry carries a MEASURED count and names the record, so + * "declared" cannot decay into an adjective; + * 5. no `FRAGMENT_MARKER` was written inside either subtree. That is card 2's + * most easily lost decision: a marker is an edit INSIDE a record, and an + * ungated document is never compiled, so a marker there would declare a block + * this gate already does not read — debt with nothing to ever prompt its + * removal. The ledger alone already keeps the block accounted for. + * + * The BOUNDARY is pinned the same way card 1's was, with one addition: `recursive: + * true` on these two against `recursive: false` on `ROOT_DOCS` is a claim about + * three different walks, so the fixture below shows the descent happening here and + * the test above shows it not happening there. A subdirectory of `docs/` that is + * neither of these two is in no leg at all, and that is asserted rather than + * assumed. + */ +describe('objectui#7856 card 2 — docs/adr/** and docs/audits/** are in the scan set, ledger-first', () => { + const legPages = () => [...adrDocsPages(repoRoot), ...auditDocsPages(repoRoot)]; + + it('listDocuments reaches both legs', () => { + const documents = listDocuments(repoRoot); + for (const doc of legPages()) expect(documents).toContain(doc); + // Non-vacuous, per leg: a union that is non-empty overall would stay green + // with one of the two enumerators returning nothing. + expect(adrDocsPages(repoRoot).length).toBeGreaterThan(0); + expect(auditDocsPages(repoRoot).length).toBeGreaterThan(0); + expect(adrDocsPages(repoRoot)).toContain('docs/adr/0001-master-detail-subform.md'); + expect(auditDocsPages(repoRoot)).toContain('docs/audits/2026-07-objectview-detailview-schema.md'); + }); + + it('the widening is VISIBLE to the accounting: every block-bearing page in them is on the ledger', () => { + const state = analyze({}) as { + scans: Map; + covered: string[]; + }; + const legs = legPages(); + const withBlocks = legs.filter((doc) => (state.scans.get(doc)?.blocks.length ?? 0) > 0); + // Non-vacuous: these subtrees really do carry snippets, which is why they + // were worth bringing into the walk at all. + expect(withBlocks.length).toBeGreaterThan(0); + expect([...withBlocks].sort()).toEqual( + Object.keys(UNGATED_DOCS as Record) + .filter((doc) => legs.includes(doc)) + .sort(), + ); + // …and a page in these legs that holds NO block is covered, contributing + // nothing — it may not be ledgered (the stale-entry check would refuse it). + for (const doc of legs) { + if (!withBlocks.includes(doc)) expect(state.covered).toContain(doc); + } + }); + + it('and NOTHING in either leg is compiled — the ledger is the delivery, not a step toward one', () => { + const state = analyze({}) as { + compiled: { doc: string }[]; + declaredFragments: { doc: string }[]; + }; + const legs = new Set(legPages()); + expect(state.compiled.filter((b) => legs.has(b.doc))).toEqual([]); + expect(state.declaredFragments.filter((b) => legs.has(b.doc))).toEqual([]); + }); + + it('every ledger entry inside the two legs carries a measured count and names the record', () => { + const legs = new Set(legPages()); + const entries = Object.entries(UNGATED_DOCS as Record).filter(([doc]) => legs.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 the page is a record`).toMatch(/record/i); + } + }); + + it('no fragment marker was written inside either record subtree', () => { + for (const doc of legPages()) { + const { markers } = scanFences(fs.readFileSync(path.join(repoRoot, doc), 'utf8')) as { + markers: unknown[]; + }; + expect(markers, `${doc} carries a fragment marker — an edit inside a dated record`).toEqual([]); + } + }); + + it('recursive: true descends — a page filed deeper does not fall silently out of the walk', () => { + const root = tempTree({ + 'docs/PAGE.md': '# top level\n', + 'docs/adr/0001-decision.md': '# a\n', + 'docs/adr/superseded/0002-decision.md': '# filed deeper\n', + 'docs/adr/notes.txt': 'not a page\n', + 'docs/adr/assets/diagram.png': 'not a page\n', + 'docs/audits/2026-07-audit.md': '# b\n', + 'docs/audits/2026-08/split-audit.mdx': '# also a page\n', + 'docs/rfcs/0001-proposal.md': '# a THIRD subdirectory, in no leg\n', + }); + try { + expect(adrDocsPages(root)).toEqual([ + 'docs/adr/0001-decision.md', + 'docs/adr/superseded/0002-decision.md', + ]); + // Directories are visited at their own alphabetical position, so a nested + // page sorts by its DIRECTORY name rather than after every loose file. + expect(auditDocsPages(root)).toEqual([ + 'docs/audits/2026-07-audit.md', + 'docs/audits/2026-08/split-audit.mdx', + ]); + // Where the surface stops, asserted rather than assumed: `docs/rfcs/` is in + // no leg, so nothing collects it. + expect(listDocuments(root)).not.toContain('docs/rfcs/0001-proposal.md'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('an absent subtree yields nothing here, and a verdict is refused in main', () => { + const root = tempTree({ 'docs/PAGE.md': '# top level\n' }); + try { + // A throwaway fixture tree stays listable… + expect(adrDocsPages(root)).toEqual([]); + expect(auditDocsPages(root)).toEqual([]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + // …while a REAL run refuses, exactly as a missing `docs/` or a dangling + // ROOT_PAGES name does. `main()` takes no `--root`, so this is pinned against + // the source for the same reason those two are. + const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-snippet-types.mjs'), 'utf8'); + expect(source).toMatch(/for \(const tree of \[ADR_DOCS, AUDIT_DOCS\]\) \{\n\s*if \(!existsSync\(join\(repoRoot, tree\.dir\)\)\) \{/); + expect(ADR_DOCS).toEqual({ dir: 'docs/adr', recursive: true }); + expect(AUDIT_DOCS).toEqual({ dir: 'docs/audits', recursive: true }); + }); +}); + 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 b99a56855b..2e506a998a 100644 --- a/scripts/check-doc-snippet-types.mjs +++ b/scripts/check-doc-snippet-types.mjs @@ -369,10 +369,20 @@ * SURFACE is stated in the same breath as the coverage rule rather than left to * be read off the collector: * - * every `.mdx` and `.md` page under `content/docs`, every - * `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`. + * 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`. + * + * ⚠️ 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 + * that carries a `ts` / `tsx` block is named in `UNGATED_DOCS` below with its + * measured count: an ADR and a dated audit are RECORDS, so a block inside one is + * not this gate's to repair (see those constants). What the widening delivers is + * objectui#5174's distinction and only that — a named debt with a number instead + * of a tree that no accounting could mention. * * 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 @@ -509,9 +519,13 @@ const DOC_EXTENSIONS = ['.mdx', '.md']; * 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. + * So the enumeration below is by DIRECTORY ENTRY and filtered to FILES, and that + * is what made card 2 an edit a reviewer sees rather than a side effect of a page + * being moved into a subdirectory: the two subtrees arrived as `ADR_DOCS` and + * `AUDIT_DOCS` below, LEDGER-FIRST, and this leg is byte-for-byte the leg card 1 + * landed. Re-measured on the current tree, the diagnostics that paragraph + * predicted are exactly the ones they brought — which is why they arrived on the + * ledger instead of in front of a pull request expected to fix them. * * 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 @@ -542,6 +556,96 @@ export function rootDocsPages(root) { .map((entry) => `${ROOT_DOCS.dir}/${entry}`); } +/** + * The two subtrees BELOW that top level (objectui#7856, card 2). + * + * Card 1 stopped at `docs/`'s top level and said why: `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. A `**`-shaped walk from `docs/` would have + * pulled both into a gate whose failures a non-governed pull request is expected + * to fix — "which is how a widening turns into a change nobody can land". + * + * Card 2 is that landing, and it arrives LEDGER-FIRST rather than repair-first. + * The 2026-09-07 triage ruling on objectui#7856 is the reason, and it is a + * statement about the DOCUMENTS rather than about cost: + * + * `docs/audits/**` are dated audit snapshots — records of what was true on + * their date. "Repairing" a code block inside one falsifies the record, + * exactly as it would inside an ADR. + * + * So an ADR's or an audit's block is not a block this gate may ask an author to + * fix on its own authority. What the widening buys is the objectui#5174 + * distinction and nothing more: a document inside the walk and named on the + * ledger is a KNOWN debt with a measured count, where a document outside the walk + * is "neither covered NOR declared ungated" — invisible to this gate's own + * accounting, which is strictly worse. Paying the debt down is a separate, + * per-record decision for whoever owns the record. + * + * `recursive: true` here, against `ROOT_DOCS`' `false`, and the asymmetry is the + * point rather than an inconsistency. Card 1's leg is non-recursive because its + * subdirectories are a DIFFERENT review route; these two legs ARE that route, so + * inside them there is nothing left to stop above. A page filed under + * `docs/adr/superseded/` tomorrow travels into the walk by itself instead of + * falling silently out of it — which is objectui#7115's defect, one level down. + * + * ⚠️ Where the walk still stops, stated rather than left to be read off the + * collector: a THIRD subdirectory of `docs/` — one that is neither of these two — + * is in no leg, exactly as `docs/adr` was before this card. `docs/screenshots/` + * is today's example and holds no page (it is `.png`), so the gap is currently + * empty; a new prose subtree under `docs/` is an edit to this file that a + * reviewer sees, never a side effect of a directory being created. + * + * Exported — the constants and the enumerators both — so a sibling census can ask + * this gate what its legs contain instead of re-spelling them, which is what + * `check-doc-fence-languages.test.ts` does: that guard does NOT gain these legs + * (its walk is `check:doc-fences`' own surface, and moving it is not this card), + * and its walk-equality pin subtracts these enumerators BY IMPORT rather than a + * hand-written list of today's fifteen filenames. + */ +export const ADR_DOCS = { dir: 'docs/adr', recursive: true }; +export const AUDIT_DOCS = { dir: 'docs/audits', recursive: true }; + +/** + * Every page under one subtree constant, in a stable order. + * + * `recursive` is READ here rather than being a comment on the constant: a flag a + * reader can see and the walk ignores is the same false friend as a count nothing + * re-derives. Directories are visited at their own alphabetical position, files + * are filtered to `DOC_EXTENSIONS`, and an absent tree yields `[]` so a throwaway + * fixture stays listable — `main` refuses to publish a verdict when one of these + * directories is missing from a REAL run, for the reason `ROOT_DOCS`' guard + * states. + */ +function subtreeDocPages(root, tree) { + const base = join(root, tree.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 (tree.recursive) walk(p); + continue; + } + if (DOC_EXTENSIONS.some((ext) => entry.endsWith(ext))) + out.push(relative(root, p).split(sep).join('/')); + } + }; + walk(base); + return out; +} + +/** Every page under `ADR_DOCS.dir`, in a stable order. */ +export function adrDocsPages(root) { + return subtreeDocPages(root, ADR_DOCS); +} + +/** Every page under `AUDIT_DOCS.dir`, in a stable order. */ +export function auditDocsPages(root) { + return subtreeDocPages(root, AUDIT_DOCS); +} + /** 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. */ @@ -561,16 +665,21 @@ const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); * README.md ✓ ✓ ✓ objectui#7115 * packages//README.md ✓ ✓ ✗ ships inside `files` * docs/*.md (top level only) ✗ ✓ ✗ objectui#7856 card 1 + * docs/adr/** ✗ ✓ ✗ objectui#7856 card 2 + * docs/audits/** ✗ ✓ ✗ objectui#7856 card 2 * - * The `docs/*.md` row is the one leg THIS gate carries alone, and the asymmetry + * 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 * 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). + * Card 2 EXTENDS that subtraction with `adrDocsPages()` and `auditDocsPages()` + * rather than rewriting it — which is why card 1 exported its enumerator in the + * first place. ⛔ The two subtrees are their own rows, never this one: a leg says + * 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. * * `check-doc-component-types` does not read the package READMEs — it asks * whether a documented `type` literal is a registered component key, and a @@ -581,9 +690,7 @@ 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/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 + * `packages/**` (by far the largest); 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/**`; @@ -603,7 +710,14 @@ 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?$|\.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 + * subtrees rather than `docs/`, so a third one shows up in the command's output + * on the day it is created. Today there is none carrying prose + * (`docs/screenshots/` is images), which is exactly why it is written down now + * rather than discovered later. * * ⛔ `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 @@ -615,16 +729,43 @@ const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); * Documents whose snippets are NOT compiled, each with the reason. The default * is covered; this list is the debt, by name, and it can only shrink. * - * ⚠️ The ledger is EMPTY, and that is objectui#5174's finished state rather than a - * gap: every document the collector reaches is in the covered tier, so the default - * is now the only tier. There were 19 `.md` entries under `content/docs` when that + * ⚠️ The ledger holds objectui#7856 card 2 and NOTHING ELSE, and the distinction + * matters more than the length. It had reached zero — objectui#5174's finished + * state, every document the collector reached sitting in the covered tier — and + * card 2 did not re-open it by parking a page that failed. It re-opened it because + * the two subtrees it brought into the walk are RECORDS: an ADR states what was + * decided on a date, an audit states what was true on a date, and the 2026-09-07 + * triage ruling on objectui#7856 drew the consequence in one line — + * + * "Repairing" a code block inside one falsifies the record, exactly as it + * would inside an ADR. + * + * ⇒ For those two subtrees the ledger is not a deferral of the work, it IS the + * work: the honest terminal state for a block nobody may rewrite is a named debt + * carrying a measured number, not a green. Every entry below therefore says what + * was measured, when, and — unlike every entry that came before it — that paying + * it down is a decision for whoever owns the record rather than a task waiting + * for a spare afternoon. + * + * ⚠️ The counts in those entries are DATED MEASUREMENTS, not re-derived values, + * and this ledger's own rule about numbers applies to them: nothing fails when + * one goes stale. What IS re-derived every run is the part that matters — the + * entry must name a document in the scan set that really holds a `ts` / `tsx` + * block, so an entry cannot outlive its subject. Re-measure with the gate's own + * analyzer (`analyze({ ungated: {} })` + `compileSnippets()`) against the built + * closure; do not hand-count fences. + * + * ⛔ None of that licenses a NEW entry outside those two subtrees. For every + * other document the default is still COVERED, a new entry is still new debt, and + * it still owes a reason that says WHAT would have to change. There were 19 `.md` + * entries under `content/docs` when that * card made them visible — the collector reads `.md`, and an entry with a measured * reason is what a page that cannot pass yet is owed — and the card then walked * every one of them, then the `.mdx` pages, then the package READMEs, and last the * root `README.md`, back OFF this list rather than re-wording their reasons. Each * page left by compiling, never by softening this gate. * - * ⛔ An empty object is NOT an invitation to park the next page that fails. A new + * ⛔ This list is NOT an invitation to park the next page that fails. A new * entry is new debt and owes the same thing every entry above owed: a reason that * says WHAT would have to change, measured on the page rather than estimated. The * sentence this replaces carried the literal `12 .mdx pages and 32 package @@ -748,7 +889,58 @@ const TS_FENCE_LANGUAGES = new Set(['ts', 'tsx', 'typescript']); * * @type {Record} */ -const UNGATED_DOCS = {}; +const UNGATED_DOCS = { + // 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 + // phases. Four records, five `ts` blocks, 29 diagnostics — 21 syntax-phase and + // 8 semantic — split 26 under `docs/adr/**` and 3 under `docs/audits/**`. + // + // ⚠️ Read the syntax-phase entries for what they do NOT say. A block that fails + // to PARSE never enters the semantic program, so its semantic half is not "0", + // it is UNMEASURED — the count in each entry is the whole of what is known, and + // making such a block parse is the only way to learn what else is wrong with it. + // That is a fact about the instrument, and it is also the reason a marker would + // buy nothing here: an ungated document is not compiled at all, so a + // `FRAGMENT_MARKER` inside one of these records would declare a block that this + // gate already never reads — "a marker on a block the gate no longer collects is + // debt nothing would ever fail to prompt the removal of", one level up. ⇒ Card 2 + // wrote no marker and edited no record. + 'docs/adr/0001-master-detail-subform.md': + '2 `ts` blocks (fences 142, 211), 15 diagnostics, ALL syntax-phase: TS1005 x5, TS1109 x8, TS1011 x1 — ' + + 'the semantic half is unmeasured, not clean. Both blocks are schema SKETCHES written in prose ' + + 'TypeScript — bare object literals at statement position, `?:` optionality markers written on values, ' + + "`[...]` elisions, and `'create' | 'edit'` standing where a value goes. What would have to change: each " + + 'sketch rewritten as a real annotated declaration resolving against the shipped `dist/*.d.ts`, or its ' + + 'fence relabelled to a language this gate does not compile. Neither is this gate\'s to do: ADR-0001 is ' + + 'the dated record of an accepted decision (2026-06-05), and a sketch edited until it compiles is a ' + + 'record that no longer says what was decided (objectui#7856 triage, 2026-09-07).', + 'docs/adr/0036-field-conditional-rules.md': + '1 `ts` block (fence 178), 3 diagnostics, ALL syntax-phase: TS1005 x2, TS1109 x1 — the semantic half is ' + + 'unmeasured, not clean. The block is a fragment of a field map: three `Field.*` property assignments ' + + 'with no surrounding object and no import, which TypeScript reads as labelled statements. What would ' + + 'have to change: the excerpt wrapped in the declaration it is excerpted FROM and `Field` imported, ' + + 'which edits what ADR-0036 shows it decided (accepted 2026-06-07) rather than repairing it — the record ' + + 'is the excerpt, so a bigger excerpt is a different record.', + 'docs/adr/0057-console-ai-chat-one-conversation-docked.md': + '1 `ts` block (fence 192), 8 diagnostics, ALL semantic-phase: TS7006 x1, TS7031 x3, TS7053 x1, TS2304 x3 ' + + '— the ONE block in either subtree that parses, so this is the only entry here whose number is complete. ' + + 'The block is the ADR\'s resolver sketch: unannotated parameters and a destructured options bag ' + + '(implicit any), and three helpers it names but does not define (`isBuildAgent`, `resolveAgentParam`, ' + + '`resolveDefaultAgentName`). What would have to change: parameter types written and the three helpers ' + + 'imported from wherever the console ships them. ⚠️ That is a repair this gate could describe and must ' + + 'not ask for: ADR-0057 is accepted and IMPLEMENTED IN FULL (2026-07-13), so its sketch is the record of ' + + 'the resolver that was agreed, and typing it against today\'s console would silently restate the record ' + + 'as whatever shipped.', + 'docs/audits/2026-07-objectview-detailview-schema.md': + '1 `ts` block (fence 66), 3 diagnostics, ALL syntax-phase: TS1005 x3 — the semantic half is unmeasured, ' + + 'not clean. The block is a shape excerpt of the ADR-0047 container: a bare brace-delimited list of ' + + '`key: Schema` pairs, which is a block statement rather than an object literal. What would have to ' + + 'change: the excerpt given a declaration to be the initialiser of, and the four schema names imported. ' + + '⛔ Ungoverned is not the same as safe to repair: this is a DATED audit snapshot (2026-07) of what the ' + + 'two schemas were at `@objectstack/spec` 16.1.0, so editing the excerpt to compile against today\'s ' + + 'types would make the record assert something it never measured (objectui#7856 triage, 2026-09-07).', +}; // ── Fence scanning ─────────────────────────────────────────────────────────── @@ -950,8 +1142,14 @@ export function listDocuments(root = repoRoot) { } // 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. + // (governed) and `docs/audits/**` cannot arrive through THIS leg by accident — + // they arrive through their own, immediately below, which is card 2. out.push(...rootDocsPages(root)); + // The two subtrees below it (objectui#7856 card 2), each its own leg for the + // same reason the leg above is not recursive: a scan surface says where it + // stops, and these two say it separately from the tree that contains them. + out.push(...adrDocsPages(root)); + out.push(...auditDocsPages(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. @@ -2179,6 +2377,22 @@ function main() { return EXIT_CODES.couldNotRun; } + // And for card 2's two subtree legs, for exactly the same reason. They are + // checked as a PAIR because they arrived as one: a rename that took only one of + // them out would leave the other's count looking healthy, which is the shape + // this guard exists to refuse. + for (const tree of [ADR_DOCS, AUDIT_DOCS]) { + if (!existsSync(join(repoRoot, tree.dir))) { + console.error( + `This gate's scan surface names \`${tree.dir}/\`, which does not exist under ${repoRoot}. That ` + + 'subtree is part of the surface objectui#7856 card 2 widened onto, so its absence silently ' + + 'narrows the surface back and every count below would still look healthy. Re-point the leg at ' + + "the tree's new path, or remove it deliberately.", + ); + return EXIT_CODES.couldNotRun; + } + } + const state = analyze({}); if (argv.includes('--build-filter')) {