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
10 changes: 10 additions & 0 deletions .changeset/27532-upgrade-guide-surface-span.md
Original file line number Diff line number Diff line change
@@ -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 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.
22 changes: 20 additions & 2 deletions packages/spec/scripts/build-upgrade-guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)), '..');
Expand Down Expand Up @@ -97,6 +98,23 @@ 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(' | ')} |`;
}

/**
* 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(id: string, surface: string): string {
return codeSpan(surface, `entry ${id}`);
}

/** One hop's own section: `## Protocol N-1 → N` and everything under it. */
function hopSection(major: number): string[] {
const lines: string[] = [];
Expand All @@ -123,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(`| \`${c.id}\` | \`${c.surface}\` | ${c.summary} | ${window} |`);
out(tableRow([`\`${c.id}\``, surfaceSpan(c.id, c.surface), c.summary, window]));
}
out();
}
Expand All @@ -132,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}\`** — \`${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}`);
}
Expand Down
79 changes: 79 additions & 0 deletions packages/spec/scripts/lib/markdown-code-span.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// 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 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, 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}`;
}

/** 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 (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);
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?|\n/g, ' ');
if (content.startsWith(' ') && content.endsWith(' ') && !/^ +$/.test(content)) content = content.slice(1, -1);
return { content, end: i + run };
}
i += run;
}
return null;
}
247 changes: 247 additions & 0 deletions packages/spec/scripts/upgrade-guide-surface-span.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
// 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. 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 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 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';
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;
}

/**
* 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 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`');
});

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, 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', () => {
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('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();
});

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(0);
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(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 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 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(/\\\|/);
}
});
});

describe('the generated guide — read back span by span, every entry’s surface is verbatim', () => {
let tmp: string;
let sections: Map<number, string[]>;

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 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));
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(0);
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 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 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);
}
}
});

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);
}
});
});
2 changes: 0 additions & 2 deletions packages/spec/src/api/export-job-family-retirement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']) {
Expand Down
Loading
Loading