Skip to content

Commit f667c1d

Browse files
fix(scripts): parse a json doc fence strictly with parseJsonFence (objectui#10943) (#10985)
Fixes #10943 Clause-②: no — a docs-census strength change on repository tooling; no published contract, accept set or public surface changes (a contract review is still owed for the README fence retag) ## What changes This executes ruling **A** on objectui#10943: the maintainer's 「同意」 on 2026-09-28, recorded in the card's ruling comment `5869344478`. The existing carriage census `scripts/check-doc-expression-carriage.mjs` now holds `json` and `jsonc` fences to different contracts: - **`json`**: parsed by `parseJsonFence`, imported from `scripts/check-skill-examples.mjs` and not copied. That function is `JSON.parse` and nothing else. No tolerance, no object-body retry and no multi-document split applies. - **`jsonc`**: unchanged. It keeps the four tolerances (comments, raw newlines in strings, trailing commas, elisions) and the object-body retry. The fences the blind-spot measurement reads (the languages the census does not judge) stay on that same tolerant path. - **Unparsed entries**: each one now carries the file, the fence's opening line, its language and the parse error. The CLI prints the two kinds apart. For `json` it prints the ruled remedy, 「retag as `jsonc` if the example needs comments or trailing commas」. For `jsonc` it keeps the old `sanitizeFence` remedy (H3). The CLI and the pin both print this text from `UNPARSED_PRESCRIPTIONS`, so the two cannot drift apart. - **Posture**: the CLI still exits 0 on anything it reads in a page. What goes red is the EXISTING pin, 'has no blind spot on the corpus it ships against'. It still asserts `census.unparsed` is empty, and a non-JSON `json` fence now lands on that list. This adds no new script, workflow, test file or gate. The ruling's non-generalisation also holds: the `jsonc` tolerance list is not widened, and no other fence language gains a parse check. - **Header docblock** (H4): the parse-surface section no longer says the tolerances apply to `json`. A new section records the ruling, the posture above and the import mechanics. The exit-code line also names a missing contract as an instrument failure. ### Why the import is a guarded dynamic `import()`, not a static one This was measured, not assumed. `check-skill-examples.mjs` imports `typescript` and `check-doc-snippet-types.mjs` at load. With a static import, the orphan pin ('is LOUD when the instrument itself is broken') runs the gate with no install. The gate then dies at module link with `ERR_MODULE_NOT_FOUND` for `typescript`, and the probe counted 0 occurrences of the asserted 'A failure, not a skip' line. So the census loads the module with one `const` declaration holding an `import()` whose rejection settles into an `error` field. A top-level `try` is refused by `check:entry-guard` as a statement that runs on import. `requireJsonContract()` raises the held error inside the CLI's existing instrument check. A one-off probe (renderer and installed spec present, `check-skill-examples.mjs` absent) exits 1 with that line. Cost: the census now loads `typescript` at start. Measured start times were about 0.2 s for the census `--self-test` and about 0.45 to 0.53 s to import `check-skill-examples.mjs`. ### The surface under strict `json`, measured before any change (H2) - **Population**: the census's own `scanFences` over its whole surface. That surface is imported from `check:doc-types` and printed as `content/docs, apps/*/docs, packages/*/README.md and README.md`, so it is not `content/docs` alone. - **Verdict**: `parseJsonFence(body, 'json')`. - **Controls in the same run**: a valid body was accepted, a trailing comma was rejected, and a comment was rejected. - **Result**: exactly one `json` fence failed. It is the VS Code settings example under 「⚙️ Configuration」 in `packages/vscode-extension/README.md`, which uses `//` comments because VS Code settings files are JSONC. The README's other `json` fence (the 「Example Schema」 one) already parses strictly. No fence was simply broken, so there is no docs bug to report. - **Re-derive**: `node scripts/check-doc-expression-carriage.mjs` on this branch prints the per-language row and names any unparsed fence. ### The retag in `packages/vscode-extension/README.md` (for the contract review) This is a published-docs prose change, and it is the smallest one available. One info string changes from `json` to `jsonc` on the Configuration fence, and the body bytes are unchanged. `git diff` over the file shows 1 line out and 1 line in. The site page `content/docs/utilities/vscode-extension.mdx` already tags its settings example `jsonc`, so the README now agrees with it. The other gates that read that fence's tag are green: `check:doc-fences` and `check:doc-types` (see Evidence). ### `CONTRIBUTING.md`: one sentence this change made false Its `json`-fence convention paragraph said 「Nothing enforces it for `content/docs` today」 and called whether a gate should block such a fence 「the open decision objectui#10943」. After this change, both statements are wrong. The sentence now names the census, the pin and the fact that the tolerances apply to `jsonc` only. ⚠️ This file is **outside the claim's declared file surface**. The edit was made because a statement this change makes false is owed in the same change; the report names it as a deviation. ## Evidence Readings are at head `33012bd4d6` unless a line says otherwise. Base is `deca847a8a`. - **Census before/after** - Base: `json 221 (221 parsed, 0 unparsed); jsonc 15 (15 parsed, 0 unparsed)`. - Head: `json 220 (220 parsed, 0 unparsed); jsonc 16 (16 parsed, 0 unparsed)`. - A `diff` of the two full census outputs differs in that one line only, so the judged findings (nodes, `${…}` sites, carried count, blind-spot lists) did not move. The `--list` inventories differ only in the retagged fence's language. - `--self-test` exits 0 and prints `Controls pass`. - **Ablation**: one planted bad `json` fence on the real surface, with the `//`-comment class, planted in `content/docs/guide/expressions.md` through objectstack's `scripts/ablation-replace.mjs` in wrap mode with its EXIT/INT/TERM restore. - **Head leg** (`11fc05b98f`): the pin went **RED**, `Tests 1 failed | 58 skipped (59)`. The received entry was `"lang": "json"` with `"reason": "invalid-json: Expected property name or '}' in JSON at position 4 (line 2 column 3)"`. The failure message began: A `json` fence here is NOT JSON — a docs defect, not a blind spot: retag as `jsonc` if the example needs comments or trailing commas; … - **Base leg** (`deca847a8a`, a separate throwaway checkout): the identical plant left the same pin **GREEN**, `Tests 1 passed | 46 skipped (47)`. The old tolerant parse read the comment away. This is the strength increase itself. - **Restore**, both legs: the tool reported blob == HEAD blob and `git diff HEAD` empty. Independently, `git diff --quiet HEAD` exited 0 and the plant marker count went back to 0. - **Void attempts**: the first head attempt was a no-op. The tool refused it because the replacement still contained the anchor (anchor count 1 to 1), and vitest never ran, so it was re-anchored. The first base attempt hit a vitest startup error (a symlinked `node_modules` lacked `@vitejs/plugin-react`), which is not a verdict. It was re-run after a real `pnpm install` in that checkout. - **Tests**: `pnpm exec vitest run --maxWorkers=2` over 15 files passed, `Test Files 15 passed (15)`, `Tests 911 passed (911)`. The files are the census test, `check-skill-examples.test.ts`, `markdown-fence-scan.test.ts` (it imports `scanFences`), `entry-guard-wiring.test.ts`, and every test recorded by `scripts/markdown-test-inputs.mjs --list` as reading the README or `CONTRIBUTING.md`. The census test itself went from 47 to 59 tests. - **Gates**, each exit 0 and each re-run after the last code commit: - `pnpm type-check:scripts`. `--listFiles` shows it compiles the edited test. - `pnpm lint:root`: 0 errors. All 34 warnings are in files this PR does not touch. - `pnpm check:doc-types` - `node scripts/check-doc-fence-languages.mjs --self-test` and `node scripts/check-doc-fence-languages.mjs` - `pnpm check:control-bytes` - `pnpm check:new-line-citations`, with 0 new citations - `pnpm check:entry-guard` - `pnpm check:pre-install-import-graph` - `pnpm check:test-path-roots` - `pnpm check:installed-pin-claims` - `node scripts/check-doc-links.mjs` - `node scripts/check-governed-queue-guard.mjs --test` on the 4 paths, which prints NOT GOVERNED - `node scripts/check-changeset-presence.mjs`, which says no changeset is owed: the four changed files are not published source of any released package - **NOT MEASURED** - `pnpm check:readme-exports`: prerequisite not met, because every package's `dist/index.d.ts` is absent without a full build. This diff changes no import line in any README. - `pnpm check:skill-examples`: not run, because its module graph is unchanged. The census imports it, and nothing it imports changed. Its test file is in the 15 above. ## Acceptance notes These are recorded here and not filed. - The census row in `content/docs/guide/ci-cd-pipeline.md` describes the walk as `content/docs/**`, every `apps/*/docs/**` tree and the root `README.md`. It omits the package-README leg, which predates this PR. This change does not make that row false: the CLI it describes still exits 0 regardless. Carrier: none. - The `CONTRIBUTING.md` convention says a raw newline inside a string is invalid under both tags. The census's `jsonc` tolerance 2 re-escapes raw newlines, so a `jsonc` fence stays more tolerant here than the written convention. The ruling keeps that list as is. Observation only. Related: objectui#10088 · PR objectui#10942 (the repairs this lands after) · objectui#7474 (the skills-tree precedent) --- _Generated by [Claude Code](https://claude.ai/code/session_01EBx9rvB7dufCz4at53x35U)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a097316 commit f667c1d

4 files changed

Lines changed: 308 additions & 29 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ The repository root also has a `docs/` directory, and it is **not** part of the
427427

428428
**A code block inside one of those records is a SPECIMEN, not an example to copy.** An ADR states what was decided on a date and a dated audit states what was measured on one, so a snippet inside either is part of the record — edited until it compiles, it makes the record say something it never said. Fence such a block `plaintext` (an unhighlighted spelling `scripts/check-doc-fence-languages.mjs` already lists, and one `scripts/check-doc-snippet-types.mjs` does not compile), never `ts` / `tsx` / `typescript`. Code a reader may copy belongs in `content/docs/**` or `skills/objectui/**`, where a gate compiles it and a wrong line can be fixed without falsifying a record. Maintainer ruling 2026-09-08 (objectui#8363); the four records that predate it are not edited — they are named, with their measured counts, in that gate's `UNGATED_DOCS` ledger.
429429

430-
**A `json` fence is what a reader copies into a metadata file, so its body must pass a strict `JSON.parse`.** A block that annotates JSON with `//` or `/* … */` comments or trailing commas is fenced `jsonc` instead, and it must still be one JSON document once those are removed. The tag is how a reader tells a deliberate annotation from a mistake, and a `json` fence that does not parse is always the mistake. The other non-parsing shapes are admitted by no JSON dialect, so rewrite them rather than retag them. A `${…}` expression goes on one line inside its string, because a raw newline in a string is invalid under both tags. A bad/good comparison becomes one fence per document, with its label on the line above. An elision (`...`, `[...]`, a `"..."` member) is removed, which leaves an empty list or the bare node. A counter-example still parses: what is wrong with it belongs in its content, not its syntax. This is the contract `parseJsonFence` in `scripts/check-skill-examples.mjs` enforces on the skills tree's marked fences. ⚠️ Nothing enforces it for `content/docs` today. `scripts/check-doc-expression-carriage.mjs` reads these fences but normalises comments, raw newlines and elisions away by design, and whether a gate should block a `json` fence that does not parse is the open decision objectui#10943.
430+
**A `json` fence is what a reader copies into a metadata file, so its body must pass a strict `JSON.parse`.** A block that annotates JSON with `//` or `/* … */` comments or trailing commas is fenced `jsonc` instead, and it must still be one JSON document once those are removed. The tag is how a reader tells a deliberate annotation from a mistake, and a `json` fence that does not parse is always the mistake. The other non-parsing shapes are admitted by no JSON dialect, so rewrite them rather than retag them. A `${…}` expression goes on one line inside its string, because a raw newline in a string is invalid under both tags. A bad/good comparison becomes one fence per document, with its label on the line above. An elision (`...`, `[...]`, a `"..."` member) is removed, which leaves an empty list or the bare node. A counter-example still parses: what is wrong with it belongs in its content, not its syntax. This is the contract `parseJsonFence` in `scripts/check-skill-examples.mjs` enforces on the skills tree's marked fences. `scripts/check-doc-expression-carriage.mjs` imports that same function for every `json` fence on the surface `check:doc-types` walks (objectui#10943). A `json` fence that does not parse lands on that census's unparsed list, and the pin 'has no blind spot on the corpus it ships against' in `scripts/__tests__/check-doc-expression-carriage.test.ts`, which keeps that list empty, fails the pull request that adds it. The census's comment, raw-newline, trailing-comma and elision tolerances apply to `jsonc` only.
431431

432432
```bash
433433
# Start the documentation site dev server

‎packages/vscode-extension/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ Access these commands via the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
122122

123123
Customize the extension behavior in VSCode settings:
124124

125-
```json
125+
```jsonc
126126
{
127127
// Preview settings
128128
"objectui.preview.port": 3000,

‎scripts/__tests__/check-doc-expression-carriage.test.ts‎

Lines changed: 136 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ import {
2121
parseFence,
2222
parseFenceDialect,
2323
RENDERER_SOURCE,
24+
requireJsonContract,
2425
ROOT_PAGES,
2526
runControls,
2627
sanitizeFence,
2728
splitTopLevel,
2829
SURFACE_LABEL,
2930
toJsonDialect,
31+
UNPARSED_PRESCRIPTIONS,
32+
unparsedPrescription,
3033
} from '../check-doc-expression-carriage.mjs';
3134
import {
3235
APP_DOCS as TYPES_APP_DOCS,
@@ -35,6 +38,7 @@ import {
3538
packageReadmePages as typesPackageReadmePages,
3639
ROOT_PAGES as TYPES_ROOT_PAGES,
3740
} from '../check-doc-component-types.mjs';
41+
import { parseJsonFence } from '../check-skill-examples.mjs';
3842

3943
const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..');
4044
const GATE = 'scripts/check-doc-expression-carriage.mjs';
@@ -194,7 +198,9 @@ describe('check-doc-expression-carriage: the controls can see, and can fail', ()
194198
});
195199

196200
describe('check-doc-expression-carriage: the parse surface', () => {
197-
const parse = (body: string) => parseFence(body);
201+
// These are the `jsonc` tolerances. Since objectui#10943 a `json` fence gets
202+
// none of them; the next describe block pins that half.
203+
const parse = (body: string) => parseFence(body, 'jsonc');
198204

199205
it('drops comments outside strings and keeps a // that is data', () => {
200206
const out = parse('{ "type": "text", // a note\n "content": "https://example.com" }');
@@ -290,6 +296,117 @@ describe('check-doc-expression-carriage: the parse surface', () => {
290296
});
291297
});
292298

299+
/**
300+
* objectui#10943, ruled A on 2026-09-28. A `json` fence is parsed STRICTLY with
301+
* the skills tree's own `parseJsonFence`, and `jsonc` keeps the tolerances. The
302+
* maintainer granted this as a strength increase of ONE existing pin, 'has no
303+
* blind spot on the corpus it ships against', and not as a new gate.
304+
*
305+
* Every tolerance body below is pinned on BOTH tags: `json` must reject it and
306+
* `jsonc` must still parse it. If a later edit quietly sent `json` back through
307+
* `sanitizeFence`, the `jsonc` half would stay green and the `json` half would
308+
* go red. A pin needs both halves to see that regression.
309+
*/
310+
describe('check-doc-expression-carriage: `json` is strict, `jsonc` keeps the tolerances (objectui#10943)', () => {
311+
it('judges `json` with the skills tree’s own parseJsonFence, the same function and not a copy', () => {
312+
// Identity, not behavioural equality, for the reason the scan-surface pin
313+
// below gives for its constants: an import has nothing to drift.
314+
expect(requireJsonContract()).toBe(parseJsonFence);
315+
});
316+
317+
it('parses a good `json` fence and hands its value on', () => {
318+
const out = parseFence('{\n "type": "text",\n "content": "${user.name}"\n}', 'json');
319+
expect(out).toEqual({
320+
ok: true,
321+
reason: null,
322+
values: [{ type: 'text', content: '${user.name}' }],
323+
wrapped: false,
324+
});
325+
});
326+
327+
// One body per tolerance the gate's header lists, plus the object-body retry
328+
// and the multi-document split. This census reads each of them under `jsonc`,
329+
// and none of them is JSON.
330+
const notJson: Array<[string, string]> = [
331+
['a line comment', '{\n // a note\n "type": "text"\n}'],
332+
['a block comment', '{ /* a note */ "type": "text" }'],
333+
['a raw newline inside a string', '{ "type": "text", "content": "${ a\n ? 1 : 2 }" }'],
334+
['a trailing comma', '{ "type": "text", "content": "x", }'],
335+
['an elision', '{ "type": "form", "fields": [...] }'],
336+
['an object BODY', '"dependencies": {\n "@object-ui/plugin-x": "workspace:*"\n}'],
337+
['two top-level documents', '{ "type": "a" }\n\n{ "type": "b" }'],
338+
];
339+
340+
it.each(notJson)('rejects %s under `json` with the contract’s own error, and reads it under `jsonc`', (_label, body) => {
341+
const error = parseJsonFence(body, 'json');
342+
expect(error, 'the contract itself must reject this body, or this row is not about the contract').not.toBeNull();
343+
344+
const strict = parseFence(body, 'json');
345+
expect(strict).toEqual({ ok: false, reason: `invalid-json: ${error}`, values: [], wrapped: false });
346+
347+
expect(parseFence(body, 'jsonc').ok).toBe(true);
348+
});
349+
350+
it('parses a good `jsonc` fence carrying a comment', () => {
351+
const out = parseFence('{\n // Preview settings\n "objectui.preview.port": 3000,\n}', 'jsonc');
352+
expect(out.ok).toBe(true);
353+
expect(out.values).toEqual([{ 'objectui.preview.port': 3000 }]);
354+
});
355+
356+
it('prescribes the ruled remedy for `json`, and keeps the blind-spot remedy for every other language', () => {
357+
expect(UNPARSED_PRESCRIPTIONS.json).toContain('retag as `jsonc` if the example needs comments or trailing commas');
358+
expect(unparsedPrescription('json')).toBe(UNPARSED_PRESCRIPTIONS.json);
359+
expect(unparsedPrescription('jsonc')).toBe(UNPARSED_PRESCRIPTIONS.jsonc);
360+
expect(UNPARSED_PRESCRIPTIONS.jsonc).toContain('sanitizeFence');
361+
});
362+
363+
/**
364+
* The census on a throwaway tree: a good `json` fence, the SAME annotated body
365+
* once as `json` and once as `jsonc`. Only the `json` copy may land on the
366+
* unparsed list. The entry must name the file, the fence's opening line, the
367+
* language and the contract's own parse error. The CLI stays report-only (exit
368+
* 0) and prints the ruled remedy.
369+
*/
370+
it('names file, fence line, language and parse error, and the CLI prints the `jsonc` retag remedy', async () => {
371+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'carriage-strict-'));
372+
const page = `${DOCS_ROOT}/guide/fences.md`;
373+
const annotated = ['{', ' // VS Code settings are JSONC', ' "objectui.preview.port": 3000', '}'];
374+
fs.mkdirSync(path.join(root, DOCS_ROOT, 'guide'), { recursive: true });
375+
fs.writeFileSync(
376+
path.join(root, page),
377+
[
378+
'# Fences', // 1
379+
'', // 2
380+
'```json', // 3
381+
'{ "type": "text", "content": "ok" }',
382+
'```',
383+
'',
384+
'```json', // 7: the one that must land on the list
385+
...annotated,
386+
'```',
387+
'',
388+
'```jsonc',
389+
...annotated,
390+
'```',
391+
'',
392+
].join('\n'),
393+
);
394+
395+
const census = analyze(root, { channels: deriveChannels(ROOT), carriage: await loadCarriage() });
396+
expect(census.counters.fences).toBe(3);
397+
expect(census.counters.parsed).toBe(2);
398+
expect(census.unparsed).toEqual([
399+
{ file: page, line: 7, lang: 'json', reason: `invalid-json: ${parseJsonFence(annotated.join('\n'), 'json')}` },
400+
]);
401+
402+
const run = spawnSync(process.execPath, [GATE, '--root', root], { cwd: ROOT, encoding: 'utf8' });
403+
expect(run.status, run.stderr).toBe(0);
404+
expect(run.stdout).toContain(`${page}:7 (json) invalid-json: `);
405+
expect(run.stdout).toContain(UNPARSED_PRESCRIPTIONS.json);
406+
fs.rmSync(root, { recursive: true, force: true });
407+
});
408+
});
409+
293410
/**
294411
* objectui#7878 — the scan surface.
295412
*
@@ -520,14 +637,23 @@ describe('check-doc-expression-carriage: the real tree, and the posture', () =>
520637
// blocking through the back door — the exact thing the ruling forbade.
521638
});
522639

640+
/**
641+
* objectui#10943 strengthened this pin and added no gate. A `json` fence that
642+
* is not JSON now lands on `census.unparsed`, so this assertion is what reds
643+
* the pull request that adds one. The list holds two different things, so the
644+
* message gives the remedy for each language actually on it. The text comes
645+
* from the same constant the CLI prints.
646+
*/
523647
it('has no blind spot on the corpus it ships against', async () => {
524648
const census = analyze(ROOT, { channels: deriveChannels(ROOT), carriage: await loadCarriage() });
525-
expect(
526-
census.unparsed,
527-
'a json fence under content/docs that this gate cannot parse is a fence it says NOTHING ' +
528-
'about — the size of its blind spot, not a docs rule. Teach `sanitizeFence` the spelling ' +
529-
`(see the tolerances in ${GATE}'s header), or fix the fence if it is simply malformed.`,
530-
).toEqual([]);
649+
const langs: string[] = [...new Set<string>(census.unparsed.map((fence: { lang: string }) => fence.lang))];
650+
const remedies = langs.map((lang) =>
651+
lang === 'json'
652+
? `A \`json\` fence here is NOT JSON — a docs defect, not a blind spot: ${unparsedPrescription(lang)}.`
653+
: `A \`${lang}\` fence here is one this gate says NOTHING about — the size of its blind spot, ` +
654+
`not a docs rule: ${unparsedPrescription(lang)}.`,
655+
);
656+
expect(census.unparsed, remedies.join(' ')).toEqual([]);
531657
});
532658

533659
/**
@@ -693,7 +819,9 @@ describe('check-doc-expression-carriage: the real tree, and the posture', () =>
693819
// those modules for the imports to resolve at all. If this list ever falls
694820
// behind the gate's imports the failure is a module-resolution stack trace
695821
// rather than the message below, which is why the message is asserted and not
696-
// merely the exit code.
822+
// merely the exit code. `check-skill-examples.mjs` is deliberately NOT copied:
823+
// objectui#10943's import of it is guarded, because it loads `typescript`, and
824+
// an orphan missing it must still reach the message below.
697825
for (const file of [
698826
'check-doc-expression-carriage.mjs',
699827
'check-doc-component-types.mjs',

0 commit comments

Comments
 (0)