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
30 changes: 30 additions & 0 deletions .changeset/example-caption-fence-assertion.md
Original file line number Diff line number Diff line change
@@ -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.
116 changes: 116 additions & 0 deletions packages/spec/scripts/file-description.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
79 changes: 74 additions & 5 deletions packages/spec/scripts/lib/file-description.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,11 +502,11 @@ const SKILL_EXAMPLE_MARKER = '<!-- os:check -->';
* 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading