Skip to content

Commit 613d35a

Browse files
os-billclaude
andauthored
docs(spec): assert an @example caption has a block beneath it (#17395)
* wip: assert an @example caption has a block beneath it Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * docs(spec): assert an `@example` caption has a block beneath it Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 025588a commit 613d35a

3 files changed

Lines changed: 220 additions & 5 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
The reference-docs renderer now refuses an `@example CAPTION` with no code block beneath it,
6+
instead of publishing an orphaned caption.
7+
8+
`@example CAPTION` is declared to be *the caption of the fence beneath it*, and the renderer
9+
acts on that reading: it promotes the tag into a bold lead-in on the assumption that a fence
10+
follows. Nothing asserted that one did. When a module header captioned a listing and wrote its
11+
rows as bare prose, the promotion still fired and the rows below collapsed into a single run-on
12+
paragraph — consecutive non-blank lines are one markdown paragraph, and the docs site loads no
13+
`remark-breaks`. Two customer-facing reference pages shipped that way.
14+
15+
The assumption is now a precondition the generator checks before it emits anything. A module
16+
description whose caption has no block under it fails the docs build with a message naming the
17+
caption and the source-side fix, the way the renderer already refuses a heading it cannot
18+
renumber. Deliberately a refusal in the generator rather than a separate gate: it makes the
19+
wrong page impossible instead of detecting it afterwards, and it is scoped to the population
20+
the renderer actually renders — module doc blocks — rather than to every `@example` line in the
21+
package.
22+
23+
⛔ The check never asks whether a run of prose is "really" a table. Shape-sniffing is exactly
24+
what this renderer refuses to do, and what an author writes instead of a fence is not knowable
25+
from the text. It asks only the question the contract already states: is there a block beneath
26+
the caption? An author who wants those words as ordinary prose writes them without the tag.
27+
28+
Both code kinds satisfy it. An indented block reaches the page as a fence — the render loop
29+
re-emits it as one — so a caption above one captions a fence by the time a reader sees it. All
30+
twelve captions in the corpus are fenced today and are unaffected; no schema behavior changes.

packages/spec/scripts/file-description.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,122 @@ describe('renderFileDescription — #14455: a tag WITH a payload is rewritten, n
850850
});
851851
});
852852

