From 8e4d77a017dfe57c3a11cba50effe8e3b7cd5894 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:54:44 +0000 Subject: [PATCH 1/3] fix(spec): the generated upgrade guide keeps a surface in one code span when it carries its own backticks The protocol upgrade guide wrapped every conversion's and semantic entry's `surface` in a single-backtick code span. 183 of the 516 semantic entries mark their own inline code inside the surface, and CommonMark closes a single-backtick span at the first backtick inside it, so those lines rendered as alternating code and prose fragments. Two conversion surfaces carry a `|`, which split their table row into extra cells. Both render sites now go through one helper, `codeSpan` in `scripts/lib/markdown-code-span.ts`: the span is fenced one backtick longer than the longest run inside the text (CommonMark's rule), padded when the text starts or ends with a backtick, and byte-identical for a backtick-free text. A table row escapes `|` in every cell. `readCodeSpan`, the reading half of the same rule, lets the pin decode what the generator wrote. The pin (`scripts/upgrade-guide-surface-span.test.ts`) covers the helper, the real entry set (every surface round-trips), and the generated guide read back span by span, so a new entry cannot bring the defect back. The workaround note in `18.visibility-predicate-has-argument-refused.ts` is dropped. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gP8YEs7CoWrbVsSAi --- .../27532-upgrade-guide-surface-span.md | 10 + packages/spec/scripts/build-upgrade-guide.ts | 26 ++- .../spec/scripts/lib/markdown-code-span.ts | 77 +++++++ .../upgrade-guide-surface-span.test.ts | 196 ++++++++++++++++++ ...sibility-predicate-has-argument-refused.ts | 2 - 5 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 .changeset/27532-upgrade-guide-surface-span.md create mode 100644 packages/spec/scripts/lib/markdown-code-span.ts create mode 100644 packages/spec/scripts/upgrade-guide-surface-span.test.ts diff --git a/.changeset/27532-upgrade-guide-surface-span.md b/.changeset/27532-upgrade-guide-surface-span.md new file mode 100644 index 00000000000..15ff3de9e3d --- /dev/null +++ b/.changeset/27532-upgrade-guide-surface-span.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): the generated protocol upgrade guide keeps an entry's `surface` in one code span when the surface carries backticks of its own + +Clause-②: no (a generator fix: no schema, accepted value, served body or error code moves; only the rendering of the guide the package ships changes) +Metadata-protocol: none + +`protocol-upgrade-guide.md`, generated at publish from the ADR-0087 registries, shipped in `@objectstack/spec` and attached to the GitHub Release, wrapped every conversion's and semantic entry's `surface` in a single-backtick code span. Most semantic entries mark their own inline code inside the surface, and CommonMark closes a single-backtick span at the first backtick inside it, so those lines rendered as alternating code and prose fragments. The generator now fences each surface by CommonMark's rule, one backtick longer than the longest run inside it, so a surface renders as one span with the author's backticks shown literally; a backtick-free surface is byte-identical to before. A conversion-table cell also escapes `|`, the one character a table row reads as structure. diff --git a/packages/spec/scripts/build-upgrade-guide.ts b/packages/spec/scripts/build-upgrade-guide.ts index 5ee6dce6fc1..c0296e4a2ff 100644 --- a/packages/spec/scripts/build-upgrade-guide.ts +++ b/packages/spec/scripts/build-upgrade-guide.ts @@ -43,6 +43,7 @@ import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry'; import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '../src/kernel/protocol-version'; import { MIGRATIONS_BY_MAJOR, MIGRATION_SUPPORT_FLOOR } from '../src/migrations/registry'; import { exitWith, runProjectionCli, type ProjectionCliResult } from './lib/projection-cli'; +import { codeSpan } from './lib/markdown-code-span'; import { buildDocsPages, DOCS_DIR, writeDocsPages, type HopInput } from './lib/upgrade-guide-docs'; const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); @@ -97,6 +98,27 @@ function howToUpgrade(form: 'file' | 'page'): string[] { return lines; } +/** + * A GFM table row. The pipe is the one character a row reads as structure, in + * every inline span of the cell included, so each cell escapes it. + */ +function tableRow(cells: readonly string[]): string { + return `| ${cells.map((cell) => cell.replace(/\|/g, '\\|')).join(' | ')} |`; +} + +/** + * A `surface` as one code span, however the entry author wrote it. Many + * entries mark their own code inside it (`` `globalFilters` on a view ``), and a + * single-backtick wrapper closed at the first of those marks, so the line + * rendered as alternating code and prose fragments. `codeSpan` fences the text + * by CommonMark's rule (one backtick longer than the longest run inside), which + * leaves every backtick-free surface byte-identical and shows an author's inner + * backticks literally inside the span. + */ +function surfaceSpan(surface: string): string { + return codeSpan(surface); +} + /** One hop's own section: `## Protocol N-1 → N` and everything under it. */ function hopSection(major: number): string[] { const lines: string[] = []; @@ -123,7 +145,7 @@ function hopSection(major: number): string[] { const window = c.retiredFromLoadPath ? 'retired — `migrate meta` only' : `live — protocol ${c.toMajor} loader accepts the old shape`; - out(`| \`${c.id}\` | \`${c.surface}\` | ${c.summary} | ${window} |`); + out(tableRow([`\`${c.id}\``, surfaceSpan(c.surface), c.summary, window])); } out(); } @@ -132,7 +154,7 @@ function hopSection(major: number): string[] { out('### Semantic (delegated to you, with acceptance criteria)'); out(); for (const s of semantic) { - out(`- **\`${s.id}\`** — \`${s.surface}\` → ${s.replacement}`); + out(`- **\`${s.id}\`** — ${surfaceSpan(s.surface)} → ${s.replacement}`); out(` - Why not automatic: ${s.reason}`); out(` - Done when: ${s.acceptanceCriteria}`); } diff --git a/packages/spec/scripts/lib/markdown-code-span.ts b/packages/spec/scripts/lib/markdown-code-span.ts new file mode 100644 index 00000000000..3d3c23ed08e --- /dev/null +++ b/packages/spec/scripts/lib/markdown-code-span.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A Markdown code span that holds ANY one-line text, backticks included. + * + * CommonMark §6.1: a code span opens with a backtick string and closes at the + * next backtick string of the SAME length. So a single-backtick span closes at + * the first backtick inside it — which is what broke the protocol upgrade + * guide: a `surface` the entry author wrote with its own inline code + * (`` `globalFilters` on a view ``) was wrapped in one more single-backtick + * span and rendered as alternating code and prose fragments. The fix is the + * rule the spec gives for exactly this: fence the text with a backtick string + * one longer than the longest run inside it, and pad with one space when the + * text starts or ends with a backtick or a space (the renderer strips one + * space from each end when both are present, so the pad is what it strips). + * + * `readCodeSpan` is the READING half of the same rule, kept here so the pin + * over the real registries can decode what the generator wrote without + * repeating the generator's format: a span is unbroken when the reader gets + * the author's text back. + */ + +/** Longest run of backticks in `text` (0 when it has none). */ +function longestBacktickRun(text: string): number { + let longest = 0; + for (const run of text.match(/`+/g) ?? []) longest = Math.max(longest, run.length); + return longest; +} + +/** + * `text` as one code span, whatever backticks it carries. Byte-identical to + * `` `${text}` `` for a text with no backtick at all, so the common case does + * not move. Refuses a blank or multi-line text: a code span cannot say nothing, + * and a line ending would render as a space and end the list item or table row + * the span sits in. + */ +export function codeSpan(text: string): string { + if (text.trim() === '') throw new Error('codeSpan: a code span cannot hold a blank text.'); + if (/[\r\n]/.test(text)) throw new Error(`codeSpan: a code span holds one line; got ${JSON.stringify(text)}.`); + const fence = '`'.repeat(longestBacktickRun(text) + 1); + const pad = /^[` ]|[` ]$/.test(text) ? ' ' : ''; + return `${fence}${pad}${text}${pad}${fence}`; +} + +/** A backtick string: one or more backticks neither preceded nor followed by a backtick. */ +function backtickStringLengthAt(markdown: string, at: number): number { + if (markdown[at] !== '`' || (at > 0 && markdown[at - 1] === '`')) return 0; + let end = at; + while (markdown[end] === '`') end++; + return end - at; +} + +/** + * The code span that opens at `at` in `markdown`, read by CommonMark's rule: + * its content (after the one-space strip), and the index just past its closing + * backtick string. `null` when no backtick string of the opening length + * follows — the opener is then literal backticks, not a span. + */ +export function readCodeSpan(markdown: string, at: number): { content: string; end: number } | null { + const open = backtickStringLengthAt(markdown, at); + if (open === 0) return null; + let i = at + open; + while (i < markdown.length) { + if (markdown[i] !== '`') { + i++; + continue; + } + const run = backtickStringLengthAt(markdown, i); + if (run === open) { + let content = markdown.slice(at + open, i).replace(/\r?\n/g, ' '); + if (content.startsWith(' ') && content.endsWith(' ') && content.trim() !== '') content = content.slice(1, -1); + return { content, end: i + run }; + } + i += run; + } + return null; +} diff --git a/packages/spec/scripts/upgrade-guide-surface-span.test.ts b/packages/spec/scripts/upgrade-guide-surface-span.test.ts new file mode 100644 index 00000000000..9f82ce7f116 --- /dev/null +++ b/packages/spec/scripts/upgrade-guide-surface-span.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The protocol upgrade guide renders every entry's `surface` inside a code +// span, and a surface may carry backticks of its own: most semantic entries +// mark their inline code in it. CommonMark closes a span at the next backtick +// string of the opening length, so the span must be fenced longer than any run +// inside it — or the line renders as alternating code and prose fragments. +// Three pins, kept apart: +// +// - the helper: `codeSpan` fences by that rule and is byte-identical for a +// backtick-free text; `readCodeSpan` reads a span the way a renderer does, and +// reads the OLD single-backtick wrapping of a marked surface as broken (the +// lit control — a reader that cannot see a break would prove nothing); +// - the real entry set: every conversion and semantic surface the registries +// carry round-trips through the pair, so a new entry cannot bring the defect +// back whatever backticks it carries; +// - the generated guide: the REAL generator's bytes, read back span by span, +// give every entry's surface verbatim — pinned on what ships, not on the +// helper. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from '../src/conversions/registry'; +import { MIGRATION_SUPPORT_FLOOR, MIGRATIONS_BY_MAJOR } from '../src/migrations/registry'; +import type { SemanticMigration } from '../src/migrations/types'; +import { codeSpan, readCodeSpan } from './lib/markdown-code-span'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG = path.resolve(HERE, '..'); +const TSX = path.join(PKG, 'node_modules', '.bin', 'tsx'); +const SPAWN_TIMEOUT_MS = 120_000; + +/** Every semantic entry with the major its hop arrives at. */ +function semanticEntries(): { major: number; entry: SemanticMigration }[] { + const out: { major: number; entry: SemanticMigration }[] = []; + for (const [major, step] of Object.entries(MIGRATIONS_BY_MAJOR)) { + for (const entry of step.semantic) out.push({ major: Number(major), entry }); + } + return out; +} + +describe('codeSpan — one code span for any one-line text', () => { + it('a backtick-free text is wrapped in single backticks, byte for byte as before', () => { + expect(codeSpan('flow.node.type')).toBe('`flow.node.type`'); + expect(codeSpan('data.query.aggregations[].distinct')).toBe('`data.query.aggregations[].distinct`'); + }); + + it('a text with its own backticks is fenced one backtick longer than its longest run', () => { + expect(codeSpan('x `a` y')).toBe('``x `a` y``'); + expect(codeSpan('a ``b`` c')).toBe('```a ``b`` c```'); + }); + + it('a text that starts or ends with a backtick or a space is padded, which the reader strips again', () => { + expect(codeSpan('`a` on b')).toBe('`` `a` on b ``'); + expect(codeSpan('on `b`')).toBe('`` on `b` ``'); + expect(codeSpan(' lead')).toBe('` lead `'); + }); + + it('a blank or multi-line text is refused, never rendered as a span that says nothing or breaks its line', () => { + expect(() => codeSpan('')).toThrow(/blank/); + expect(() => codeSpan(' ')).toThrow(/blank/); + expect(() => codeSpan('a\nb')).toThrow(/one line/); + }); + + it('round-trips through the reader', () => { + for (const text of ['flow.node.type', 'x `a` y', '`a` on b', 'on `b`', 'a ``b`` c', ' lead', 'trail ', '`', '``']) { + const span = codeSpan(text); + expect(readCodeSpan(span, 0), span).toEqual({ content: text, end: span.length }); + } + }); +}); + +describe('readCodeSpan — CommonMark’s reading of a span', () => { + it('closes at the next backtick string of the opening length, and strips one space from each end', () => { + expect(readCodeSpan('`a` b', 0)).toEqual({ content: 'a', end: 3 }); + expect(readCodeSpan('`` `a` b `` c', 0)).toEqual({ content: '`a` b', end: 11 }); + expect(readCodeSpan('x `a`', 2)).toEqual({ content: 'a', end: 5 }); + }); + + it('an opener with no closer of its length is not a span', () => { + expect(readCodeSpan('``a` b', 0)).toBeNull(); + expect(readCodeSpan('`a', 0)).toBeNull(); + }); + + it('a position that is not the start of a backtick string is not a span', () => { + expect(readCodeSpan('a `b`', 0)).toBeNull(); + expect(readCodeSpan('``a``', 1)).toBeNull(); + }); + + it('the lit control: the old single-backtick wrapping of a real marked surface reads as broken', () => { + // A surface with a single-backtick string inside: the old wrapper closed at + // it, or opened a double-backtick span that never closes. (A run of two or + // more inside a single-backtick span is literal and would not break it.) + const marked = semanticEntries().filter(({ entry }) => /(?:^|[^`])`(?:[^`]|$)/.test(entry.surface)); + expect(marked.length).toBeGreaterThan(100); + for (const { entry } of marked) { + const old = `\`${entry.surface}\``; + expect(readCodeSpan(old, 0)?.content, entry.id).not.toBe(entry.surface); + } + }); +}); + +describe('the real entry set — every surface the registries carry renders as one unbroken span', () => { + it('every semantic surface round-trips, whatever backticks it carries', () => { + const entries = semanticEntries(); + expect(entries.length).toBeGreaterThan(400); + for (const { entry } of entries) { + const span = codeSpan(entry.surface); + expect(readCodeSpan(span, 0)?.content, entry.id).toBe(entry.surface); + } + }); + + it('every conversion surface round-trips, whatever backticks it carries', () => { + expect(ALL_CONVERSIONS.length).toBeGreaterThan(50); + for (const c of ALL_CONVERSIONS) { + const span = codeSpan(c.surface); + expect(readCodeSpan(span, 0)?.content, c.id).toBe(c.surface); + } + }); +}); + +describe('the generated guide — read back span by span, every entry’s surface is verbatim', () => { + let tmp: string; + let sections: Map; + + beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'upgrade-guide-surface-')); + const file = path.join(tmp, 'guide.md'); + const r = spawnSync(TSX, [path.join(HERE, 'build-upgrade-guide.ts'), '--out', file], { + cwd: PKG, + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'pipe'], + }); + expect(r.status, `${r.stdout ?? ''}${r.stderr ?? ''}`).toBe(0); + const text = fs.readFileSync(file, 'utf8'); + sections = new Map(); + for (const part of text.split(/^(?=## Protocol \d+ → \d+$)/m).slice(1)) { + sections.set(Number(/→ (\d+)/.exec(part)![1]), part.split('\n')); + } + }); + + afterAll(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + /** + * Whether the guide renders a hop for `major`: it starts one above the chain's + * support floor, so a registry major at or below the floor has no section, + * and one above it must. + */ + function rendered(major: number): boolean { + const has = sections.has(major); + if (!has) expect(major, `protocol ${major} has no section`).toBeLessThanOrEqual(MIGRATION_SUPPORT_FLOOR); + return has; + } + + /** The one line of `major`'s section that starts with `prefix`. */ + function lineOf(major: number, prefix: string): string { + const hits = (sections.get(major) ?? []).filter((line) => line.startsWith(prefix)); + expect(hits, prefix).toHaveLength(1); + return hits[0]!; + } + + it('each semantic entry’s line carries its surface as one span, followed by the replacement arrow', () => { + const entries = semanticEntries(); + expect(entries.length).toBeGreaterThan(400); + for (const { major, entry } of entries) { + if (!rendered(major)) continue; + const prefix = `- **\`${entry.id}\`** — `; + const line = lineOf(major, prefix); + const span = readCodeSpan(line, prefix.length); + expect(span?.content, entry.id).toBe(entry.surface); + expect(line.slice(span!.end).startsWith(' → '), entry.id).toBe(true); + } + }); + + it('each conversion’s table row carries its surface as one span in its own cell', () => { + for (const [key, list] of Object.entries(CONVERSIONS_BY_MAJOR)) { + const major = Number(key); + if (!rendered(major)) continue; + for (const c of list) { + const prefix = `| \`${c.id}\` | `; + const line = lineOf(major, prefix); + const span = readCodeSpan(line, prefix.length); + expect(span?.content.replace(/\\\|/g, '|'), c.id).toBe(c.surface); + expect(line.slice(span!.end).startsWith(' | '), c.id).toBe(true); + } + } + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.visibility-predicate-has-argument-refused.ts b/packages/spec/src/migrations/entries/semantic/18.visibility-predicate-has-argument-refused.ts index db249647f86..bb9398e5dca 100644 --- a/packages/spec/src/migrations/entries/semantic/18.visibility-predicate-has-argument-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.visibility-predicate-has-argument-refused.ts @@ -10,8 +10,6 @@ import type { SemanticMigration } from '../../types.js'; // Which one the author meant is not recorded, and the platform does not guess. export const entry: SemanticMigration = { id: 'visibility-predicate-has-argument-refused', - // No backticks in `surface` — build-upgrade-guide renders it inside a code - // span already, and a nested backtick would close it. surface: 'visibleWhen on a page component and on a form view\'s sections and section fields — a has() call whose ' + 'argument is not a field selection: a name alone, has(status), an index as the last step, ' From d5876c748b4c36f6400bba07a1e5082e66dfbc16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:23:08 +0000 Subject: [PATCH 2/3] fix(spec): the conversion-row pin reads cells the GFM way, and the sibling tests stop forbidding backticks in a surface Applies the independent review's findings on the previous head. - The generated-guide pin splits each conversion row into cells as GFM does (every pipe ends a cell, inside a code span or not, except an escaped one) and expects exactly four, with the surface span filling its own cell; its lit control shows the old single-backtick row of a pipe-carrying surface splitting into extra cells. Before this the pin could not see the pipe defect the row escape fixes. - Two sibling tests pinned the workaround the card drops ("No backticks in surface: the upgrade guide renders it inside a code span") on real entries; the sentence is now false and the assertion forbade what is legal, so both pairs go. - The generator's refusal names the entry; the helper's docblocks say "no backtick and no leading or trailing space" instead of "backtick-free", and give the table-row reason for refusing a line ending; the reader treats a lone carriage return as a line ending and strips padding only around spaces; the conversion entry-set pin asserts no surface carries a backslash, where GFM renderers part ways on an escaped pipe. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gP8YEs7CoWrbVsSAi --- .../27532-upgrade-guide-surface-span.md | 2 +- packages/spec/scripts/build-upgrade-guide.ts | 18 ++-- .../spec/scripts/lib/markdown-code-span.ts | 26 +++--- .../upgrade-guide-surface-span.test.ts | 88 ++++++++++++++----- .../api/export-job-family-retirement.test.ts | 2 - .../package-upgrade-family-retirement.test.ts | 2 - 6 files changed, 90 insertions(+), 48 deletions(-) diff --git a/.changeset/27532-upgrade-guide-surface-span.md b/.changeset/27532-upgrade-guide-surface-span.md index 15ff3de9e3d..e25961e2f4f 100644 --- a/.changeset/27532-upgrade-guide-surface-span.md +++ b/.changeset/27532-upgrade-guide-surface-span.md @@ -7,4 +7,4 @@ fix(spec): the generated protocol upgrade guide keeps an entry's `surface` in on Clause-②: no (a generator fix: no schema, accepted value, served body or error code moves; only the rendering of the guide the package ships changes) Metadata-protocol: none -`protocol-upgrade-guide.md`, generated at publish from the ADR-0087 registries, shipped in `@objectstack/spec` and attached to the GitHub Release, wrapped every conversion's and semantic entry's `surface` in a single-backtick code span. Most semantic entries mark their own inline code inside the surface, and CommonMark closes a single-backtick span at the first backtick inside it, so those lines rendered as alternating code and prose fragments. The generator now fences each surface by CommonMark's rule, one backtick longer than the longest run inside it, so a surface renders as one span with the author's backticks shown literally; a backtick-free surface is byte-identical to before. A conversion-table cell also escapes `|`, the one character a table row reads as structure. +`protocol-upgrade-guide.md`, generated at publish from the ADR-0087 registries, shipped in `@objectstack/spec` and attached to the GitHub Release, wrapped every conversion's and semantic entry's `surface` in a single-backtick code span. Most semantic entries mark their own inline code inside the surface, and CommonMark closes a single-backtick span at the first backtick inside it, so those lines rendered as alternating code and prose fragments. The generator now fences each surface by CommonMark's rule, one backtick longer than the longest run inside it, so a surface renders as one span with the author's backticks shown literally; a surface with no backtick and no leading or trailing space is byte-identical to before. A conversion-table cell also escapes `|`, the one character a table row reads as structure. diff --git a/packages/spec/scripts/build-upgrade-guide.ts b/packages/spec/scripts/build-upgrade-guide.ts index c0296e4a2ff..e2c5f5fe2b8 100644 --- a/packages/spec/scripts/build-upgrade-guide.ts +++ b/packages/spec/scripts/build-upgrade-guide.ts @@ -107,16 +107,12 @@ function tableRow(cells: readonly string[]): string { } /** - * A `surface` as one code span, however the entry author wrote it. Many - * entries mark their own code inside it (`` `globalFilters` on a view ``), and a - * single-backtick wrapper closed at the first of those marks, so the line - * rendered as alternating code and prose fragments. `codeSpan` fences the text - * by CommonMark's rule (one backtick longer than the longest run inside), which - * leaves every backtick-free surface byte-identical and shows an author's inner - * backticks literally inside the span. + * An entry's `surface` as one code span, however the author wrote it: both + * render sites go through this one door (`lib/markdown-code-span.ts` says why + * the fence is longer than a backtick), and a refusal names the entry. */ -function surfaceSpan(surface: string): string { - return codeSpan(surface); +function surfaceSpan(id: string, surface: string): string { + return codeSpan(surface, `entry ${id}`); } /** One hop's own section: `## Protocol N-1 → N` and everything under it. */ @@ -145,7 +141,7 @@ function hopSection(major: number): string[] { const window = c.retiredFromLoadPath ? 'retired — `migrate meta` only' : `live — protocol ${c.toMajor} loader accepts the old shape`; - out(tableRow([`\`${c.id}\``, surfaceSpan(c.surface), c.summary, window])); + out(tableRow([`\`${c.id}\``, surfaceSpan(c.id, c.surface), c.summary, window])); } out(); } @@ -154,7 +150,7 @@ function hopSection(major: number): string[] { out('### Semantic (delegated to you, with acceptance criteria)'); out(); for (const s of semantic) { - out(`- **\`${s.id}\`** — ${surfaceSpan(s.surface)} → ${s.replacement}`); + out(`- **\`${s.id}\`** — ${surfaceSpan(s.id, s.surface)} → ${s.replacement}`); out(` - Why not automatic: ${s.reason}`); out(` - Done when: ${s.acceptanceCriteria}`); } diff --git a/packages/spec/scripts/lib/markdown-code-span.ts b/packages/spec/scripts/lib/markdown-code-span.ts index 3d3c23ed08e..ddb3aeac60b 100644 --- a/packages/spec/scripts/lib/markdown-code-span.ts +++ b/packages/spec/scripts/lib/markdown-code-span.ts @@ -29,14 +29,15 @@ function longestBacktickRun(text: string): number { /** * `text` as one code span, whatever backticks it carries. Byte-identical to - * `` `${text}` `` for a text with no backtick at all, so the common case does - * not move. Refuses a blank or multi-line text: a code span cannot say nothing, - * and a line ending would render as a space and end the list item or table row - * the span sits in. + * `` `${text}` `` for a text with no backtick and no leading or trailing space, + * so the common case does not move. Refuses a blank or multi-line text, naming + * `subject` (the entry, for the generator) in the refusal: a code span cannot + * say nothing, and a line ending renders as a space inside the span and, in a + * table row, ends the row. */ -export function codeSpan(text: string): string { - if (text.trim() === '') throw new Error('codeSpan: a code span cannot hold a blank text.'); - if (/[\r\n]/.test(text)) throw new Error(`codeSpan: a code span holds one line; got ${JSON.stringify(text)}.`); +export function codeSpan(text: string, subject = 'codeSpan'): string { + if (text.trim() === '') throw new Error(`${subject}: a code span cannot hold a blank text.`); + if (/[\r\n]/.test(text)) throw new Error(`${subject}: a code span holds one line; got ${JSON.stringify(text)}.`); const fence = '`'.repeat(longestBacktickRun(text) + 1); const pad = /^[` ]|[` ]$/.test(text) ? ' ' : ''; return `${fence}${pad}${text}${pad}${fence}`; @@ -52,9 +53,10 @@ function backtickStringLengthAt(markdown: string, at: number): number { /** * The code span that opens at `at` in `markdown`, read by CommonMark's rule: - * its content (after the one-space strip), and the index just past its closing - * backtick string. `null` when no backtick string of the opening length - * follows — the opener is then literal backticks, not a span. + * its content (line endings as spaces; one space stripped from each end when + * both ends are spaces and the content is not spaces only), and the index just + * past its closing backtick string. `null` when no backtick string of the + * opening length follows — the opener is then literal backticks, not a span. */ export function readCodeSpan(markdown: string, at: number): { content: string; end: number } | null { const open = backtickStringLengthAt(markdown, at); @@ -67,8 +69,8 @@ export function readCodeSpan(markdown: string, at: number): { content: string; e } const run = backtickStringLengthAt(markdown, i); if (run === open) { - let content = markdown.slice(at + open, i).replace(/\r?\n/g, ' '); - if (content.startsWith(' ') && content.endsWith(' ') && content.trim() !== '') content = content.slice(1, -1); + let content = markdown.slice(at + open, i).replace(/\r\n?|\n/g, ' '); + if (content.startsWith(' ') && content.endsWith(' ') && !/^ +$/.test(content)) content = content.slice(1, -1); return { content, end: i + run }; } i += run; diff --git a/packages/spec/scripts/upgrade-guide-surface-span.test.ts b/packages/spec/scripts/upgrade-guide-surface-span.test.ts index 9f82ce7f116..95356b959ba 100644 --- a/packages/spec/scripts/upgrade-guide-surface-span.test.ts +++ b/packages/spec/scripts/upgrade-guide-surface-span.test.ts @@ -4,19 +4,21 @@ // span, and a surface may carry backticks of its own: most semantic entries // mark their inline code in it. CommonMark closes a span at the next backtick // string of the opening length, so the span must be fenced longer than any run -// inside it — or the line renders as alternating code and prose fragments. -// Three pins, kept apart: +// inside it — or the line renders as alternating code and prose fragments. A +// conversion's surface sits in a GFM table cell as well, where an unescaped +// pipe ends the cell, inside a code span or not. Three pins, kept apart: // -// - the helper: `codeSpan` fences by that rule and is byte-identical for a -// backtick-free text; `readCodeSpan` reads a span the way a renderer does, and -// reads the OLD single-backtick wrapping of a marked surface as broken (the -// lit control — a reader that cannot see a break would prove nothing); +// - the helper: `codeSpan` fences by that rule and leaves a text with no +// backtick and no leading or trailing space byte-identical; `readCodeSpan` +// reads a span the way a renderer does, and reads the OLD single-backtick +// wrapping of a marked surface as broken (the lit control — a reader that +// cannot see a break would prove nothing); // - the real entry set: every conversion and semantic surface the registries // carry round-trips through the pair, so a new entry cannot bring the defect // back whatever backticks it carries; -// - the generated guide: the REAL generator's bytes, read back span by span, -// give every entry's surface verbatim — pinned on what ships, not on the -// helper. +// - the generated guide: the REAL generator's bytes, read back span by span and +// cell by cell, give every entry's surface verbatim — pinned on what ships, +// not on the helper. import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { spawnSync } from 'node:child_process'; @@ -44,8 +46,32 @@ function semanticEntries(): { major: number; entry: SemanticMigration }[] { return out; } +/** + * A GFM table row's cells, read the way the spec reads them: every `|` ends a + * cell, inside a code span or not, except one escaped as `\|` (GFM §4.10, + * example 200). The pieces outside the row's leading and trailing pipe are + * dropped; each cell is trimmed. + */ +function gfmCells(row: string): string[] { + const cells: string[] = []; + let cell = ''; + for (let i = 0; i < row.length; i++) { + if (row[i] === '\\' && row[i + 1] === '|') { + cell += '\\|'; + i++; + } else if (row[i] === '|') { + cells.push(cell); + cell = ''; + } else { + cell += row[i]; + } + } + cells.push(cell); + return cells.slice(1, -1).map((c) => c.trim()); +} + describe('codeSpan — one code span for any one-line text', () => { - it('a backtick-free text is wrapped in single backticks, byte for byte as before', () => { + it('a text with no backtick and no leading or trailing space is wrapped in single backticks, byte for byte as before', () => { expect(codeSpan('flow.node.type')).toBe('`flow.node.type`'); expect(codeSpan('data.query.aggregations[].distinct')).toBe('`data.query.aggregations[].distinct`'); }); @@ -61,10 +87,11 @@ describe('codeSpan — one code span for any one-line text', () => { expect(codeSpan(' lead')).toBe('` lead `'); }); - it('a blank or multi-line text is refused, never rendered as a span that says nothing or breaks its line', () => { - expect(() => codeSpan('')).toThrow(/blank/); + it('a blank or multi-line text is refused, naming the subject, never rendered as a span that says nothing or breaks its row', () => { + expect(() => codeSpan('')).toThrow(/^codeSpan: .*blank/); expect(() => codeSpan(' ')).toThrow(/blank/); expect(() => codeSpan('a\nb')).toThrow(/one line/); + expect(() => codeSpan('', 'entry some-id')).toThrow(/^entry some-id: /); }); it('round-trips through the reader', () => { @@ -82,6 +109,13 @@ describe('readCodeSpan — CommonMark’s reading of a span', () => { expect(readCodeSpan('x `a`', 2)).toEqual({ content: 'a', end: 5 }); }); + it('a line ending inside the span reads as a space; a span of spaces only keeps them', () => { + expect(readCodeSpan('`a\nb`', 0)).toEqual({ content: 'a b', end: 5 }); + expect(readCodeSpan('`a\r\nb`', 0)).toEqual({ content: 'a b', end: 6 }); + expect(readCodeSpan('` `', 0)).toEqual({ content: ' ', end: 4 }); + expect(readCodeSpan('` \t `', 0)).toEqual({ content: '\t', end: 5 }); + }); + it('an opener with no closer of its length is not a span', () => { expect(readCodeSpan('``a` b', 0)).toBeNull(); expect(readCodeSpan('`a', 0)).toBeNull(); @@ -115,11 +149,15 @@ describe('the real entry set — every surface the registries carry renders as o } }); - it('every conversion surface round-trips, whatever backticks it carries', () => { + it('every conversion surface round-trips, whatever backticks it carries, and carries no backslash', () => { expect(ALL_CONVERSIONS.length).toBeGreaterThan(50); for (const c of ALL_CONVERSIONS) { const span = codeSpan(c.surface); expect(readCodeSpan(span, 0)?.content, c.id).toBe(c.surface); + // A backslash before a pipe is where GFM renderers part ways (one reads + // `\\|` as a literal backslash and a cell boundary, another as a backslash + // and an escaped pipe), so a conversion surface carries none — loudly. + expect(c.surface, c.id).not.toContain('\\'); } }); }); @@ -160,9 +198,9 @@ describe('the generated guide — read back span by span, every entry’s surfac return has; } - /** The one line of `major`'s section that starts with `prefix`. */ + /** The one line of a rendered `major`'s section that starts with `prefix`. */ function lineOf(major: number, prefix: string): string { - const hits = (sections.get(major) ?? []).filter((line) => line.startsWith(prefix)); + const hits = sections.get(major)!.filter((line) => line.startsWith(prefix)); expect(hits, prefix).toHaveLength(1); return hits[0]!; } @@ -180,17 +218,27 @@ describe('the generated guide — read back span by span, every entry’s surfac } }); - it('each conversion’s table row carries its surface as one span in its own cell', () => { + it('each conversion’s table row is four cells, the surface one span filling its own cell', () => { for (const [key, list] of Object.entries(CONVERSIONS_BY_MAJOR)) { const major = Number(key); if (!rendered(major)) continue; for (const c of list) { - const prefix = `| \`${c.id}\` | `; - const line = lineOf(major, prefix); - const span = readCodeSpan(line, prefix.length); + const line = lineOf(major, `| \`${c.id}\` | `); + const cells = gfmCells(line); + expect(cells, c.id).toHaveLength(4); + const span = readCodeSpan(cells[1]!, 0); + expect(span?.end, c.id).toBe(cells[1]!.length); expect(span?.content.replace(/\\\|/g, '|'), c.id).toBe(c.surface); - expect(line.slice(span!.end).startsWith(' | '), c.id).toBe(true); } } }); + + it('the lit control: the old single-backtick row of a conversion whose surface carries a pipe splits into extra cells', () => { + const piped = ALL_CONVERSIONS.filter((c) => c.surface.includes('|')); + expect(piped.length).toBeGreaterThan(0); + for (const c of piped) { + const old = `| \`${c.id}\` | \`${c.surface}\` | ${c.summary} | live |`; + expect(gfmCells(old).length, c.id).toBeGreaterThan(4); + } + }); }); diff --git a/packages/spec/src/api/export-job-family-retirement.test.ts b/packages/spec/src/api/export-job-family-retirement.test.ts index 33407fb8525..f2fd2f85c4b 100644 --- a/packages/spec/src/api/export-job-family-retirement.test.ts +++ b/packages/spec/src/api/export-job-family-retirement.test.ts @@ -211,8 +211,6 @@ describe('ADR-0087 registration', () => { expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); // The route is stated where the next reader looks: why D3 semantic and not D2. expect(entry!.reason).toMatch(/not a D2 conversion/); - // No backticks in `surface`: the upgrade guide renders it inside a code span AND a table cell. - expect(entry!.surface).not.toMatch(/`/); // The surface names every def and the three retired surfaces beside them. for (const def of RETIRED_DEFS) expect(entry!.surface, `surface must name ${def}`).toContain(def); for (const name of ['ExportApiContracts', 'IExportService', 'ScheduleExportInput', 'ScheduleStateSchema']) { diff --git a/packages/spec/src/kernel/package-upgrade-family-retirement.test.ts b/packages/spec/src/kernel/package-upgrade-family-retirement.test.ts index 7f41bc5dbf7..85b5d015abd 100644 --- a/packages/spec/src/kernel/package-upgrade-family-retirement.test.ts +++ b/packages/spec/src/kernel/package-upgrade-family-retirement.test.ts @@ -202,8 +202,6 @@ describe('ADR-0087 registration', () => { expect(entry!.acceptanceCriteria).toMatch(/TS2305/); // The route is stated where the next reader looks: why D3 semantic and not D2. expect(entry!.reason).toMatch(/not a D2 conversion/); - // No backticks in `surface`: the upgrade guide renders it inside a code span AND a table cell. - expect(entry!.surface).not.toMatch(/`/); for (const def of RETIRED_DEFS) expect(entry!.surface, `surface must name ${def}`).toContain(def); for (const name of ['planUpgrade', 'getSnapshot', 'PlanUpgradeInput', 'PackageUpgradeResponse.data']) { expect(entry!.surface, `surface must name ${name}`).toContain(name); From 851518444ff8fabc84bbc43c20c9104ed53047af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:35:34 +0000 Subject: [PATCH 3/3] test(spec): the upgrade-guide pin's pipe control outlives today's two pipe-carrying conversions Applies the delta review's findings. The lit control for the table-row escape starts from a literal old-style row with a pipe in its surface, so it keeps biting after the two retirement kits that carry a pipe today age out one major on; the real-registry loop follows with no count floor. The count floors on the other real-set pins drop to "more than zero", the liveness they need. The conversion-surface pin forbids an escaped pipe, the one pair GFM renderers read two ways, instead of any backslash. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2gP8YEs7CoWrbVsSAi --- .../upgrade-guide-surface-span.test.ts | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/spec/scripts/upgrade-guide-surface-span.test.ts b/packages/spec/scripts/upgrade-guide-surface-span.test.ts index 95356b959ba..da89cc6aa0b 100644 --- a/packages/spec/scripts/upgrade-guide-surface-span.test.ts +++ b/packages/spec/scripts/upgrade-guide-surface-span.test.ts @@ -131,7 +131,7 @@ describe('readCodeSpan — CommonMark’s reading of a span', () => { // it, or opened a double-backtick span that never closes. (A run of two or // more inside a single-backtick span is literal and would not break it.) const marked = semanticEntries().filter(({ entry }) => /(?:^|[^`])`(?:[^`]|$)/.test(entry.surface)); - expect(marked.length).toBeGreaterThan(100); + expect(marked.length).toBeGreaterThan(0); for (const { entry } of marked) { const old = `\`${entry.surface}\``; expect(readCodeSpan(old, 0)?.content, entry.id).not.toBe(entry.surface); @@ -142,22 +142,23 @@ describe('readCodeSpan — CommonMark’s reading of a span', () => { describe('the real entry set — every surface the registries carry renders as one unbroken span', () => { it('every semantic surface round-trips, whatever backticks it carries', () => { const entries = semanticEntries(); - expect(entries.length).toBeGreaterThan(400); + expect(entries.length).toBeGreaterThan(0); for (const { entry } of entries) { const span = codeSpan(entry.surface); expect(readCodeSpan(span, 0)?.content, entry.id).toBe(entry.surface); } }); - it('every conversion surface round-trips, whatever backticks it carries, and carries no backslash', () => { - expect(ALL_CONVERSIONS.length).toBeGreaterThan(50); + it('every conversion surface round-trips, whatever backticks it carries, and never escapes a pipe itself', () => { + expect(ALL_CONVERSIONS.length).toBeGreaterThan(0); for (const c of ALL_CONVERSIONS) { const span = codeSpan(c.surface); expect(readCodeSpan(span, 0)?.content, c.id).toBe(c.surface); - // A backslash before a pipe is where GFM renderers part ways (one reads - // `\\|` as a literal backslash and a cell boundary, another as a backslash - // and an escaped pipe), so a conversion surface carries none — loudly. - expect(c.surface, c.id).not.toContain('\\'); + // A backslash right before a pipe is where GFM renderers part ways once + // the row escapes it again (`\\|` is a literal backslash and a cell + // boundary to one, a backslash and an escaped pipe to another), so a + // conversion surface never carries that pair — loudly. + expect(c.surface, c.id).not.toMatch(/\\\|/); } }); }); @@ -207,7 +208,7 @@ describe('the generated guide — read back span by span, every entry’s surfac it('each semantic entry’s line carries its surface as one span, followed by the replacement arrow', () => { const entries = semanticEntries(); - expect(entries.length).toBeGreaterThan(400); + expect(entries.length).toBeGreaterThan(0); for (const { major, entry } of entries) { if (!rendered(major)) continue; const prefix = `- **\`${entry.id}\`** — `; @@ -233,10 +234,12 @@ describe('the generated guide — read back span by span, every entry’s surfac } }); - it('the lit control: the old single-backtick row of a conversion whose surface carries a pipe splits into extra cells', () => { - const piped = ALL_CONVERSIONS.filter((c) => c.surface.includes('|')); - expect(piped.length).toBeGreaterThan(0); - for (const c of piped) { + it('the lit control: the old single-backtick row of a surface that carries a pipe splits into extra cells', () => { + // A literal row first, so the control outlives the two pipe-carrying + // conversions in today's registry (their retirement kits age out one + // major on, ADR-0087); then every real one the registry carries. + expect(gfmCells("| `a` | `x ('m' | 's')` | s | live |")).toHaveLength(5); + for (const c of ALL_CONVERSIONS.filter((c) => c.surface.includes('|'))) { const old = `| \`${c.id}\` | \`${c.surface}\` | ${c.summary} | live |`; expect(gfmCells(old).length, c.id).toBeGreaterThan(4); }