Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions content/docs/guide/ci-cd-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
94 changes: 88 additions & 6 deletions scripts/__tests__/check-doc-json-fences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
FRAGMENT_MARKER,
FRAGMENT_MARKER_EXAMPLES,
REFUSED_CONTROL,
SCAN_SURFACE,
SURFACE_ANCHORS,
analyze,
classifyFence,
controlFailures,
Expand All @@ -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"
Expand Down Expand Up @@ -87,8 +90,8 @@ function tempTree(files: Record<string, string>): string {

const FENCE = '```';
const fence = (lang: string, value: unknown): string => [`${FENCE}${lang}`, JSON.stringify(value, null, 2), FENCE].join('\n');
const run = (files: Record<string, string>, anchor: string | null = null) =>
analyze({ root: tempTree(files), judge, tolerant, nodeTypes, parseJsonc, anchor });
const run = (files: Record<string, string>, 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', () => {
Expand Down Expand Up @@ -216,14 +219,90 @@ 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 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(readmeAnchors.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([]);
Expand Down Expand Up @@ -268,8 +347,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);
});
});

Expand Down
Loading
Loading