853+
/**
854+
* #16962 — the caption's fence is a PRECONDITION, and the generator asserts it.
855+
*
856+
* `EXAMPLE_CAPTION` promotes `@example CAPTION` to a bold lead-in because the
857+
* contract says a fence follows. Nothing checked, and #15440 is what that cost:
858+
* two module headers captioned a listing, wrote its rows as bare prose, and the
859+
* rows reached two customer-facing reference pages as one run-on paragraph —
860+
* consecutive non-blank lines are one markdown paragraph, and the docs site
861+
* loads no `remark-breaks`.
862+
*
863+
* ⛔ The refusal is NOT a detector for "prose that is really a table". This
864+
* module's own header rejects that shape-sniffing and so does the card. The
865+
* question asked here is only the one the contract already states: is there a
866+
* block beneath the caption? An author who wants those words as prose writes
867+
* them without the tag.
868+
*/
869+
describe('renderFileDescription — #16962: a caption with no block beneath it is refused, not published', () => {
870+
const ctx = { fromCategory: 'api', sourcePathToDocsRoute: () => null, sectionLevel: PAGE_SECTION_LEVEL };
871+
872+
const moduleBlock = (...body: string[]): string =>
873+
['/**', ...body.map(l => (l === '' ? ' *' : ` * ${l}`)), ' */', '', "import { z } from 'zod';", ''].join('\n');
874+
875+
it('refuses the #15440 shape — a caption over rows written as bare prose', () => {
876+
// `api/automation-api` and `api/package-api`, reduced to the shape they
877+
// shipped. Before the assertion this rendered `**Endpoints**` followed by
878+
// one paragraph reading `GET /api/automation … POST /api/automation …`.
879+
expect(() =>
880+
renderFileDescription(
881+
moduleBlock(
882+
'Automation API Protocol',
883+
'',
884+
'@example Endpoints',
885+
'GET /api/automation - list',
886+
'POST /api/automation - create',
887+
),
888+
ctx,
889+
),
890+
).toThrow(/`@example Endpoints` with no code block beneath it/);
891+
});
892+
893+
it('names the source fix, because the source is where the fix goes', () => {
894+
// The renderer cannot repair this and must not try — the same reason the
895+
// heading-depth refusal points at the file header rather than clamping.
896+
expect(() => renderFileDescription(moduleBlock('@example Endpoints', 'GET /api/x'), ctx)).toThrow(
897+
/Fence the block in the source's own file header/,
898+
);
899+
});
900+
901+
it('refuses a caption that ends the block, with nothing at all beneath it', () => {
902+
// The other orphan shape, and the one a "next line is not a fence" test
903+
// written with an off-by-one would sail past.
904+
expect(() => renderFileDescription(moduleBlock('Automation API Protocol', '', '@example Endpoints'), ctx)).toThrow(
905+
/no code block beneath it/,
906+
);
907+
});
908+
909+
it('refuses a caption whose next block is another tag rather than a fence', () => {
910+
// A run of tags is the arrangement `withTagBlocksSeparated` exists for, so
911+
// the caption is followed by a blank line here whatever the source wrote.
912+
// Skipping blanks must not be mistaken for finding a block.
913+
expect(() =>
914+
renderFileDescription(
915+
moduleBlock('Automation API Protocol', '', '@example Endpoints', '@see https://example.invalid/api'),
916+
ctx,
917+
),
918+
).toThrow(/no code block beneath it/);
919+
});
920+
921+
it('accepts the fenced form — the twelve captions in the corpus keep rendering', () => {
922+
const out = renderFileDescription(
923+
moduleBlock('Automation API Protocol', '', '@example Endpoints', '```', 'GET /api/automation', '```'),
924+
ctx,
925+
);
926+
expect(out).toContain('**Endpoints**\n```');
927+
});
928+
929+
it('accepts a blank line between the caption and its fence', () => {
930+
// Markdown puts the fence under the bold line either way, and the sources
931+
// write both spellings — refusing this one would reject correct pages.
932+
const out = renderFileDescription(
933+
moduleBlock('@example Endpoints', '', '```', 'GET /api/automation', '```'),
934+
ctx,
935+
);
936+
expect(out).toContain('**Endpoints**');
937+
expect(out).toContain('GET /api/automation');
938+
});
939+
940+
it('accepts an INDENTED block, which reaches the page as a fence anyway', () => {
941+
// `data/date-macros` and `data/context-tokens` write examples this way and
942+
// the render loop re-emits them fenced. Judged by KIND, so a caption above
943+
// one captions a fence by the time a reader sees it.
944+
const out = renderFileDescription(moduleBlock('@example Macros', '', ' value: 1'), ctx);
945+
expect(out).toContain('**Macros**');
946+
expect(out).toContain('```\nvalue: 1\n```');
947+
});
948+
949+
it('ignores an `@example CAPTION` shown INSIDE a fence — that is an author illustrating the tag', () => {
950+
// Judged on the same classification the rewrite is, so a header teaching the
951+
// convention is not refused for demonstrating the broken form. A refusal
952+
// written over raw text instead of over `kind` would reject this file's own
953+
// documentation.
954+
const out = renderFileDescription(
955+
moduleBlock('How a module header captions an example:', '', '```md', '@example Endpoints', 'GET /api/x', '```'),
956+
ctx,
957+
);
958+
expect(out).toContain('@example Endpoints');
959+
});
960+
961+
it('ignores a mid-sentence mention, the same UNTRIMMED test the rewrite uses', () => {
962+
// `MODULE_MARKER`'s rule, and the reason the two can share one pattern: only
963+
// a line that OPENS with the tag is a tag.
964+
const out = renderFileDescription(moduleBlock('Write `@example Foo` above a fence to caption it.'), ctx);
965+
expect(out).toContain('@example Foo');
966+
});
967+
});
968+
853969
/**
854970
* #5553 — the block is rendered as the markdown it was written as.
855971
*

packages/spec/scripts/lib/file-description.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -502,11 +502,11 @@ const SKILL_EXAMPLE_MARKER = '<!-- os:check -->';
502502
* may be DROPPED is decided by whether it has a payload, and the tags that
503503
* reach a page do not answer alike: `@module` and a bare `@example` are their
504504
* own entire content, while `@example CAPTION` is the caption of the fence
505-
* beneath it and `@see` is a cross-reference — both rewritten instead
506-
* (`EXAMPLE_CAPTION`, and `renderProse`'s `See also: …`). A blanket line filter
507-
* cannot express that difference; it would take the caption off the page and
508-
* orphan its fence, and "no page loses non-tag prose" is this fix's acceptance
509-
* criterion. `@category` is the one payload-carrying tag that still renders as
505+
* beneath it — asserted by `assertCaptionedBlocksAreFenced`, not merely assumed
506+
* and `@see` is a cross-reference; both rewritten instead (`EXAMPLE_CAPTION`,
507+
* and `renderProse`'s `See also: …`). A blanket line filter cannot express that
508+
* difference; it would take the caption off the page and orphan its fence, and
509+
* "no page loses non-tag prose" is this fix's acceptance criterion. `@category` is the one payload-carrying tag that still renders as
510510
* nothing, and `CATEGORY_MARKER` carries the measurement that says why.
511511
*
512512
* Judged with the same UNTRIMMED `^@module\b` test `hasModuleMarker` selects
@@ -795,9 +795,71 @@ function mapProse(text: string, kinds: ProseRun['kind'][], fn: (plain: string) =
795795
* `@example` in a list item — and only ever shown prose, so an `@example` inside
796796
* a fence stays as the author wrote it. Held global-safe by `String#replace`,
797797
* which resets `lastIndex` around the call.
798+
*
799+
* ⚠️ "the block it captions" is a PRECONDITION, not an observation, and
800+
* `assertCaptionedBlocksAreFenced` is what makes it one. Until it existed this
801+
* comment promised a fence that nothing checked for, and two module headers
802+
* captioned a listing whose rows were bare prose: the promotion still fired,
803+
* and the rows below collapsed into one run-on paragraph on two customer-facing
804+
* reference pages. The rewrite is unconditional BY DESIGN — it may stay that
805+
* way precisely because the assertion runs before it.
798806
*/
799807
const EXAMPLE_CAPTION = /^@example[ \t]+(\S.*)$/gm;
800808

