Skip to content

Commit ce9811c

Browse files
feat(scripts): the JSON-fence gate reads the published READMEs (objectui#12131, phase 2) (#12139)
Fixes #12131 Clause-②: no Phase 2 of objectui#12131, the card's last part. Phase 1 landed as PR objectui#12134. This PR widens the scan surface of `scripts/check-doc-json-fences.mjs` from `content/docs` to the published READMEs, so the README examples phase 1 repaired stay covered by the strict authoring face. Triage `6106881179` called this a blind-spot repair inside the ruled programme (objectui#5250), not a new gate. ## What changed - **One declared scan surface.** `SCAN_SURFACE` in the gate lists three legs in walk order: every `.mdx` / `.md` page under `content/docs`, every `packages/NAME/README.md`, and the root `README.md`. `listDocuments` and the new `surfaceLegs` both read it, and the summary prints it leg by leg with each leg's page count. - **The README legs come from the doc-gate family.** `PACKAGE_READMES` / `packageReadmePages` and `ROOT_PAGES` are imported from `scripts/check-doc-component-types.mjs`, the same legs the snippet, fence-language and component-type gates walk. This gate does not spell them a second time. - **Every leg proves it read something (H2).** Each leg names an anchor page that must contribute a judged node document on every run: `content/docs/blocks/forms.mdx` (unchanged), `packages/plugin-dashboard/README.md` and `README.md`. A README leg that collects nothing cannot hand in its anchor, so the run exits 2 (could not run) instead of printing a green over zero READMEs. `analyze` takes `anchors` (default: every leg's anchor) where it took `anchor`. - **The gate describes itself accurately (H3).** The header's first sentence, a new section on the scan surface, the ANTI-IDLE control text, and the list of what the gate does not read now match the code. The summary's surface line comes from `SCAN_SURFACE`. Its NOT-read line names what stays off. In `content/docs/guide/ci-cd-pipeline.md`, the gate's table row and its section now say what it reads. The old sentence that pages outside `content/docs` are not read is replaced by a paragraph that names what is read and what is off. - **Off the surface on purpose:** `docs/audits/**` (triage: point-in-time records), CHANGELOGs and `.changeset/` (history), `skills/**` (objectui#12135 owns that fence), and READMEs below a package root. - `scripts/markdown-test-inputs.mjs`: the gate test's `ADJUDICATED` entry now reads `README.md`, `content/docs/**` and `packages/**`. The agreement test walks the widened surface. The `notRead: README.md` line is gone, because the root README is now really read. ## H1: which reading of "the published READMEs" (measured at `9b755099`) Each reading was judged with the gate's own scanner, predicate and the built strict face: | Reading | Pages | json/jsonc fences | Judged node documents | Newly refused | |---|---|---|---|---| | (a, npm-only) root `README.md` plus the root README of every package not marked `private` | 38 | 22 | 18 | 0 | | **(a) chosen:** root `README.md` plus every `packages/NAME/README.md` (the family leg) | **40** | **24** | **19** | **0** | | (b) every tracked `README*.md` in the tree | 56 | 30 | 23 | 1: the `object-kanban` `onCardMove` document in `packages/components/src/renderers/complex/README-KANBAN.md` | - **Why (a), not the npm-only spelling.** `private: true` means "not on npm", not "not published". `packages/vscode-extension` is npm-private, but `vsce publish` puts its README on the VS Code Marketplace, and that README teaches an authored `.objectui.json` node document (a `div` example, accepted). The npm-only spelling would drop that page. The one unpublished page (a) adds, `packages/test-support/README.md`, holds no json fence. (a) is also the leg the three sibling doc gates already walk. - **Phase 1's three repaired examples are inside (a), judged and accepted:** the root README's Contact Form `form`, its Dashboard `grid` of `statistic` tiles, and the `object-data-table` in `packages/plugin-dashboard/README.md`. - **Nested READMEs do not ship.** `npm pack --dry-run --json --ignore-scripts` in every package directory lists no README below a package root. `@object-ui/components` publishes `dist`, `README.md`, `CHANGELOG.md` and `LICENSE`. So `README-KANBAN.md` stays off the surface and needs no fragment marker. The gate's header records this as a dated reading that the gate does not re-derive. **Classification:** empty. The chosen reading newly refuses no README document, so no README is repaired or declared, and no README file is touched. ## Gate run, before and after - Base `9b755099`: 184 pages, 227 fences, 185 judged (179 accepted, 6 declared, 0 refused). - Head `e297f3ad`: 224 pages (content/docs 184, package READMEs 39, root README 1), 251 fences, 204 judged (198 accepted, 6 declared, 0 refused). All three anchors read. `node scripts/check-doc-json-fences.mjs` exit 0. ## Reverse verification (one-time ablation, not kept as a test) From committed head `e297f3ad`, `node ../objectstack/scripts/ablation-replace.mjs` deleted both README legs from `SCAN_SURFACE`. Anchor hits went from 1 to 0, and the blob changed from `62fef312061e` to `6b5e3d8793fa`. Readings with the mutation on disk: - The gate printed exactly the base surface (184 pages, 227 fences, 185 judged), with exit 0. A content-only surface is green about the READMEs. - `scripts/__tests__/check-doc-json-fences.test.ts`: **5 failed | 27 passed (32)**. The five that failed are the README tests: the refusal in a package README and the root README; the leg order and page counts; the README fragment declaration; the README anchor control; and the equality with the family's legs. - Restore was proven by the tool: the blob equals HEAD (`62fef312061e`) and `git diff HEAD` is empty. After restore: **34 passed (34)**. ## Tests and gates (all at `e297f3ad`) Exit codes were captured by redirect-then-read, never through a pipe. Heavy runs went through `os-verify-lock`, and each result below is its `VERDICT command-exit 0` line. - `pnpm exec vitest run scripts/__tests__/check-doc-json-fences.test.ts scripts/__tests__/markdown-test-inputs.test.ts scripts/__tests__/ci-cd-pipeline-doc.test.ts`: 3 files, 147 tests passed. - `pnpm exec vitest run --maxWorkers=2 scripts/__tests__/` plus the two `packages/components` tests that read the markdown ledger: 182 passed, 2 skipped (184 files); 5562 tests passed, 2 skipped. - `pnpm type-check:scripts`: exit 0. `--listFiles` shows the gate, its test and `markdown-test-inputs.mjs` in the program. - `pnpm check:doc-types` 0 · `pnpm docs:check-links` 0 · `pnpm check:new-line-citations` 0 (0 new) · `pnpm check:control-bytes` 0 · `pnpm check:doc-fences` 0 and `--self-test` 0 · `pnpm check:test-path-roots` 0 · `node scripts/markdown-test-inputs.mjs --audit` 0 · `node scripts/check-lint-coverage.mjs` 0 · `node scripts/check-vi-mock-override-shape.mjs` 0. - `pnpm lint:root`: exit 0 (0 errors; 34 warnings, all in files this PR does not touch). `eslint --format json` on the 3 changed script files: 3 files, 0 errors, 0 warnings. - `node scripts/check-changeset-presence.mjs`: exit 0, "no changeset is owed". No package source and no README changed. - `node scripts/check-governed-queue-guard.mjs --test` (4 paths): NOT GOVERNED. - First measured run of `ci-cd-pipeline-doc.test.ts`: red on command parity. My first wording credited the gate's section with a script path that `doc-snippet-types.yml` does not run. The page wording was changed, and the parity ledger was not touched. - Not run locally: `pnpm check:doc-snippets` and `pnpm check:doc-examples` (they need the full build closure, and this diff adds no ts/tsx fence), and lychee `check-links` (CI-only tool). CI runs them. ## Acceptance notes - **The refused population outside `content/docs`, re-derived at `9b755099`, not copied:** 16 node documents. 4 CHANGELOG fences and 1 pending changeset are history. 1 is a `docs/audits` record (out by triage). 9 are `skills/**` fences (objectui#12135's surface). 1 is `README-KANBAN.md`, which is off the surface and not shipped. None is on the widened surface. - **Stale prose noticed, not changed (carrier: none):** the `NESTED_PACKAGE_READMES` docblock in `scripts/check-doc-snippet-types.mjs` says `packages/types` lists the whole of `src/` in its manifest `files`, so `packages/types/src/zod/README.md` ships. Today `packages/types` `files` is `dist`, `README.md`, `CHANGELOG.md`, `LICENSE`, and npm pack ships no nested README. That file is outside this claim. - **Not changed:** the "What the three doc gates own" coverage table in that same file has no column for this gate. That is outside this claim (carrier: none). The session for this run is `https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W`. --- _Generated by [Claude Code](https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1d1cfe0 commit ce9811c

4 files changed

Lines changed: 258 additions & 62 deletions

File tree

‎content/docs/guide/ci-cd-pipeline.md‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,7 +1174,7 @@ copying it out of an IDE hover got TS2322, and nothing in this repository had ev
11741174
| `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** |
11751175
| `pnpm check:doc-snippets --emit-census` | code this repository's generators emit from **template literals** | No — report-only |
11761176
| `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** |
1177-
| `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** |
1177+
| `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** |
11781178

11791179
**Why it shares this job rather than getting a workflow.** It is a *sibling* of the gate above, not a
11801180
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
12181218
gates above read `ts` / `tsx` only, so a `json` fence was checked by nothing:
12191219
[#5229](https://github.com/objectstack-ai/objectui/issues/5229) taught a `validation` bag on an
12201220
`input` node in `content/docs/blocks/forms.mdx` while every shipped tool was green. Every fenced
1221-
`json` / `jsonc` block under `content/docs` that parses into an object whose `type` names a node
1221+
`json` / `jsonc` block on its scan surface that parses into an object whose `type` names a node
12221222
schema is judged through the **strict authoring face** — `StrictAnyComponentSchema`, read from the
12231223
`@object-ui/types` the build step above produced, the face `objectui validate` and `objectui check`
12241224
judge an authored document with ([#5250](https://github.com/objectstack-ai/objectui/issues/5250)).
12251225
So a key no schema declares fails here exactly as it fails `objectui validate`; the tolerant face
12261226
keeps it, because `BaseSchemaCore` is `.passthrough()` for the renderer.
12271227

1228+
**What it reads.** Three legs, declared once in the script's `SCAN_SURFACE` and printed leg by leg
1229+
in its summary: every `.mdx` / `.md` page under `content/docs`, every package's own `README.md`
1230+
(`packages/NAME/README.md`), and the root `README.md`. The two README legs are the ones the other doc
1231+
gates walk, imported from the component-type gate rather than spelled a second time. They joined in
1232+
[#12131](https://github.com/objectstack-ai/objectui/issues/12131), after the root README's form and
1233+
dashboard examples and a table in `packages/plugin-dashboard/README.md` were refused by
1234+
`objectui validate` while this gate was green. Pages off that surface are not read, and the summary
1235+
says so: `docs/audits/**` (point-in-time records, kept out on purpose), `skills/**`, CHANGELOGs,
1236+
changesets, and READMEs below a package root, which are published only when a package's `files`
1237+
reaches them.
1238+
12281239
**What it does not judge, it counts.** A fence that is not JSON, not an object, carries no string
12291240
`type`, or names a `type` no node schema declares is not judged; the summary prints each bucket, and
1230-
`--list` prints every fence with its bucket. Pages outside `content/docs` are not read, and the
1231-
summary says so.
1241+
`--list` prints every fence with its bucket.
12321242

12331243
**Fragments are declared, and the declaration is re-judged.** A node document that is deliberately
12341244
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
12401250
**It proves itself before it judges the docs.** The face must resolve inside `packages/types/dist/`;
12411251
a document carrying an undeclared key must be refused by the strict face and accepted by the
12421252
tolerant one; a declared-only document must be accepted; and the walk must judge at least one node
1243-
document, including one from its anchor page. Its exit codes are the sibling gates' three: `0`
1244-
clean, `1` the gate ran and found errors, `2` it **could not run** — `@object-ui/types` unbuilt, or
1245-
a control or the enumeration failed — which is neither green nor red.
1253+
document, including one from each leg's anchor page, so a README leg that read nothing cannot pass.
1254+
Its exit codes are the sibling gates' three: `0` clean, `1` the gate ran and found errors, `2` it
1255+
**could not run** — `@object-ui/types` unbuilt, or a control or the enumeration failed — which is
1256+
neither green nor red.
12461257

12471258
**If it fails:** each refusal names the page, the line of its fence, and each reason — an undeclared
12481259
key by name and path, anything else by the face's own message. Remove the key, or replace it with

‎scripts/__tests__/check-doc-json-fences.test.ts‎

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
FRAGMENT_MARKER,
1515
FRAGMENT_MARKER_EXAMPLES,
1616
REFUSED_CONTROL,
17+
SCAN_SURFACE,
18+
SURFACE_ANCHORS,
1719
analyze,
1820
classifyFence,
1921
controlFailures,
@@ -28,6 +30,7 @@ import {
2830
FRAGMENT_MARKER as SNIPPET_FRAGMENT_MARKER,
2931
FRAGMENT_MARKER_EXAMPLES as SNIPPET_FRAGMENT_MARKER_EXAMPLES,
3032
} from '../check-doc-snippet-types.mjs';
33+
import { packageReadmePages as familyPackageReadmes, ROOT_PAGES as FAMILY_ROOT_PAGES } from '../check-doc-component-types.mjs';
3134

3235
/**
3336
* objectui#5250 — the JSON-fence gate the maintainer ruling orders "on top of"
@@ -87,8 +90,8 @@ function tempTree(files: Record<string, string>): string {
8790

8891
const FENCE = '```';
8992
const fence = (lang: string, value: unknown): string => [`${FENCE}${lang}`, JSON.stringify(value, null, 2), FENCE].join('\n');
90-
const run = (files: Record<string, string>, anchor: string | null = null) =>
91-
analyze({ root: tempTree(files), judge, tolerant, nodeTypes, parseJsonc, anchor });
93+
const run = (files: Record<string, string>, anchors: readonly string[] | null = null) =>
94+
analyze({ root: tempTree(files), judge, tolerant, nodeTypes, parseJsonc, anchors });
9295
const reasons = (r: { findings: { reason: string }[] }) => r.findings.map((f) => f.reason);
9396

9497
describe('scanJsonFences — what the gate reads', () => {
@@ -216,14 +219,90 @@ describe('analyze — the verdicts, against the real face', () => {
216219

217220
it('ANTI-IDLE: an enumeration that judged nothing, or lost its anchor, is a precondition, never a green', () => {
218221
expect(run({ 'content/docs/a.mdx': fence('json', { name: 'not a node' }) }).preconditions.map((p) => p.reason)).toEqual(['nothing-judged']);
219-
expect(run({ 'content/docs/a.mdx': fence('json', { type: 'button', label: 'Go' }) }, 'content/docs/anchor.mdx').preconditions.map((p) => p.reason)).toEqual([
222+
expect(run({ 'content/docs/a.mdx': fence('json', { type: 'button', label: 'Go' }) }, ['content/docs/anchor.mdx']).preconditions.map((p) => p.reason)).toEqual([
220223
'anchor-unread',
221224
]);
222-
const empty = analyze({ root: tempTree({ 'README.md': '# not on the surface' }), judge, tolerant, nodeTypes, parseJsonc, anchor: null });
225+
// Only pages OFF the surface (objectui#12131): a record, a README below a
226+
// package root, a CHANGELOG, a changeset and a skill page. Each holds a
227+
// refused node document, so a leg that reached any of them would report it.
228+
const refusedDoc = fence('json', { type: 'button', label: 'Go', nonsense: 1 });
229+
const empty = run({
230+
'docs/audits/2026-01-record.md': refusedDoc,
231+
'packages/demo/src/renderers/README-KANBAN.md': refusedDoc,
232+
'packages/demo/src/README.md': refusedDoc,
233+
'packages/demo/CHANGELOG.md': refusedDoc,
234+
'.changeset/a-change.md': refusedDoc,
235+
'skills/demo/README.md': refusedDoc,
236+
});
237+
expect(empty.documents).toEqual([]);
238+
expect(empty.findings).toEqual([]);
223239
expect(empty.preconditions.map((p) => p.reason)).toEqual(['empty-enumeration']);
224240
});
225241
});
226242

243+
/**
244+
* objectui#12131 — the published READMEs joined the scan surface, because this
245+
* gate was green while three README examples were refused by `objectui
246+
* validate`. Each test below goes RED against the content-docs-only surface the
247+
* gate had before: that surface read neither README, so it reported nothing and
248+
* judged nothing there.
249+
*/
250+
describe('the scan surface — the published READMEs (objectui#12131)', () => {
251+
const refusedDoc = fence('json', { type: 'button', label: 'Go', action: { type: 'submit' } });
252+
const acceptedDoc = fence('json', { type: 'button', label: 'Go' });
253+
254+
it('RED: refuses a node document in a package\'s own README and in the root README, and names each', () => {
255+
const r = run({ 'content/docs/a.mdx': acceptedDoc, 'packages/demo/README.md': refusedDoc, 'README.md': refusedDoc });
256+
expect(reasons(r)).toEqual(['refused', 'refused']);
257+
// fixture-address: pages in the temp fixture tree this test writes, as the gate under test reports them
258+
expect(r.findings.map((f) => f.site)).toEqual(['packages/demo/README.md:1', 'README.md:1']);
259+
expect(r.findings.map((f) => f.issues)).toEqual([['at (root): undeclared key "action"'], ['at (root): undeclared key "action"']]);
260+
});
261+
262+
it('walks the legs in their declared order and counts each leg\'s pages', () => {
263+
const r = run({ 'content/docs/a.mdx': acceptedDoc, 'packages/demo/README.md': acceptedDoc, 'README.md': acceptedDoc });
264+
expect(r.legs.map((l) => l.leg)).toEqual(SCAN_SURFACE.map((l) => l.leg));
265+
expect(r.legs.map((l) => l.pages)).toEqual([['content/docs/a.mdx'], ['packages/demo/README.md'], ['README.md']]);
266+
expect(r.documents).toEqual(['content/docs/a.mdx', 'packages/demo/README.md', 'README.md']);
267+
expect(r.accepted).toHaveLength(3);
268+
});
269+
270+
it('takes a README fragment declared with the HTML-comment marker, and judges a declaration there as anywhere else', () => {
271+
const declared = run({ 'packages/demo/README.md': [FRAGMENT_MARKER_EXAMPLES[1], refusedDoc].join('\n') });
272+
expect(declared.findings).toEqual([]);
273+
expect(declared.declared).toHaveLength(1);
274+
275+
const stale = run({ 'README.md': [FRAGMENT_MARKER_EXAMPLES[1], acceptedDoc].join('\n') });
276+
expect(reasons(stale)).toEqual(['stale-fragment-marker']);
277+
});
278+
279+
it('ANTI-IDLE: every leg owes its anchor — a README leg that collected nothing is a precondition, never a green', () => {
280+
const readmeAnchors = SURFACE_ANCHORS.filter((a) => a !== ANCHOR_DOCUMENT);
281+
// Non-vacuous: both README legs exist, so there are anchors to fail.
282+
expect(readmeAnchors).toContain('README.md');
283+
expect(readmeAnchors.some((a) => /^packages\/[^/]+\/README\.md$/.test(a))).toBe(true);
284+
const r = run({ [ANCHOR_DOCUMENT]: acceptedDoc }, SURFACE_ANCHORS);
285+
expect(r.judged).toHaveLength(1);
286+
expect(r.preconditions.map((p) => `${p.reason} ${p.site}`)).toEqual(readmeAnchors.map((a) => `anchor-unread ${a}`));
287+
});
288+
289+
it('every leg names an anchor, and the anchors are one per leg', () => {
290+
expect(SURFACE_ANCHORS).toEqual(SCAN_SURFACE.map((l) => l.anchor));
291+
expect(new Set(SURFACE_ANCHORS).size).toBe(SCAN_SURFACE.length);
292+
expect(SURFACE_ANCHORS).toContain(ANCHOR_DOCUMENT);
293+
});
294+
295+
it('its README legs are the doc-gate family\'s, read from the real tree', () => {
296+
const docs = listDocuments(repoRoot);
297+
const family = familyPackageReadmes(repoRoot).map((abs: string) => path.relative(repoRoot, abs).split(path.sep).join('/'));
298+
// Non-vacuous: an empty family leg would make the equality below pass.
299+
expect(family.length, 'the family collected no package README').toBeGreaterThan(10);
300+
expect(docs.filter((d) => /^packages\/[^/]+\/README\.md$/.test(d))).toEqual(family);
301+
for (const name of FAMILY_ROOT_PAGES) expect(docs).toContain(name);
302+
expect(docs.filter((d) => d.startsWith('docs/') || d.startsWith('skills/') || /CHANGELOG\.md$/.test(d))).toEqual([]);
303+
});
304+
});
305+
227306
describe('controls — a probe that cannot fail is not a probe', () => {
228307
it('hold against the real faces', () => {
229308
expect(controlFailures({ judge, tolerant, nodeTypes })).toEqual([]);
@@ -268,8 +347,11 @@ describe('one authority — the CLI door and the gate agree on every judged docu
268347
expect(new Set(judged.map((j) => judge(j.value).success))).toEqual(new Set([true, false]));
269348
});
270349

271-
it(`reads its anchor page, ${ANCHOR_DOCUMENT}, from the real corpus`, () => {
272-
expect(listDocuments(repoRoot)).toContain(ANCHOR_DOCUMENT);
350+
it.each(SURFACE_ANCHORS)('reads its anchor page %s, and judges a node document there, from the real corpus', (anchor) => {
351+
expect(listDocuments(repoRoot)).toContain(anchor);
352+
const r = analyze({ root: repoRoot, judge, tolerant, nodeTypes, parseJsonc, anchors: [anchor] });
353+
expect(r.preconditions).toEqual([]);
354+
expect(r.judged.some((j: { doc: string }) => j.doc === anchor)).toBe(true);
273355
});
274356
});
275357

0 commit comments

Comments
 (0)