From 4a3d6f26d70baf046c1dcf3f18121f02f472ed3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:53:16 +0000 Subject: [PATCH 1/2] feat(scripts): the JSON-fence gate reads the published READMEs (objectui#12131, phase 2) `check-doc-json-fences.mjs` judged `content/docs` only, so the README examples phase 1 repaired were outside every strict-face check. Its scan surface is now one declared list, `SCAN_SURFACE`: the content tree, every `packages/NAME/README.md` and the root `README.md`. The two README legs are imported from `check-doc-component-types.mjs`, the doc-gate family's spelling of them. Each leg carries an anchor page that must contribute a judged node document, so a README leg that reads nothing exits 2. The summary prints the surface leg by leg. `docs/audits/**`, `skills/**`, CHANGELOGs, changesets and READMEs below a package root stay off. Claude-Session: https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W Co-authored-by: Claude --- content/docs/guide/ci-cd-pipeline.md | 25 ++- .../__tests__/check-doc-json-fences.test.ts | 92 ++++++++- scripts/check-doc-json-fences.mjs | 190 ++++++++++++++---- scripts/markdown-test-inputs.mjs | 11 +- 4 files changed, 256 insertions(+), 62 deletions(-) diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index c96a055c12..e32b8a657d 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -1174,7 +1174,7 @@ copying it out of an IDE hover got TS2322, and nothing in this repository had ev | `pnpm check:doc-snippets` (`scripts/check-doc-snippet-types.mjs`) | fenced `ts` / `tsx` blocks in the **documents** the `UNGATED_DOCS` ledger does not exempt | **Yes** | | `pnpm check:doc-snippets --emit-census` | code this repository's generators emit from **template literals** | No — report-only | | `pnpm check:doc-examples` (`scripts/check-doc-example-types.mjs`) | fenced `ts` / `tsx` blocks in JSDoc **`@example`** tags on exported declarations under `packages/NAME/src/**` | **Yes** | -| `pnpm check:doc-json` (`scripts/check-doc-json-fences.mjs`) | fenced `json` / `jsonc` **node documents** in `content/docs`, judged through the strict authoring face | **Yes** | +| `pnpm check:doc-json` (`scripts/check-doc-json-fences.mjs`) | fenced `json` / `jsonc` **node documents** in `content/docs`, every `packages/NAME/README.md` and the root `README.md`, judged through the strict authoring face | **Yes** | **Why it shares this job rather than getting a workflow.** It is a *sibling* of the gate above, not a fork: it imports that script's compiler host, its built-`.d.ts` resolution and every one of its @@ -1218,17 +1218,27 @@ The job's **last** step runs `scripts/check-doc-json-fences.mjs` (`pnpm check:do gates above read `ts` / `tsx` only, so a `json` fence was checked by nothing: [#5229](https://github.com/objectstack-ai/objectui/issues/5229) taught a `validation` bag on an `input` node in `content/docs/blocks/forms.mdx` while every shipped tool was green. Every fenced -`json` / `jsonc` block under `content/docs` that parses into an object whose `type` names a node +`json` / `jsonc` block on its scan surface that parses into an object whose `type` names a node schema is judged through the **strict authoring face** — `StrictAnyComponentSchema`, read from the `@object-ui/types` the build step above produced, the face `objectui validate` and `objectui check` judge an authored document with ([#5250](https://github.com/objectstack-ai/objectui/issues/5250)). So a key no schema declares fails here exactly as it fails `objectui validate`; the tolerant face keeps it, because `BaseSchemaCore` is `.passthrough()` for the renderer. +**What it reads.** Three legs, declared once in the script's `SCAN_SURFACE` and printed leg by leg +in its summary: every `.mdx` / `.md` page under `content/docs`, every package's own `README.md` +(`packages/NAME/README.md`), and the root `README.md`. The two README legs are the ones the other doc +gates walk, imported from the component-type gate rather than spelled a second time. They joined in +[#12131](https://github.com/objectstack-ai/objectui/issues/12131), after the root README's form and +dashboard examples and a table in `packages/plugin-dashboard/README.md` were refused by +`objectui validate` while this gate was green. Pages off that surface are not read, and the summary +says so: `docs/audits/**` (point-in-time records, kept out on purpose), `skills/**`, CHANGELOGs, +changesets, and READMEs below a package root, which are published only when a package's `files` +reaches them. + **What it does not judge, it counts.** A fence that is not JSON, not an object, carries no string `type`, or names a `type` no node schema declares is not judged; the summary prints each bucket, and -`--list` prints every fence with its bucket. Pages outside `content/docs` are not read, and the -summary says so. +`--list` prints every fence with its bucket. **Fragments are declared, and the declaration is re-judged.** A node document that is deliberately not complete or not valid — a refused example that shows the error, an excerpt that elides required @@ -1240,9 +1250,10 @@ one that is not a node document at all, is reported as stale, so the declaration **It proves itself before it judges the docs.** The face must resolve inside `packages/types/dist/`; a document carrying an undeclared key must be refused by the strict face and accepted by the tolerant one; a declared-only document must be accepted; and the walk must judge at least one node -document, including one from its anchor page. Its exit codes are the sibling gates' three: `0` -clean, `1` the gate ran and found errors, `2` it **could not run** — `@object-ui/types` unbuilt, or -a control or the enumeration failed — which is neither green nor red. +document, including one from each leg's anchor page, so a README leg that read nothing cannot pass. +Its exit codes are the sibling gates' three: `0` clean, `1` the gate ran and found errors, `2` it +**could not run** — `@object-ui/types` unbuilt, or a control or the enumeration failed — which is +neither green nor red. **If it fails:** each refusal names the page, the line of its fence, and each reason — an undeclared key by name and path, anything else by the face's own message. Remove the key, or replace it with diff --git a/scripts/__tests__/check-doc-json-fences.test.ts b/scripts/__tests__/check-doc-json-fences.test.ts index ebb4d808fb..7318a453bb 100644 --- a/scripts/__tests__/check-doc-json-fences.test.ts +++ b/scripts/__tests__/check-doc-json-fences.test.ts @@ -14,6 +14,8 @@ import { FRAGMENT_MARKER, FRAGMENT_MARKER_EXAMPLES, REFUSED_CONTROL, + SCAN_SURFACE, + SURFACE_ANCHORS, analyze, classifyFence, controlFailures, @@ -28,6 +30,7 @@ import { FRAGMENT_MARKER as SNIPPET_FRAGMENT_MARKER, FRAGMENT_MARKER_EXAMPLES as SNIPPET_FRAGMENT_MARKER_EXAMPLES, } from '../check-doc-snippet-types.mjs'; +import { packageReadmePages as familyPackageReadmes, ROOT_PAGES as FAMILY_ROOT_PAGES } from '../check-doc-component-types.mjs'; /** * objectui#5250 — the JSON-fence gate the maintainer ruling orders "on top of" @@ -87,8 +90,8 @@ function tempTree(files: Record): string { const FENCE = '```'; const fence = (lang: string, value: unknown): string => [`${FENCE}${lang}`, JSON.stringify(value, null, 2), FENCE].join('\n'); -const run = (files: Record, anchor: string | null = null) => - analyze({ root: tempTree(files), judge, tolerant, nodeTypes, parseJsonc, anchor }); +const run = (files: Record, anchors: readonly string[] | null = null) => + analyze({ root: tempTree(files), judge, tolerant, nodeTypes, parseJsonc, anchors }); const reasons = (r: { findings: { reason: string }[] }) => r.findings.map((f) => f.reason); describe('scanJsonFences — what the gate reads', () => { @@ -216,14 +219,88 @@ describe('analyze — the verdicts, against the real face', () => { it('ANTI-IDLE: an enumeration that judged nothing, or lost its anchor, is a precondition, never a green', () => { expect(run({ 'content/docs/a.mdx': fence('json', { name: 'not a node' }) }).preconditions.map((p) => p.reason)).toEqual(['nothing-judged']); - expect(run({ 'content/docs/a.mdx': fence('json', { type: 'button', label: 'Go' }) }, 'content/docs/anchor.mdx').preconditions.map((p) => p.reason)).toEqual([ + expect(run({ 'content/docs/a.mdx': fence('json', { type: 'button', label: 'Go' }) }, ['content/docs/anchor.mdx']).preconditions.map((p) => p.reason)).toEqual([ 'anchor-unread', ]); - const empty = analyze({ root: tempTree({ 'README.md': '# not on the surface' }), judge, tolerant, nodeTypes, parseJsonc, anchor: null }); + // Only pages OFF the surface (objectui#12131): a record, a README below a + // package root, a CHANGELOG, a changeset and a skill page. Each holds a + // refused node document, so a leg that reached any of them would report it. + const refusedDoc = fence('json', { type: 'button', label: 'Go', nonsense: 1 }); + const empty = run({ + 'docs/audits/2026-01-record.md': refusedDoc, + 'packages/demo/src/renderers/README-KANBAN.md': refusedDoc, + 'packages/demo/src/README.md': refusedDoc, + 'packages/demo/CHANGELOG.md': refusedDoc, + '.changeset/a-change.md': refusedDoc, + 'skills/demo/README.md': refusedDoc, + }); + expect(empty.documents).toEqual([]); + expect(empty.findings).toEqual([]); expect(empty.preconditions.map((p) => p.reason)).toEqual(['empty-enumeration']); }); }); +/** + * objectui#12131 — the published READMEs joined the scan surface, because this + * gate was green while three README examples were refused by `objectui + * validate`. Each test below goes RED against the content-docs-only surface the + * gate had before: that surface read neither README, so it reported nothing and + * judged nothing there. + */ +describe('the scan surface — the published READMEs (objectui#12131)', () => { + const refusedDoc = fence('json', { type: 'button', label: 'Go', action: { type: 'submit' } }); + const acceptedDoc = fence('json', { type: 'button', label: 'Go' }); + + it('RED: refuses a node document in a package\'s own README and in the root README, and names each', () => { + const r = run({ 'content/docs/a.mdx': acceptedDoc, 'packages/demo/README.md': refusedDoc, 'README.md': refusedDoc }); + expect(reasons(r)).toEqual(['refused', 'refused']); + // fixture-address: pages in the temp fixture tree this test writes, as the gate under test reports them + expect(r.findings.map((f) => f.site)).toEqual(['packages/demo/README.md:1', 'README.md:1']); + expect(r.findings.map((f) => f.issues)).toEqual([['at (root): undeclared key "action"'], ['at (root): undeclared key "action"']]); + }); + + it('walks the legs in their declared order and counts each leg\'s pages', () => { + const r = run({ 'content/docs/a.mdx': acceptedDoc, 'packages/demo/README.md': acceptedDoc, 'README.md': acceptedDoc }); + expect(r.legs.map((l) => l.leg)).toEqual(SCAN_SURFACE.map((l) => l.leg)); + expect(r.legs.map((l) => l.pages)).toEqual([['content/docs/a.mdx'], ['packages/demo/README.md'], ['README.md']]); + expect(r.documents).toEqual(['content/docs/a.mdx', 'packages/demo/README.md', 'README.md']); + expect(r.accepted).toHaveLength(3); + }); + + it('takes a README fragment declared with the HTML-comment marker, and judges a declaration there as anywhere else', () => { + const declared = run({ 'packages/demo/README.md': [FRAGMENT_MARKER_EXAMPLES[1], refusedDoc].join('\n') }); + expect(declared.findings).toEqual([]); + expect(declared.declared).toHaveLength(1); + + const stale = run({ 'README.md': [FRAGMENT_MARKER_EXAMPLES[1], acceptedDoc].join('\n') }); + expect(reasons(stale)).toEqual(['stale-fragment-marker']); + }); + + it('ANTI-IDLE: every leg owes its anchor — a README leg that collected nothing is a precondition, never a green', () => { + const r = run({ [ANCHOR_DOCUMENT]: acceptedDoc }, SURFACE_ANCHORS); + expect(r.judged).toHaveLength(1); + expect(r.preconditions.map((p) => `${p.reason} ${p.site}`)).toEqual( + SURFACE_ANCHORS.filter((a) => a !== ANCHOR_DOCUMENT).map((a) => `anchor-unread ${a}`), + ); + }); + + it('every leg names an anchor, and the anchors are one per leg', () => { + expect(SURFACE_ANCHORS).toEqual(SCAN_SURFACE.map((l) => l.anchor)); + expect(new Set(SURFACE_ANCHORS).size).toBe(SCAN_SURFACE.length); + expect(SURFACE_ANCHORS).toContain(ANCHOR_DOCUMENT); + }); + + it('its README legs are the doc-gate family\'s, read from the real tree', () => { + const docs = listDocuments(repoRoot); + const family = familyPackageReadmes(repoRoot).map((abs: string) => path.relative(repoRoot, abs).split(path.sep).join('/')); + // Non-vacuous: an empty family leg would make the equality below pass. + expect(family.length, 'the family collected no package README').toBeGreaterThan(10); + expect(docs.filter((d) => /^packages\/[^/]+\/README\.md$/.test(d))).toEqual(family); + for (const name of FAMILY_ROOT_PAGES) expect(docs).toContain(name); + expect(docs.filter((d) => d.startsWith('docs/') || d.startsWith('skills/') || /CHANGELOG\.md$/.test(d))).toEqual([]); + }); +}); + describe('controls — a probe that cannot fail is not a probe', () => { it('hold against the real faces', () => { expect(controlFailures({ judge, tolerant, nodeTypes })).toEqual([]); @@ -268,8 +345,11 @@ describe('one authority — the CLI door and the gate agree on every judged docu expect(new Set(judged.map((j) => judge(j.value).success))).toEqual(new Set([true, false])); }); - it(`reads its anchor page, ${ANCHOR_DOCUMENT}, from the real corpus`, () => { - expect(listDocuments(repoRoot)).toContain(ANCHOR_DOCUMENT); + it.each(SURFACE_ANCHORS)('reads its anchor page %s, and judges a node document there, from the real corpus', (anchor) => { + expect(listDocuments(repoRoot)).toContain(anchor); + const r = analyze({ root: repoRoot, judge, tolerant, nodeTypes, parseJsonc, anchors: [anchor] }); + expect(r.preconditions).toEqual([]); + expect(r.judged.some((j: { doc: string }) => j.doc === anchor)).toBe(true); }); }); diff --git a/scripts/check-doc-json-fences.mjs b/scripts/check-doc-json-fences.mjs index fe3a4cf7cb..62fef31206 100644 --- a/scripts/check-doc-json-fences.mjs +++ b/scripts/check-doc-json-fences.mjs @@ -1,8 +1,10 @@ #!/usr/bin/env node /** - * Every fenced `json` / `jsonc` NODE DOCUMENT in `content/docs` must pass the - * STRICT AUTHORING FACE — `StrictAnyComponentSchema`, the face `objectui - * validate` and `objectui check` judge an authored document with. + * Every fenced `json` / `jsonc` NODE DOCUMENT on this gate's scan surface — + * `content/docs`, every package's own `README.md` and the root `README.md` + * (`SCAN_SURFACE`) — must pass the STRICT AUTHORING FACE — + * `StrictAnyComponentSchema`, the face `objectui validate` and `objectui + * check` judge an authored document with. * * Run: node scripts/check-doc-json-fences.mjs (also `pnpm check:doc-json`) * node scripts/check-doc-json-fences.mjs --list (also print every fence and @@ -16,8 +18,8 @@ * 2 = THE GATE COULD NOT RUN, so nothing printed above is a verdict about * any document: `@object-ui/types` is not built, the JSONC reader cannot * be resolved, a control failed, or the enumeration came back empty or - * without its anchor page. Fix the tree and re-run. Never read this as a - * documentation defect, and never as a pass. + * without one of its legs' anchor pages. Fix the tree and re-run. Never + * read this as a documentation defect, and never as a pass. * * The three codes are the sibling gate's (`scripts/check-doc-snippet-types.mjs`, * its "Why could not run is its own exit code" section), for the same reason. @@ -59,6 +61,46 @@ * workflow (`doc-snippet-types.yml`) runs this gate after it has built the * snippet gate's `--build-filter` closure, which carries `@object-ui/types`. * + * ## The scan surface, declared once (objectui#12131) + * + * `SCAN_SURFACE` below is everything this gate reads: one leg per entry, in + * walk order, and the summary prints it leg by leg with the pages each leg + * collected. Three legs: + * + * 1. every `.mdx` / `.md` page under `content/docs` — the population + * objectui#5250's measurement priced; + * 2. every package's own `README.md`, `packages/NAME/README.md`; + * 3. the root `README.md`. + * + * Legs 2 and 3 are the published READMEs, and they joined because this gate + * was green while three README examples were refused by `objectui validate` + * (objectui#12131): the root README's form and dashboard, and a table in + * `packages/plugin-dashboard/README.md`. That card's first phase repaired the + * three; these legs keep them covered. Both legs are IMPORTED from + * `check-doc-component-types.mjs` (`PACKAGE_READMES` / `packageReadmePages`, + * `ROOT_PAGES`) rather than spelled again here: they are the README legs the + * doc-gate family already walks, so this gate reads the READMEs its siblings + * read, and a change to the family's legs moves this gate with them. + * + * Why a package's OWN README and not every `README*.md` in the tree: the own + * README is the page published with the package — npm packs it whatever + * `files` says, and `vsce publish` makes the VS Code extension's its + * Marketplace page, so a `private` flag is not a "not published" predicate. A + * README below a package root is published only when the package's `files` + * reaches it. When objectui#12131 measured that with `npm pack --dry-run` per + * package, none did — a dated reading, ⛔ not re-derived by this gate. So the + * deliberately refused `onCardMove` example in `README-KANBAN.md`, under + * `packages/components/src/renderers/complex/`, stays off the surface with no + * marker. If that page ever ships, it joins and owes a `doc-json: fragment` + * declaration. + * + * ⛔ Off the surface on purpose, and not to be widened onto as a side effect: + * `docs/audits/**` — point-in-time records, not teaching surfaces; triage on + * objectui#12131 kept them out of this scan, and a block inside a record is + * not this gate's to repair. CHANGELOGs and `.changeset/` are history. + * `skills/**` is a governed, published surface whose fences belong to its + * owner (objectui#12135). + * * ## What a "node document" is — the population, stated * * The predicate `scripts/measure-strict-authoring-face.mjs` measured the @@ -127,11 +169,16 @@ * ACCEPTS a declared-only document MUST be accepted. A harness broken the * other way reds every page at once and reads as "the docs are * full of defects". - * ANTI-IDLE the walk must judge at least one node document, and - * `ANCHOR_DOCUMENT` — the page objectui#5229 was found on — must - * contribute one. An enumeration that collapsed (a moved root, a - * broken language filter, a scanner that stopped seeing fences) - * exits 2 instead of printing a green over nothing. + * ANTI-IDLE the walk must judge at least one node document, and EVERY leg + * of `SCAN_SURFACE` carries an `anchor` page that must contribute + * one: `ANCHOR_DOCUMENT` — the page objectui#5229 was found on — + * for `content/docs`, and for each README leg a README + * objectui#12131 repaired. An enumeration that collapsed (a moved + * root, a leg that stopped collecting, a broken language filter, a + * scanner that stopped seeing fences) exits 2 instead of printing + * a green over nothing. A leg that collects no page cannot hand in + * its anchor, so a README leg that silently judged zero READMEs is + * this control failing, not a green. * * ## What this gate does NOT answer * @@ -140,11 +187,13 @@ * `scripts/check-doc-component-types.mjs`. * 3. Whether a `${…}` sits on a key `SchemaRenderer` evaluates — * `scripts/check-doc-expression-carriage.mjs` (report-only). - * 4. ⚠️ Any page outside `content/docs`. The package READMEs, the root - * `README.md`, the `apps//docs` trees and the root `docs/` tree are - * NOT READ here, and nothing about them is claimed by a green run. That is - * the population objectui#5250's measurement priced; widening it is a - * change of this constant and of the pages it would then read. + * 4. ⚠️ Any page off `SCAN_SURFACE`. The `apps//docs` trees, the root + * `docs/` tree (`docs/audits/**` included, deliberately — see "The scan + * surface"), a README below a package root, the READMEs under `apps/`, + * `examples/` and `patches/`, `skills/**`, CHANGELOGs and `.changeset/` + * are NOT READ here, and nothing about them is claimed by a green run. + * Widening is a change of `SCAN_SURFACE` and of the pages it would then + * read. * 5. Whether a key the face DECLARES is read by a renderer. A declared key * nothing reads passes here; that is the declaration side's question. */ @@ -160,6 +209,7 @@ import { quoteDepthOf, stripQuotePrefix, } from './check-doc-snippet-types.mjs'; +import { PACKAGE_READMES, ROOT_PAGES, packageReadmePages } from './check-doc-component-types.mjs'; import { isEntrypoint } from './invoked-as.mjs'; import { closesFence, openFence } from './markdown-fence-scan.mjs'; @@ -167,7 +217,7 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); // ── Configuration ──────────────────────────────────────────────────────────── -/** The scan surface. See the header's "does NOT answer" item 4. */ +/** The content tree — the first leg of `SCAN_SURFACE`. */ export const DOCS_ROOT = 'content/docs'; /** Page extensions collected under `DOCS_ROOT` — both, as the sibling gate collects. */ @@ -177,12 +227,64 @@ export const DOC_EXTENSIONS = ['.mdx', '.md']; export const JSON_FENCE_LANGUAGES = new Set(['json', 'jsonc']); /** - * A page that must contribute at least one judged node document on every run — - * the page objectui#5229 was found on. If it stops holding one (moved, renamed, - * or its fences rewritten away), name another; ⛔ never delete the control. + * The `content/docs` leg's anchor: a page that must contribute at least one + * judged node document on every run — the page objectui#5229 was found on. If + * it stops holding one (moved, renamed, or its fences rewritten away), name + * another; ⛔ never delete the control. */ export const ANCHOR_DOCUMENT = 'content/docs/blocks/forms.mdx'; +const toPosix = (root, abs) => relative(root, abs).split(sep).join('/'); + +/** Every page under `DOCS_ROOT`, repo-relative and POSIX-separated, in a stable order. */ +function contentDocsPages(root) { + const out = []; + const walk = (dir) => { + for (const entry of readdirSync(dir).sort()) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) walk(p); + else if (DOC_EXTENSIONS.some((ext) => entry.endsWith(ext))) out.push(toPosix(root, p)); + } + }; + const docsRoot = join(root, DOCS_ROOT); + if (existsSync(docsRoot)) walk(docsRoot); + return out; +} + +/** + * The scan surface, one leg per entry, in walk order — the header's "The scan + * surface, declared once" (objectui#12131). Each leg says what it reads + * (`leg`, the words the summary prints), lists its pages repo-relative + * (`pages(root)`), and names the page that must contribute a judged node + * document on every real run (`anchor`, the ANTI-IDLE control). The README + * legs come from `check-doc-component-types.mjs`, the family's one spelling of + * them, in the slot its siblings walk them: package READMEs before the root + * pages. + * + * Both README anchors are pages objectui#12131 repaired. If one stops holding a + * node document, name another page on the same leg; ⛔ never delete the control. + */ +export const SCAN_SURFACE = Object.freeze([ + Object.freeze({ + leg: `every ${DOC_EXTENSIONS.join(' / ')} page under ${DOCS_ROOT}/`, + anchor: ANCHOR_DOCUMENT, + pages: contentDocsPages, + }), + Object.freeze({ + leg: `every ${PACKAGE_READMES.dir}/NAME/${PACKAGE_READMES.name}`, + anchor: 'packages/plugin-dashboard/README.md', + pages: (root) => packageReadmePages(root).map((abs) => toPosix(root, abs)), + }), + Object.freeze({ + leg: `the root ${ROOT_PAGES.join(', ')}`, + anchor: 'README.md', + pages: (root) => ROOT_PAGES.filter((name) => existsSync(join(root, name))), + }), +]); + +/** Every leg's anchor — what `analyze` checks by default. */ +export const SURFACE_ANCHORS = Object.freeze(SCAN_SURFACE.map((l) => l.anchor)); + /** The package and entry the face is read from — the one `@object-ui/cli` imports. */ export const FACE_PACKAGE_DIR = 'packages/types'; export const FACE_ENTRY = './zod'; @@ -219,19 +321,14 @@ export const ACCEPTED_CONTROL = { type: 'button', label: 'Save' }; // ── Scanning ───────────────────────────────────────────────────────────────── +/** Each leg of `SCAN_SURFACE` with the pages it collects under `root`. */ +export function surfaceLegs(root = repoRoot) { + return SCAN_SURFACE.map(({ leg, anchor, pages }) => ({ leg, anchor, pages: pages(root) })); +} + /** Every page in the scan set, repo-relative and POSIX-separated, in a stable order. */ export function listDocuments(root = repoRoot) { - const out = []; - const walk = (dir) => { - for (const entry of readdirSync(dir).sort()) { - const p = join(dir, entry); - if (statSync(p).isDirectory()) walk(p); - else if (DOC_EXTENSIONS.some((ext) => entry.endsWith(ext))) out.push(relative(root, p).split(sep).join('/')); - } - }; - const docsRoot = join(root, DOCS_ROOT); - if (existsSync(docsRoot)) walk(docsRoot); - return out; + return surfaceLegs(root).flatMap((l) => l.pages); } /** @@ -411,8 +508,9 @@ export function describeIssue(issue) { * * `judge(document)` is the strict verdict (`{ success, error? }`); `tolerant` * is the tolerant face's, read for words only. Both are injected so the test - * suite can hand in the CLI's own door and the source face. `anchor: null` - * switches the anchor control off, for a fixture tree that has no anchor page. + * suite can hand in the CLI's own door and the source face. `anchors` defaults + * to every leg's anchor; `anchors: null` switches the anchor control off, for a + * fixture tree that has no anchor page. * * @param {{ * root?: string, @@ -420,13 +518,14 @@ export function describeIssue(issue) { * tolerant?: (doc: unknown) => { success: boolean }, * nodeTypes: Set, * parseJsonc: (text: string, errors: unknown[], options: object) => unknown, - * anchor?: string | null, + * anchors?: readonly string[] | null, * }} options */ -export function analyze({ root = repoRoot, judge, tolerant, nodeTypes, parseJsonc, anchor = ANCHOR_DOCUMENT }) { +export function analyze({ root = repoRoot, judge, tolerant, nodeTypes, parseJsonc, anchors = SURFACE_ANCHORS }) { const findings = []; const preconditions = []; - const documents = listDocuments(root); + const legs = surfaceLegs(root); + const documents = legs.flatMap((l) => l.pages); const buckets = { notJson: [], notAnObject: [], noTypeKey: [], unresolvedType: [] }; const judged = []; const accepted = []; @@ -500,15 +599,17 @@ export function analyze({ root = repoRoot, judge, tolerant, nodeTypes, parseJson } if (documents.length === 0) { - preconditions.push({ reason: 'empty-enumeration', site: DOCS_ROOT, detail: 'no page was found on the scan surface' }); + preconditions.push({ reason: 'empty-enumeration', site: 'SCAN_SURFACE', detail: 'no page was found on any leg of the scan surface' }); } else if (judged.length === 0) { - preconditions.push({ reason: 'nothing-judged', site: DOCS_ROOT, detail: 'the walk judged no node document at all — the enumeration collapsed, so a green here would be over nothing' }); + preconditions.push({ reason: 'nothing-judged', site: 'SCAN_SURFACE', detail: 'the walk judged no node document at all — the enumeration collapsed, so a green here would be over nothing' }); } - if (anchor && !judged.some((j) => j.doc === anchor)) { - preconditions.push({ reason: 'anchor-unread', site: anchor, detail: 'the anchor page contributed no judged node document — the scanner, the language filter or the page has moved; name another anchor, never delete the control' }); + for (const anchor of anchors ?? []) { + if (!judged.some((j) => j.doc === anchor)) { + preconditions.push({ reason: 'anchor-unread', site: anchor, detail: 'the anchor page contributed no judged node document — its leg, the scanner, the language filter or the page has moved; name another anchor on the same leg, never delete the control' }); + } } - return { documents, fenceCount, buckets, judged, accepted, declared, refused, findings, preconditions }; + return { legs, documents, fenceCount, buckets, judged, accepted, declared, refused, findings, preconditions }; } // ── Loading the face and the reader ────────────────────────────────────────── @@ -600,15 +701,16 @@ export async function main(argv = process.argv.slice(2), root = repoRoot) { } const unresolvedTypes = [...new Set(r.buckets.unresolvedType.map((b) => b.type))].sort(); + const unreadAnchors = new Set(r.preconditions.filter((p) => p.reason === 'anchor-unread').map((p) => p.site)); console.log( [ '', `JSON doc fences — strict authoring face: ${faceRelative} (StrictAnyComponentSchema, the face \`objectui validate\` and \`objectui check\` judge with)`, - ` scan surface: every ${DOC_EXTENSIONS.join(' / ')} page under ${DOCS_ROOT}/ — ${r.documents.length} page(s), ${r.fenceCount} json/jsonc fence(s)`, + ` scan surface: ${r.legs.map((l) => `${l.leg} (${l.pages.length} page(s))`).join(' · ')} — ${r.documents.length} page(s), ${r.fenceCount} json/jsonc fence(s)`, ` judged node documents: ${r.judged.length} — ${r.accepted.length} accepted, ${r.declared.length} declared fragment(s) the face refuses as declared, ${r.refused.length} refused`, ` not judged: ${r.buckets.notJson.length} not JSON · ${r.buckets.notAnObject.length} not an object · ${r.buckets.noTypeKey.length} without a string \`type\` · ${r.buckets.unresolvedType.length} with a \`type\` no node schema declares${unresolvedTypes.length ? ` (${unresolvedTypes.join(', ')})` : ''}`, - ` controls: RESOLUTION, REFUSES, ACCEPTS hold over ${nodeTypes.size} node type(s); anchor ${ANCHOR_DOCUMENT} ${r.preconditions.some((p) => p.reason === 'anchor-unread') ? 'NOT read' : 'read'}`, - ` NOT read: pages outside ${DOCS_ROOT}/ (see this script's header) — a green run claims nothing about them`, + ` controls: RESOLUTION, REFUSES, ACCEPTS hold over ${nodeTypes.size} node type(s); anchors ${SURFACE_ANCHORS.map((a) => `${a} ${unreadAnchors.has(a) ? 'NOT read' : 'read'}`).join(', ')}`, + ` NOT read: every page off that surface — docs/audits/**, skills/**, CHANGELOGs, .changeset/ and READMEs below a package root among them (see this script's header) — a green run claims nothing about them`, ].join('\n'), ); @@ -625,7 +727,7 @@ export async function main(argv = process.argv.slice(2), root = repoRoot) { ); return EXIT_CODES.documentsFailed; } - console.log('✅ Every judged JSON node document in the docs passes the strict authoring face.'); + console.log('✅ Every judged JSON node document on the scan surface passes the strict authoring face.'); return EXIT_CODES.verified; } diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index 251845d535..5ab49b60a1 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -820,11 +820,12 @@ export const ADJUDICATED = new Map([ 'scripts/__tests__/check-doc-json-fences.test.ts', { // objectui#5250: the agreement test walks the JSON-fence gate's own scan - // surface, `content/docs/**` (the CI pipeline guide it also reads by name - // is inside it). Its `README.md` is a file the test writes into a - // temporary fixture tree, never the root document. - reads: ['content/docs/**'], - notRead: ['README.md'], + // surface (the CI pipeline guide it also reads by name is inside it). + // objectui#12131 widened that surface to the published READMEs: the root + // `README.md` and every `packages/NAME/README.md`, a leg taken by IMPORT + // from `check-doc-component-types.mjs`, hence `packages/**` as in the + // carriage census's entry above. + reads: ['README.md', 'content/docs/**', 'packages/**'], walker: 'markdown-tree', }, ], From e297f3ad9ea6b05f506b05330c364544e8a8b0d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:54:05 +0000 Subject: [PATCH 2/2] test(scripts): the README-anchor control pin names both README legs (objectui#12131) The leg-anchor pin derived its expected list from SURFACE_ANCHORS alone, so a surface that lost both README legs expected nothing and stayed green. It now first asserts the root README and a package README are among the anchors. Claude-Session: https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W Co-authored-by: Claude --- scripts/__tests__/check-doc-json-fences.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/__tests__/check-doc-json-fences.test.ts b/scripts/__tests__/check-doc-json-fences.test.ts index 7318a453bb..1751234cdd 100644 --- a/scripts/__tests__/check-doc-json-fences.test.ts +++ b/scripts/__tests__/check-doc-json-fences.test.ts @@ -277,11 +277,13 @@ describe('the scan surface — the published READMEs (objectui#12131)', () => { }); it('ANTI-IDLE: every leg owes its anchor — a README leg that collected nothing is a precondition, never a green', () => { + const readmeAnchors = SURFACE_ANCHORS.filter((a) => a !== ANCHOR_DOCUMENT); + // Non-vacuous: both README legs exist, so there are anchors to fail. + expect(readmeAnchors).toContain('README.md'); + expect(readmeAnchors.some((a) => /^packages\/[^/]+\/README\.md$/.test(a))).toBe(true); const r = run({ [ANCHOR_DOCUMENT]: acceptedDoc }, SURFACE_ANCHORS); expect(r.judged).toHaveLength(1); - expect(r.preconditions.map((p) => `${p.reason} ${p.site}`)).toEqual( - SURFACE_ANCHORS.filter((a) => a !== ANCHOR_DOCUMENT).map((a) => `anchor-unread ${a}`), - ); + expect(r.preconditions.map((p) => `${p.reason} ${p.site}`)).toEqual(readmeAnchors.map((a) => `anchor-unread ${a}`)); }); it('every leg names an anchor, and the anchors are one per leg', () => {