809+
/**
810+
* The same pattern, per line and stateless — one source, so the two can never
811+
* drift.
812+
*
813+
* Dropping `g` is what makes it safe to `.test()` in a loop: a `g` regex
814+
* carries `lastIndex` between calls and would answer for every second caption.
815+
* Dropping `m` costs nothing, because `^` and `$` against a single line mean
816+
* exactly what they meant against a line of the block.
817+
*/
818+
const EXAMPLE_CAPTION_LINE = new RegExp(EXAMPLE_CAPTION.source);
819+
820+
/**
821+
* Refuse a caption with no block beneath it, rather than publishing one.
822+
*
823+
* `EXAMPLE_CAPTION` promotes `@example CAPTION` to a bold lead-in on the stated
824+
* assumption that a fence follows. This asserts the assumption instead of
825+
* trusting it — the generator makes the wrong page impossible, which is the
826+
* same move `findModuleDocBlock` makes for block selection and the reason
827+
* neither needs a detector bolted on beside it.
828+
*
829+
* ⛔ It never asks whether a run of prose is "really" a table. That is the
830+
* shape-sniffing this module's own header rejects, and the thing an author
831+
* writes instead of a fence is not knowable from the text. The question here is
832+
* only the one the contract already states: is the next block a block? An
833+
* author who wants the words as ordinary prose writes them without the tag.
834+
*
835+
* Both code kinds count. `indented` reaches the page as a fence — the render
836+
* loop re-emits it as one — so a caption above an indented block captions a
837+
* fence by the time a reader sees it, and refusing it would reject a form that
838+
* renders correctly today.
839+
*
840+
* Blank lines between the caption and its block are skipped: `withTagBlocksSeparated`
841+
* inserts one before every tag, the sources write their own, and markdown puts
842+
* the fence under the bold line either way.
843+
*/
844+
function assertCaptionedBlocksAreFenced(lines: readonly string[], kind: readonly LineKind[]): void {
845+
for (let i = 0; i < lines.length; i++) {
846+
if (kind[i] !== 'prose' || !EXAMPLE_CAPTION_LINE.test(lines[i])) continue;
847+
848+
let next = i + 1;
849+
while (next < lines.length && lines[next].trim() === '') next++;
850+
if (next < lines.length && (kind[next] === 'fenced' || kind[next] === 'indented')) continue;
851+
852+
const caption = EXAMPLE_CAPTION_LINE.exec(lines[i])![1];
853+
throw new Error(
854+
`file-description: this module description writes \`@example ${caption}\` with no code block ` +
855+
`beneath it. An \`@example CAPTION\` line is the caption OF the block below it and is published ` +
856+
`as a bold lead-in on that assumption; with no fence there, the lines under it are consecutive ` +
857+
`prose and markdown renders them as ONE run-on paragraph. Fence the block in the source's own ` +
858+
`file header, or drop the tag and write the caption as ordinary prose.`,
859+
);
860+
}
861+
}
862+
801863
/** One run of consecutive prose lines, rendered to MDX. */
802864
function renderProse(text: string, ctx: FileDescriptionContext): string {
803865
// ONE resolution rule for every position a path can be referenced from — the
@@ -902,6 +964,13 @@ export function renderFileDescription(source: string, ctx: FileDescriptionContex
902964
);
903965
const kind = classifyLines(lines);
904966

967+
// Before anything is emitted, and against that same classification: a caption
968+
// whose block is missing is refused here rather than published (see
969+
// `assertCaptionedBlocksAreFenced`). Ordered before the renumbering because a
970+
// source this rejects should be told what is wrong with it, not handed a
971+
// heading-depth error it did not cause.
972+
assertCaptionedBlocksAreFenced(lines, kind);
973+
905974
// Renumbered against the SAME classification the render loop below uses, so
906975
// the shift and the "this line is code" verdict can never disagree. Adding
907976
// hashes cannot change a line's kind — the indent is preserved, so a prose

0 commit comments

Comments
 (0)