diff --git a/.changeset/example-caption-fence-assertion.md b/.changeset/example-caption-fence-assertion.md new file mode 100644 index 0000000000..c9daf17ef5 --- /dev/null +++ b/.changeset/example-caption-fence-assertion.md @@ -0,0 +1,30 @@ +--- +"@objectstack/spec": patch +--- + +The reference-docs renderer now refuses an `@example CAPTION` with no code block beneath it, +instead of publishing an orphaned caption. + +`@example CAPTION` is declared to be *the caption of the fence beneath it*, and the renderer +acts on that reading: it promotes the tag into a bold lead-in on the assumption that a fence +follows. Nothing asserted that one did. When a module header captioned a listing and wrote its +rows as bare prose, the promotion still fired and the rows below collapsed into a single run-on +paragraph — consecutive non-blank lines are one markdown paragraph, and the docs site loads no +`remark-breaks`. Two customer-facing reference pages shipped that way. + +The assumption is now a precondition the generator checks before it emits anything. A module +description whose caption has no block under it fails the docs build with a message naming the +caption and the source-side fix, the way the renderer already refuses a heading it cannot +renumber. Deliberately a refusal in the generator rather than a separate gate: it makes the +wrong page impossible instead of detecting it afterwards, and it is scoped to the population +the renderer actually renders — module doc blocks — rather than to every `@example` line in the +package. + +⛔ The check never asks whether a run of prose is "really" a table. Shape-sniffing is exactly +what this renderer refuses to do, and what an author writes instead of a fence is not knowable +from the text. It asks only the question the contract already states: is there a block beneath +the caption? An author who wants those words as ordinary prose writes them without the tag. + +Both code kinds satisfy it. An indented block reaches the page as a fence — the render loop +re-emits it as one — so a caption above one captions a fence by the time a reader sees it. All +twelve captions in the corpus are fenced today and are unaffected; no schema behavior changes. diff --git a/packages/spec/scripts/file-description.test.ts b/packages/spec/scripts/file-description.test.ts index e4b297d3b6..8e1d422176 100644 --- a/packages/spec/scripts/file-description.test.ts +++ b/packages/spec/scripts/file-description.test.ts @@ -850,6 +850,122 @@ describe('renderFileDescription — #14455: a tag WITH a payload is rewritten, n }); }); +/** + * #16962 — the caption's fence is a PRECONDITION, and the generator asserts it. + * + * `EXAMPLE_CAPTION` promotes `@example CAPTION` to a bold lead-in because the + * contract says a fence follows. Nothing checked, and #15440 is what that cost: + * two module headers captioned a listing, wrote its rows as bare prose, and the + * rows reached two customer-facing reference pages as one run-on paragraph — + * consecutive non-blank lines are one markdown paragraph, and the docs site + * loads no `remark-breaks`. + * + * ⛔ The refusal is NOT a detector for "prose that is really a table". This + * module's own header rejects that shape-sniffing and so does the card. The + * question asked here is only the one the contract already states: is there a + * block beneath the caption? An author who wants those words as prose writes + * them without the tag. + */ +describe('renderFileDescription — #16962: a caption with no block beneath it is refused, not published', () => { + const ctx = { fromCategory: 'api', sourcePathToDocsRoute: () => null, sectionLevel: PAGE_SECTION_LEVEL }; + + const moduleBlock = (...body: string[]): string => + ['/**', ...body.map(l => (l === '' ? ' *' : ` * ${l}`)), ' */', '', "import { z } from 'zod';", ''].join('\n'); + + it('refuses the #15440 shape — a caption over rows written as bare prose', () => { + // `api/automation-api` and `api/package-api`, reduced to the shape they + // shipped. Before the assertion this rendered `**Endpoints**` followed by + // one paragraph reading `GET /api/automation … POST /api/automation …`. + expect(() => + renderFileDescription( + moduleBlock( + 'Automation API Protocol', + '', + '@example Endpoints', + 'GET /api/automation - list', + 'POST /api/automation - create', + ), + ctx, + ), + ).toThrow(/`@example Endpoints` with no code block beneath it/); + }); + + it('names the source fix, because the source is where the fix goes', () => { + // The renderer cannot repair this and must not try — the same reason the + // heading-depth refusal points at the file header rather than clamping. + expect(() => renderFileDescription(moduleBlock('@example Endpoints', 'GET /api/x'), ctx)).toThrow( + /Fence the block in the source's own file header/, + ); + }); + + it('refuses a caption that ends the block, with nothing at all beneath it', () => { + // The other orphan shape, and the one a "next line is not a fence" test + // written with an off-by-one would sail past. + expect(() => renderFileDescription(moduleBlock('Automation API Protocol', '', '@example Endpoints'), ctx)).toThrow( + /no code block beneath it/, + ); + }); + + it('refuses a caption whose next block is another tag rather than a fence', () => { + // A run of tags is the arrangement `withTagBlocksSeparated` exists for, so + // the caption is followed by a blank line here whatever the source wrote. + // Skipping blanks must not be mistaken for finding a block. + expect(() => + renderFileDescription( + moduleBlock('Automation API Protocol', '', '@example Endpoints', '@see https://example.invalid/api'), + ctx, + ), + ).toThrow(/no code block beneath it/); + }); + + it('accepts the fenced form — the twelve captions in the corpus keep rendering', () => { + const out = renderFileDescription( + moduleBlock('Automation API Protocol', '', '@example Endpoints', '```', 'GET /api/automation', '```'), + ctx, + ); + expect(out).toContain('**Endpoints**\n```'); + }); + + it('accepts a blank line between the caption and its fence', () => { + // Markdown puts the fence under the bold line either way, and the sources + // write both spellings — refusing this one would reject correct pages. + const out = renderFileDescription( + moduleBlock('@example Endpoints', '', '```', 'GET /api/automation', '```'), + ctx, + ); + expect(out).toContain('**Endpoints**'); + expect(out).toContain('GET /api/automation'); + }); + + it('accepts an INDENTED block, which reaches the page as a fence anyway', () => { + // `data/date-macros` and `data/context-tokens` write examples this way and + // the render loop re-emits them fenced. Judged by KIND, so a caption above + // one captions a fence by the time a reader sees it. + const out = renderFileDescription(moduleBlock('@example Macros', '', ' value: 1'), ctx); + expect(out).toContain('**Macros**'); + expect(out).toContain('```\nvalue: 1\n```'); + }); + + it('ignores an `@example CAPTION` shown INSIDE a fence — that is an author illustrating the tag', () => { + // Judged on the same classification the rewrite is, so a header teaching the + // convention is not refused for demonstrating the broken form. A refusal + // written over raw text instead of over `kind` would reject this file's own + // documentation. + const out = renderFileDescription( + moduleBlock('How a module header captions an example:', '', '```md', '@example Endpoints', 'GET /api/x', '```'), + ctx, + ); + expect(out).toContain('@example Endpoints'); + }); + + it('ignores a mid-sentence mention, the same UNTRIMMED test the rewrite uses', () => { + // `MODULE_MARKER`'s rule, and the reason the two can share one pattern: only + // a line that OPENS with the tag is a tag. + const out = renderFileDescription(moduleBlock('Write `@example Foo` above a fence to caption it.'), ctx); + expect(out).toContain('@example Foo'); + }); +}); + /** * #5553 — the block is rendered as the markdown it was written as. * diff --git a/packages/spec/scripts/lib/file-description.ts b/packages/spec/scripts/lib/file-description.ts index 73c6399981..4570a8aaec 100644 --- a/packages/spec/scripts/lib/file-description.ts +++ b/packages/spec/scripts/lib/file-description.ts @@ -502,11 +502,11 @@ const SKILL_EXAMPLE_MARKER = ''; * may be DROPPED is decided by whether it has a payload, and the tags that * reach a page do not answer alike: `@module` and a bare `@example` are their * own entire content, while `@example CAPTION` is the caption of the fence - * beneath it and `@see` is a cross-reference — both rewritten instead - * (`EXAMPLE_CAPTION`, and `renderProse`'s `See also: …`). A blanket line filter - * cannot express that difference; it would take the caption off the page and - * orphan its fence, and "no page loses non-tag prose" is this fix's acceptance - * criterion. `@category` is the one payload-carrying tag that still renders as + * beneath it — asserted by `assertCaptionedBlocksAreFenced`, not merely assumed + * — and `@see` is a cross-reference; both rewritten instead (`EXAMPLE_CAPTION`, + * and `renderProse`'s `See also: …`). A blanket line filter cannot express that + * difference; it would take the caption off the page and orphan its fence, and + * "no page loses non-tag prose" is this fix's acceptance criterion. `@category` is the one payload-carrying tag that still renders as * nothing, and `CATEGORY_MARKER` carries the measurement that says why. * * Judged with the same UNTRIMMED `^@module\b` test `hasModuleMarker` selects @@ -795,9 +795,71 @@ function mapProse(text: string, kinds: ProseRun['kind'][], fn: (plain: string) = * `@example` in a list item — and only ever shown prose, so an `@example` inside * a fence stays as the author wrote it. Held global-safe by `String#replace`, * which resets `lastIndex` around the call. + * + * ⚠️ "the block it captions" is a PRECONDITION, not an observation, and + * `assertCaptionedBlocksAreFenced` is what makes it one. Until it existed this + * comment promised a fence that nothing checked for, and two module headers + * captioned a listing whose rows were bare prose: the promotion still fired, + * and the rows below collapsed into one run-on paragraph on two customer-facing + * reference pages. The rewrite is unconditional BY DESIGN — it may stay that + * way precisely because the assertion runs before it. */ const EXAMPLE_CAPTION = /^@example[ \t]+(\S.*)$/gm; +/** + * The same pattern, per line and stateless — one source, so the two can never + * drift. + * + * Dropping `g` is what makes it safe to `.test()` in a loop: a `g` regex + * carries `lastIndex` between calls and would answer for every second caption. + * Dropping `m` costs nothing, because `^` and `$` against a single line mean + * exactly what they meant against a line of the block. + */ +const EXAMPLE_CAPTION_LINE = new RegExp(EXAMPLE_CAPTION.source); + +/** + * Refuse a caption with no block beneath it, rather than publishing one. + * + * `EXAMPLE_CAPTION` promotes `@example CAPTION` to a bold lead-in on the stated + * assumption that a fence follows. This asserts the assumption instead of + * trusting it — the generator makes the wrong page impossible, which is the + * same move `findModuleDocBlock` makes for block selection and the reason + * neither needs a detector bolted on beside it. + * + * ⛔ It never asks whether a run of prose is "really" a table. That is the + * shape-sniffing this module's own header rejects, and the thing an author + * writes instead of a fence is not knowable from the text. The question here is + * only the one the contract already states: is the next block a block? An + * author who wants the words as ordinary prose writes them without the tag. + * + * Both code kinds count. `indented` reaches the page as a fence — the render + * loop re-emits it as one — so a caption above an indented block captions a + * fence by the time a reader sees it, and refusing it would reject a form that + * renders correctly today. + * + * Blank lines between the caption and its block are skipped: `withTagBlocksSeparated` + * inserts one before every tag, the sources write their own, and markdown puts + * the fence under the bold line either way. + */ +function assertCaptionedBlocksAreFenced(lines: readonly string[], kind: readonly LineKind[]): void { + for (let i = 0; i < lines.length; i++) { + if (kind[i] !== 'prose' || !EXAMPLE_CAPTION_LINE.test(lines[i])) continue; + + let next = i + 1; + while (next < lines.length && lines[next].trim() === '') next++; + if (next < lines.length && (kind[next] === 'fenced' || kind[next] === 'indented')) continue; + + const caption = EXAMPLE_CAPTION_LINE.exec(lines[i])![1]; + throw new Error( + `file-description: this module description writes \`@example ${caption}\` with no code block ` + + `beneath it. An \`@example CAPTION\` line is the caption OF the block below it and is published ` + + `as a bold lead-in on that assumption; with no fence there, the lines under it are consecutive ` + + `prose and markdown renders them as ONE run-on paragraph. Fence the block in the source's own ` + + `file header, or drop the tag and write the caption as ordinary prose.`, + ); + } +} + /** One run of consecutive prose lines, rendered to MDX. */ function renderProse(text: string, ctx: FileDescriptionContext): string { // ONE resolution rule for every position a path can be referenced from — the @@ -902,6 +964,13 @@ export function renderFileDescription(source: string, ctx: FileDescriptionContex ); const kind = classifyLines(lines); + // Before anything is emitted, and against that same classification: a caption + // whose block is missing is refused here rather than published (see + // `assertCaptionedBlocksAreFenced`). Ordered before the renumbering because a + // source this rejects should be told what is wrong with it, not handed a + // heading-depth error it did not cause. + assertCaptionedBlocksAreFenced(lines, kind); + // Renumbered against the SAME classification the render loop below uses, so // the shift and the "this line is code" verdict can never disagree. Adding // hashes cannot change a line's kind — the indent is preserved, so a prose