Skip to content

Commit 3702be4

Browse files
fix(spec): the generated upgrade guide keeps a surface in one code span when it carries its own backticks (#27724)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiEosMC3zDfHaauURHFNq1GJ)_ Fixes #27532 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:协议升级指南生成器把每个条目的 `surface` 按 CommonMark 规则包进一个代码段(围栏比内部最长反引号串多一个),转换表单元格另外转义 `|`;两处渲染点共用一个入口,516 条条目一条未手改。 - **为什么改**:旧写法用单反引号包 `surface`,而 183 条语义条目在 `surface` 里自带反引号,渲染出来是代码与正文交替的碎片;两条带 `|` 的转换条目会把表格行撕成 6 格。 - **风险与回滚**:只动生成脚本与生成产物(随包发布的 `protocol-upgrade-guide.md` 与文档站页面),不动 schema、取值、错误码;不含反引号的 `surface` 字节不变。回滚 = revert 本 PR。 - **先看**:`packages/spec/scripts/lib/markdown-code-span.ts`(围栏与补空格规则)→ `packages/spec/scripts/build-upgrade-guide.ts` 的 `tableRow` / `surfaceSpan` 两处调用 → `packages/spec/scripts/upgrade-guide-surface-span.test.ts` 的真实条目集钉子。 - **两分钟验证**:`pnpm --filter @objectstack/spec exec vitest run scripts/upgrade-guide-surface-span.test.ts`,预期 15 个测试全绿;把任一渲染点改回单反引号再跑,预期变红。 - **需要你做的**:无,席位落地(等契约复审)。 Fable review: not run — the account's Fable quota is exhausted; the PR waits for the contract review (maintainer, 2026-10-29). ## What changes - `packages/spec/scripts/lib/markdown-code-span.ts` (new): `codeSpan(text)` fences a one-line text with a backtick string one longer than the longest backtick run inside it, and pads one space on each side when the text starts or ends with a backtick or a space. That is CommonMark §6.1's own rule for code that contains backticks. A text with no backtick and no edge space comes out byte-identical to the old single-backtick span. A blank or multi-line text is refused, and the refusal names the entry. `readCodeSpan` is the reading half of the same rule; only the pin uses it. - `packages/spec/scripts/build-upgrade-guide.ts`: both render sites (the conversion table row and the semantic list item) go through one `surfaceSpan(id, surface)` door. The conversion row is built by `tableRow`, which escapes `|` in every cell. GFM ends a cell at an unescaped pipe even inside a code span, and two live conversion surfaces carry one (`navigation-mode-overlay-values-removed`, `form-view-presentation-types-removed`). - **Why a longer fence and not prose:** a surface is a key path or an expression, and it reads as code. Rendering it as prose would make the guide's two forms of one surface differ from what `migrate meta` prints. A longer fence keeps one code span per surface and shows the author's backticks literally. No entry is edited. - `18.visibility-predicate-has-argument-refused.ts`: the workaround note ("no backticks in `surface`") is dropped. Its surface is untouched. - `export-job-family-retirement.test.ts`, `package-upgrade-family-retirement.test.ts`: each drops one `expect(entry!.surface).not.toMatch(/BACKTICK/)` line. Those lines forbade backticks in a surface only because of this defect. Every other assertion in both tests stands. - `.changeset/27532-upgrade-guide-surface-span.md`: `patch` on `@objectstack/spec`. `protocol-upgrade-guide.md` is in the package's `files[]` and is generated at publish, so the shipped bytes move. - **Committed outputs regenerated: none exist.** `packages/spec/protocol-upgrade-guide.md` and `content/docs/protocol-upgrade/` are both gitignored and generated at publish or docs build. `docs/protocol-upgrade-guide.md` is a pointer stub that nothing generates. `git ls-files` lists no rendered guide. ## Tests `packages/spec/scripts/upgrade-guide-surface-span.test.ts` (15 tests) has three layers: - **The helper:** fence length, padding, refusals, and a round trip through the reader. A lit control shows the OLD single-backtick wrapping of every real marked surface reads as broken, so the reader can see a break. - **The real entry set:** every semantic surface (516) and every conversion surface (109) round-trips through `codeSpan`/`readCodeSpan`. A new entry cannot bring the defect back, whatever backticks it carries. - **The generated guide:** the REAL generator runs (`tsx build-upgrade-guide.ts --out`). Its bytes are read back span by span: each semantic line yields its surface verbatim followed by ` → `. Each conversion row is exactly four GFM cells, and the surface is one span filling its cell. A second lit control: the old row shape of a pipe-carrying surface splits into more than four cells, on a literal row and on every real pipe-carrying conversion. Ablation on head `34de285ecf`, each leg through `scripts/ablation-replace.mjs`, with anchor hit, blob change and restore all verified on disk. There is no `dist/` leg: the test imports `../src` and spawns the script source by relative path. - Semantic site reverted to `` `${s.surface}` ``: 1 failed / 14 passed (`action-descriptor-is-async-retired` first). Restored, blob == HEAD. - Conversion site reverted to main's row: 1 failed / 14 passed (`navigation-mode-overlay-values-removed`: 6 cells, not 4). Restored, blob == HEAD. - Helper fence forced to one backtick: 5 failed / 10 passed. Restored, blob == HEAD. ## Acceptance notes - Measured size of the defect: 183 of the 516 semantic surfaces carry a backtick, and all 183 broke under the old wrapping (markdown-it, rendered in memory). The card's "most" overstates it, but the fix and the pin are the same either way. - Two independent renderers were checked against the fixed guide, beyond the pin's own CommonMark reader. remark with GFM (micromark, the docs site's parser) gave 516/516 semantic spans and 104/104 rendered conversion rows verbatim. markdown-it gave 516/516 semantic spans. An escaped pipe inside a table-cell code span renders as `|` in remark, markdown-it and marked alike. - `summary` and the load-window cell are now pipe-escaped too, because `tableRow` escapes every cell. No current summary carries a pipe, so no byte moves there today. ## Verification All of it was run on `34de285ecf`: the branch merged with `origin/main` `a75ddba178`, with a clean merge and no generated-file conflict. The branch delta vs main is unchanged at 7 files, +356 / −8. <details><summary>spec tests and typecheck</summary> - `pnpm --filter @objectstack/spec test`: Test Files 754 passed (754), Tests 22406 passed, 1 todo; exit 0. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `tsconfig.scripts.json --listFiles` compiles all three touched script files. - Targeted run (pin, docs-page tests and the two edited sibling tests): Test Files 4 passed, Tests 47 passed. </details> <details><summary>derived gates (dispatch-gates --commands, 86 commands)</summary> - 85 exit 0. That includes `check:upgrade-guide`, `check:adr-0087-registration --base origin/main`, `check:changeset-no-major`, `check:empty-changeset`, `check:spec-changes`, `check:docs`, `check:api-surface`, `check:nul-bytes`, `check:cross-package-test-inputs` and `check:pm-dispatch-gates`. - `check:doc-formula-expressions` and `check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET, no `dist/`). Both exit 0 after building their closures. - `check:dual-build-cjs-loads`: NOT MEASURED. It exits 3 because about 93 packages have no `dist/` in this worktree. The diff moves no package's built output; CI's Lint & Repo Gates runs it after the build. - `--ran` reconciliation: 86 derived, 85 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 UNRUN. </details> --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 481ade6 commit 3702be4

7 files changed

Lines changed: 356 additions & 8 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the generated protocol upgrade guide keeps an entry's `surface` in one code span when the surface carries backticks of its own
6+
7+
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)
8+
Metadata-protocol: none
9+
10+
`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.

‎packages/spec/scripts/build-upgrade-guide.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry';
4343
import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '../src/kernel/protocol-version';
4444
import { MIGRATIONS_BY_MAJOR, MIGRATION_SUPPORT_FLOOR } from '../src/migrations/registry';
4545
import { exitWith, runProjectionCli, type ProjectionCliResult } from './lib/projection-cli';
46+
import { codeSpan } from './lib/markdown-code-span';
4647
import { buildDocsPages, DOCS_DIR, writeDocsPages, type HopInput } from './lib/upgrade-guide-docs';
4748

4849
const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
@@ -97,6 +98,23 @@ function howToUpgrade(form: 'file' | 'page'): string[] {
9798
return lines;
9899
}
99100

101+
/**
102+
* A GFM table row. The pipe is the one character a row reads as structure, in
103+
* every inline span of the cell included, so each cell escapes it.
104+
*/
105+
function tableRow(cells: readonly string[]): string {
106+
return `| ${cells.map((cell) => cell.replace(/\|/g, '\\|')).join(' | ')} |`;
107+
}
108+
109+
/**
110+
* An entry's `surface` as one code span, however the author wrote it: both
111+
* render sites go through this one door (`lib/markdown-code-span.ts` says why
112+
* the fence is longer than a backtick), and a refusal names the entry.
113+
*/
114+
function surfaceSpan(id: string, surface: string): string {
115+
return codeSpan(surface, `entry ${id}`);
116+
}
117+
100118
/** One hop's own section: `## Protocol N-1 → N` and everything under it. */
101119
function hopSection(major: number): string[] {
102120
const lines: string[] = [];
@@ -123,7 +141,7 @@ function hopSection(major: number): string[] {
123141
const window = c.retiredFromLoadPath
124142
? 'retired — `migrate meta` only'
125143
: `live — protocol ${c.toMajor} loader accepts the old shape`;
126-
out(`| \`${c.id}\` | \`${c.surface}\` | ${c.summary} | ${window} |`);
144+
out(tableRow([`\`${c.id}\``, surfaceSpan(c.id, c.surface), c.summary, window]));
127145
}
128146
out();
129147
}
@@ -132,7 +150,7 @@ function hopSection(major: number): string[] {
132150
out('### Semantic (delegated to you, with acceptance criteria)');
133151
out();
134152
for (const s of semantic) {
135-
out(`- **\`${s.id}\`** — \`${s.surface}\` → ${s.replacement}`);
153+
out(`- **\`${s.id}\`** — ${surfaceSpan(s.id, s.surface)} → ${s.replacement}`);
136154
out(` - Why not automatic: ${s.reason}`);
137155
out(` - Done when: ${s.acceptanceCriteria}`);
138156
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A Markdown code span that holds ANY one-line text, backticks included.
5+
*
6+
* CommonMark §6.1: a code span opens with a backtick string and closes at the
7+
* next backtick string of the SAME length. So a single-backtick span closes at
8+
* the first backtick inside it — which is what broke the protocol upgrade
9+
* guide: a `surface` the entry author wrote with its own inline code
10+
* (`` `globalFilters` on a view ``) was wrapped in one more single-backtick
11+
* span and rendered as alternating code and prose fragments. The fix is the
12+
* rule the spec gives for exactly this: fence the text with a backtick string
13+
* one longer than the longest run inside it, and pad with one space when the
14+
* text starts or ends with a backtick or a space (the renderer strips one
15+
* space from each end when both are present, so the pad is what it strips).
16+
*
17+
* `readCodeSpan` is the READING half of the same rule, kept here so the pin
18+
* over the real registries can decode what the generator wrote without
19+
* repeating the generator's format: a span is unbroken when the reader gets
20+
* the author's text back.
21+
*/
22+
23+
/** Longest run of backticks in `text` (0 when it has none). */
24+
function longestBacktickRun(text: string): number {
25+
let longest = 0;
26+
for (const run of text.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
27+
return longest;
28+
}
29+
30+
/**
31+
* `text` as one code span, whatever backticks it carries. Byte-identical to
32+
* `` `${text}` `` for a text with no backtick and no leading or trailing space,
33+
* so the common case does not move. Refuses a blank or multi-line text, naming
34+
* `subject` (the entry, for the generator) in the refusal: a code span cannot
35+
* say nothing, and a line ending renders as a space inside the span and, in a
36+
* table row, ends the row.
37+
*/
38+
export function codeSpan(text: string, subject = 'codeSpan'): string {
39+
if (text.trim() === '') throw new Error(`${subject}: a code span cannot hold a blank text.`);
40+
if (/[\r\n]/.test(text)) throw new Error(`${subject}: a code span holds one line; got ${JSON.stringify(text)}.`);
41+
const fence = '`'.repeat(longestBacktickRun(text) + 1);
42+
const pad = /^[` ]|[` ]$/.test(text) ? ' ' : '';
43+
return `${fence}${pad}${text}${pad}${fence}`;
44+
}
45+
46+
/** A backtick string: one or more backticks neither preceded nor followed by a backtick. */
47+
function backtickStringLengthAt(markdown: string, at: number): number {
48+
if (markdown[at] !== '`' || (at > 0 && markdown[at - 1] === '`')) return 0;
49+
let end = at;
50+
while (markdown[end] === '`') end++;
51+
return end - at;
52+
}
53+
54+
/**
55+
* The code span that opens at `at` in `markdown`, read by CommonMark's rule:
56+
* its content (line endings as spaces; one space stripped from each end when
57+
* both ends are spaces and the content is not spaces only), and the index just
58+
* past its closing backtick string. `null` when no backtick string of the
59+
* opening length follows — the opener is then literal backticks, not a span.
60+
*/
61+
export function readCodeSpan(markdown: string, at: number): { content: string; end: number } | null {
62+
const open = backtickStringLengthAt(markdown, at);
63+
if (open === 0) return null;
64+
let i = at + open;
65+
while (i < markdown.length) {
66+
if (markdown[i] !== '`') {
67+
i++;
68+
continue;
69+
}
70+
const run = backtickStringLengthAt(markdown, i);
71+
if (run === open) {
72+
let content = markdown.slice(at + open, i).replace(/\r\n?|\n/g, ' ');
73+
if (content.startsWith(' ') && content.endsWith(' ') && !/^ +$/.test(content)) content = content.slice(1, -1);
74+
return { content, end: i + run };
75+
}
76+
i += run;
77+
}
78+
return null;
79+
}
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The protocol upgrade guide renders every entry's `surface` inside a code
4+
// span, and a surface may carry backticks of its own: most semantic entries
5+
// mark their inline code in it. CommonMark closes a span at the next backtick
6+
// string of the opening length, so the span must be fenced longer than any run
7+
// inside it — or the line renders as alternating code and prose fragments. A
8+
// conversion's surface sits in a GFM table cell as well, where an unescaped
9+
// pipe ends the cell, inside a code span or not. Three pins, kept apart:
10+
//
11+
// - the helper: `codeSpan` fences by that rule and leaves a text with no
12+
// backtick and no leading or trailing space byte-identical; `readCodeSpan`
13+
// reads a span the way a renderer does, and reads the OLD single-backtick
14+
// wrapping of a marked surface as broken (the lit control — a reader that
15+
// cannot see a break would prove nothing);
16+
// - the real entry set: every conversion and semantic surface the registries
17+
// carry round-trips through the pair, so a new entry cannot bring the defect
18+
// back whatever backticks it carries;
19+
// - the generated guide: the REAL generator's bytes, read back span by span and
20+
// cell by cell, give every entry's surface verbatim — pinned on what ships,
21+
// not on the helper.
22+
23+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
24+
import { spawnSync } from 'node:child_process';
25+
import fs from 'node:fs';
26+
import os from 'node:os';
27+
import path from 'node:path';
28+
import { fileURLToPath } from 'node:url';
29+
30+
import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from '../src/conversions/registry';
31+
import { MIGRATION_SUPPORT_FLOOR, MIGRATIONS_BY_MAJOR } from '../src/migrations/registry';
32+
import type { SemanticMigration } from '../src/migrations/types';
33+
import { codeSpan, readCodeSpan } from './lib/markdown-code-span';
34+
35+
const HERE = path.dirname(fileURLToPath(import.meta.url));
36+
const PKG = path.resolve(HERE, '..');
37+
const TSX = path.join(PKG, 'node_modules', '.bin', 'tsx');
38+
const SPAWN_TIMEOUT_MS = 120_000;
39+
40+
/** Every semantic entry with the major its hop arrives at. */
41+
function semanticEntries(): { major: number; entry: SemanticMigration }[] {
42+
const out: { major: number; entry: SemanticMigration }[] = [];
43+
for (const [major, step] of Object.entries(MIGRATIONS_BY_MAJOR)) {
44+
for (const entry of step.semantic) out.push({ major: Number(major), entry });
45+
}
46+
return out;
47+
}
48+
49+
/**
50+
* A GFM table row's cells, read the way the spec reads them: every `|` ends a
51+
* cell, inside a code span or not, except one escaped as `\|` (GFM §4.10,
52+
* example 200). The pieces outside the row's leading and trailing pipe are
53+
* dropped; each cell is trimmed.
54+
*/
55+
function gfmCells(row: string): string[] {
56+
const cells: string[] = [];
57+
let cell = '';
58+
for (let i = 0; i < row.length; i++) {
59+
if (row[i] === '\\' && row[i + 1] === '|') {
60+
cell += '\\|';
61+
i++;
62+
} else if (row[i] === '|') {
63+
cells.push(cell);
64+
cell = '';
65+
} else {
66+
cell += row[i];
67+
}
68+
}
69+
cells.push(cell);
70+
return cells.slice(1, -1).map((c) => c.trim());
71+
}
72+
73+
describe('codeSpan — one code span for any one-line text', () => {
74+
it('a text with no backtick and no leading or trailing space is wrapped in single backticks, byte for byte as before', () => {
75+
expect(codeSpan('flow.node.type')).toBe('`flow.node.type`');
76+
expect(codeSpan('data.query.aggregations[].distinct')).toBe('`data.query.aggregations[].distinct`');
77+
});
78+
79+
it('a text with its own backticks is fenced one backtick longer than its longest run', () => {
80+
expect(codeSpan('x `a` y')).toBe('``x `a` y``');
81+
expect(codeSpan('a ``b`` c')).toBe('```a ``b`` c```');
82+
});
83+
84+
it('a text that starts or ends with a backtick or a space is padded, which the reader strips again', () => {
85+
expect(codeSpan('`a` on b')).toBe('`` `a` on b ``');
86+
expect(codeSpan('on `b`')).toBe('`` on `b` ``');
87+
expect(codeSpan(' lead')).toBe('` lead `');
88+
});
89+
90+
it('a blank or multi-line text is refused, naming the subject, never rendered as a span that says nothing or breaks its row', () => {
91+
expect(() => codeSpan('')).toThrow(/^codeSpan: .*blank/);
92+
expect(() => codeSpan(' ')).toThrow(/blank/);
93+
expect(() => codeSpan('a\nb')).toThrow(/one line/);
94+
expect(() => codeSpan('', 'entry some-id')).toThrow(/^entry some-id: /);
95+
});
96+
97+
it('round-trips through the reader', () => {
98+
for (const text of ['flow.node.type', 'x `a` y', '`a` on b', 'on `b`', 'a ``b`` c', ' lead', 'trail ', '`', '``']) {
99+
const span = codeSpan(text);
100+
expect(readCodeSpan(span, 0), span).toEqual({ content: text, end: span.length });
101+
}
102+
});
103+
});
104+
105+
describe('readCodeSpan — CommonMark’s reading of a span', () => {
106+
it('closes at the next backtick string of the opening length, and strips one space from each end', () => {
107+
expect(readCodeSpan('`a` b', 0)).toEqual({ content: 'a', end: 3 });
108+
expect(readCodeSpan('`` `a` b `` c', 0)).toEqual({ content: '`a` b', end: 11 });
109+
expect(readCodeSpan('x `a`', 2)).toEqual({ content: 'a', end: 5 });
110+
});
111+
112+
it('a line ending inside the span reads as a space; a span of spaces only keeps them', () => {
113+
expect(readCodeSpan('`a\nb`', 0)).toEqual({ content: 'a b', end: 5 });
114+
expect(readCodeSpan('`a\r\nb`', 0)).toEqual({ content: 'a b', end: 6 });
115+
expect(readCodeSpan('` `', 0)).toEqual({ content: ' ', end: 4 });
116+
expect(readCodeSpan('` \t `', 0)).toEqual({ content: '\t', end: 5 });
117+
});
118+
119+
it('an opener with no closer of its length is not a span', () => {
120+
expect(readCodeSpan('``a` b', 0)).toBeNull();
121+
expect(readCodeSpan('`a', 0)).toBeNull();
122+
});
123+
124+
it('a position that is not the start of a backtick string is not a span', () => {
125+
expect(readCodeSpan('a `b`', 0)).toBeNull();
126+
expect(readCodeSpan('``a``', 1)).toBeNull();
127+
});
128+
129+
it('the lit control: the old single-backtick wrapping of a real marked surface reads as broken', () => {
130+
// A surface with a single-backtick string inside: the old wrapper closed at
131+
// it, or opened a double-backtick span that never closes. (A run of two or
132+
// more inside a single-backtick span is literal and would not break it.)
133+
const marked = semanticEntries().filter(({ entry }) => /(?:^|[^`])`(?:[^`]|$)/.test(entry.surface));
134+
expect(marked.length).toBeGreaterThan(0);
135+
for (const { entry } of marked) {
136+
const old = `\`${entry.surface}\``;
137+
expect(readCodeSpan(old, 0)?.content, entry.id).not.toBe(entry.surface);
138+
}
139+
});
140+
});
141+
142+
describe('the real entry set — every surface the registries carry renders as one unbroken span', () => {
143+
it('every semantic surface round-trips, whatever backticks it carries', () => {
144+
const entries = semanticEntries();
145+
expect(entries.length).toBeGreaterThan(0);
146+
for (const { entry } of entries) {
147+
const span = codeSpan(entry.surface);
148+
expect(readCodeSpan(span, 0)?.content, entry.id).toBe(entry.surface);
149+
}
150+
});
151+
152+
it('every conversion surface round-trips, whatever backticks it carries, and never escapes a pipe itself', () => {
153+
expect(ALL_CONVERSIONS.length).toBeGreaterThan(0);
154+
for (const c of ALL_CONVERSIONS) {
155+
const span = codeSpan(c.surface);
156+
expect(readCodeSpan(span, 0)?.content, c.id).toBe(c.surface);
157+
// A backslash right before a pipe is where GFM renderers part ways once
158+
// the row escapes it again (`\\|` is a literal backslash and a cell
159+
// boundary to one, a backslash and an escaped pipe to another), so a
160+
// conversion surface never carries that pair — loudly.
161+
expect(c.surface, c.id).not.toMatch(/\\\|/);
162+
}
163+
});
164+
});
165+
166+
describe('the generated guide — read back span by span, every entry’s surface is verbatim', () => {
167+
let tmp: string;
168+
let sections: Map<number, string[]>;
169+
170+
beforeAll(() => {
171+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'upgrade-guide-surface-'));
172+
const file = path.join(tmp, 'guide.md');
173+
const r = spawnSync(TSX, [path.join(HERE, 'build-upgrade-guide.ts'), '--out', file], {
174+
cwd: PKG,
175+
encoding: 'utf8',
176+
timeout: SPAWN_TIMEOUT_MS,
177+
stdio: ['ignore', 'pipe', 'pipe'],
178+
});
179+
expect(r.status, `${r.stdout ?? ''}${r.stderr ?? ''}`).toBe(0);
180+
const text = fs.readFileSync(file, 'utf8');
181+
sections = new Map();
182+
for (const part of text.split(/^(?=## Protocol \d+ → \d+$)/m).slice(1)) {
183+
sections.set(Number(/→ (\d+)/.exec(part)![1]), part.split('\n'));
184+
}
185+
});
186+
187+
afterAll(() => {
188+
fs.rmSync(tmp, { recursive: true, force: true });
189+
});
190+
191+
/**
192+
* Whether the guide renders a hop for `major`: it starts one above the chain's
193+
* support floor, so a registry major at or below the floor has no section,
194+
* and one above it must.
195+
*/
196+
function rendered(major: number): boolean {
197+
const has = sections.has(major);
198+
if (!has) expect(major, `protocol ${major} has no section`).toBeLessThanOrEqual(MIGRATION_SUPPORT_FLOOR);
199+
return has;
200+
}
201+
202+
/** The one line of a rendered `major`'s section that starts with `prefix`. */
203+
function lineOf(major: number, prefix: string): string {
204+
const hits = sections.get(major)!.filter((line) => line.startsWith(prefix));
205+
expect(hits, prefix).toHaveLength(1);
206+
return hits[0]!;
207+
}
208+
209+
it('each semantic entry’s line carries its surface as one span, followed by the replacement arrow', () => {
210+
const entries = semanticEntries();
211+
expect(entries.length).toBeGreaterThan(0);
212+
for (const { major, entry } of entries) {
213+
if (!rendered(major)) continue;
214+
const prefix = `- **\`${entry.id}\`** — `;
215+
const line = lineOf(major, prefix);
216+
const span = readCodeSpan(line, prefix.length);
217+
expect(span?.content, entry.id).toBe(entry.surface);
218+
expect(line.slice(span!.end).startsWith(' → '), entry.id).toBe(true);
219+
}
220+
});
221+
222+
it('each conversion’s table row is four cells, the surface one span filling its own cell', () => {
223+
for (const [key, list] of Object.entries(CONVERSIONS_BY_MAJOR)) {
224+
const major = Number(key);
225+
if (!rendered(major)) continue;
226+
for (const c of list) {
227+
const line = lineOf(major, `| \`${c.id}\` | `);
228+
const cells = gfmCells(line);
229+
expect(cells, c.id).toHaveLength(4);
230+
const span = readCodeSpan(cells[1]!, 0);
231+
expect(span?.end, c.id).toBe(cells[1]!.length);
232+
expect(span?.content.replace(/\\\|/g, '|'), c.id).toBe(c.surface);
233+
}
234+
}
235+
});
236+
237+
it('the lit control: the old single-backtick row of a surface that carries a pipe splits into extra cells', () => {
238+
// A literal row first, so the control outlives the two pipe-carrying
239+
// conversions in today's registry (their retirement kits age out one
240+
// major on, ADR-0087); then every real one the registry carries.
241+
expect(gfmCells("| `a` | `x ('m' | 's')` | s | live |")).toHaveLength(5);
242+
for (const c of ALL_CONVERSIONS.filter((c) => c.surface.includes('|'))) {
243+
const old = `| \`${c.id}\` | \`${c.surface}\` | ${c.summary} | live |`;
244+
expect(gfmCells(old).length, c.id).toBeGreaterThan(4);
245+
}
246+
});
247+
});

‎packages/spec/src/api/export-job-family-retirement.test.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,6 @@ describe('ADR-0087 registration', () => {
211211
expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0);
212212
// The route is stated where the next reader looks: why D3 semantic and not D2.
213213
expect(entry!.reason).toMatch(/not a D2 conversion/);
214-
// No backticks in `surface`: the upgrade guide renders it inside a code span AND a table cell.
215-
expect(entry!.surface).not.toMatch(/`/);
216214
// The surface names every def and the three retired surfaces beside them.
217215
for (const def of RETIRED_DEFS) expect(entry!.surface, `surface must name ${def}`).toContain(def);
218216
for (const name of ['ExportApiContracts', 'IExportService', 'ScheduleExportInput', 'ScheduleStateSchema']) {

0 commit comments

Comments
 (0)