Skip to content

Commit 278751d

Browse files
committed
docs(spec): the protocol upgrade guide's address is one docs-site page per major; docs/protocol-upgrade-guide.md becomes a pointer stub
Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0e756a1 commit 278751d

9 files changed

Lines changed: 308 additions & 1584 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): the protocol upgrade guide has a public address, one docs-site page per protocol major
6+
7+
Clause-②: no
8+
9+
The guide is published on the docs site, one page per protocol major, at `https://objectstack.ai/docs/protocol-upgrade/N`; `https://objectstack.ai/docs/protocol-upgrade` lists them all. A link to one major keeps resolving after the next major opens. The site builds from `main`, so each page says whether its major is released, in prerelease, or not released yet.
10+
11+
`docs/protocol-upgrade-guide.md` in the repository, which earlier READMEs and changelog entries point at, is now a short pointer naming that address.
12+
13+
What a release shipped is unchanged: `protocol-upgrade-guide.md` inside the package and attached to its GitHub Release. Its only change is the header comment, which now names the command that writes the file.
14+
15+
Nothing changes for an author, and nothing is renamed or removed.

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ next-env.d.ts
103103
.source/
104104
apps/*/. source/
105105
apps/*/.next/docs/
106+
# The protocol upgrade guide's docs pages: generated at docs build by
107+
# `pnpm --filter @objectstack/spec gen:upgrade-guide`, never committed.
108+
content/docs/protocol-upgrade/
106109
.turbo
107110

108111
# Local env overrides (may contain secrets)

‎apps/docs/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"license": "Apache-2.0",
77
"scripts": {
88
"dev": "next dev",
9-
"build": "pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs && NODE_OPTIONS='--max-old-space-size=4096' next build",
9+
"build": "pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs && pnpm --filter @objectstack/spec gen:upgrade-guide && NODE_OPTIONS='--max-old-space-size=4096' next build",
1010
"start": "next start",
1111
"site:lint": "next lint",
1212
"postinstall": "fumadocs-mdx",

‎content/docs/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"ai",
1515
"api",
1616
"upgrading",
17+
"protocol-upgrade",
1718
"---Platform---",
1819
"deployment",
1920
"plugins",

‎docs/protocol-upgrade-guide.md‎

Lines changed: 8 additions & 1571 deletions
Large diffs are not rendered by default.

‎packages/spec/scripts/check-generated.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,14 @@ const GATED: ReadonlyArray<{
8787
artifact: 'src/migrations/registry.ts — its generated regions, from src/migrations/entries/',
8888
},
8989
{ check: 'check:spec-changes', gen: 'gen:spec-changes', artifact: 'spec-changes.json' },
90-
{ check: 'check:upgrade-guide', gen: 'gen:upgrade-guide', artifact: 'docs/protocol-upgrade-guide.md' },
90+
// Its committed copy is gone: `docs/protocol-upgrade-guide.md` is a hand-written
91+
// pointer stub, and `gen:upgrade-guide` writes the docs pages that are the
92+
// guide's address. The gate generates in memory, so it never reports stale.
93+
{
94+
check: 'check:upgrade-guide',
95+
gen: 'gen:upgrade-guide',
96+
artifact: 'content/docs/protocol-upgrade/ (the docs pages; gitignored, generated at docs build)',
97+
},
9198
// [#10096] The schema-free `/meta` URL-spelling data module. Cheap: tsx-loads
9299
// the two source maps (lazySchema keeps the kernel module light), re-derives
93100
// the three-limb union, and runs the manifest/derived agreement assertion

‎packages/spec/scripts/projection-cli.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,24 @@ describe('runProjectionCli — the two projections’ command-line contract', ()
126126
expect(fs.readFileSync(path.join(tmp, 'committed', 'fixture.json'), 'utf8')).toBe('{"generated":true}\n');
127127
});
128128

129+
it('with no flag and no committed copy it refuses as a usage error, builds nothing and writes nothing', () => {
130+
let builds = 0;
131+
const result = runProjectionCli({
132+
name: 'fixture.json',
133+
build: () => {
134+
builds += 1;
135+
return '{}\n';
136+
},
137+
argv: [],
138+
});
139+
140+
expect(result.code).toBe(2);
141+
expect(result.stderr.join('\n')).toContain('fixture.json has no committed copy');
142+
expect(result.wrote).toBeNull();
143+
expect(builds).toBe(0);
144+
expect(fs.readdirSync(tmp)).toEqual([]);
145+
});
146+
129147
it('--check with --out, and --out with no path, are usage errors that build nothing', () => {
130148
let builds = 0;
131149
const build = () => {
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Pins the protocol upgrade guide's public address (#22449 B′, condition 2):
4+
// one docs-site page per protocol major plus an index, generated at docs build
5+
// by `build-upgrade-guide.ts` (no flag, or `--docs <dir>`) and never committed.
6+
//
7+
// - each page says which state its major is in, judged from the tree's own
8+
// `@objectstack/spec` version, because the site builds from `main` and
9+
// `main` can carry a major no release ships yet;
10+
// - a page's hop section is the single-file guide's section, byte for byte —
11+
// one renderer, two layouts, so the address can never say something the
12+
// shipped file does not;
13+
// - the generator replaces only files it wrote, and refuses a directory that
14+
// holds anything else.
15+
//
16+
// The first block drives `lib/upgrade-guide-docs.ts` with fixture sections; the
17+
// second spawns the REAL generator into a temporary directory.
18+
19+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
20+
import { spawnSync } from 'node:child_process';
21+
import fs from 'node:fs';
22+
import os from 'node:os';
23+
import path from 'node:path';
24+
import { fileURLToPath } from 'node:url';
25+
26+
import {
27+
buildDocsPages,
28+
DOCS_DIR,
29+
DOCS_ROUTE,
30+
majorRoute,
31+
releaseStatus,
32+
writeDocsPages,
33+
type DocsPagesInput,
34+
} from './lib/upgrade-guide-docs';
35+
36+
const HERE = path.dirname(fileURLToPath(import.meta.url));
37+
const PKG = path.resolve(HERE, '..');
38+
const TSX = path.join(PKG, 'node_modules', '.bin', 'tsx');
39+
const SPAWN_TIMEOUT_MS = 120_000;
40+
41+
let tmp: string;
42+
beforeEach(() => {
43+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'upgrade-guide-docs-'));
44+
});
45+
afterEach(() => {
46+
fs.rmSync(tmp, { recursive: true, force: true });
47+
});
48+
49+
/** The `---` block's `key: "value"` lines, JSON-decoded; null when there is no leading block. */
50+
function frontmatterOf(text: string): Record<string, string> | null {
51+
const m = /^---\n([\s\S]*?)\n---\n/.exec(text);
52+
if (!m) return null;
53+
const out: Record<string, string> = {};
54+
for (const line of m[1]!.split('\n')) {
55+
const kv = /^(\w+): (.*)$/.exec(line);
56+
if (kv) out[kv[1]!] = JSON.parse(kv[2]!) as string;
57+
}
58+
return out;
59+
}
60+
61+
/** Lines outside fenced code blocks. */
62+
function proseLines(text: string): string[] {
63+
let fenced = false;
64+
return text.split('\n').filter((line) => {
65+
if (line.startsWith('```')) {
66+
fenced = !fenced;
67+
return false;
68+
}
69+
return !fenced;
70+
});
71+
}
72+
73+
function fixture(specVersion: string): DocsPagesInput {
74+
return {
75+
specVersion,
76+
howToUpgrade: ['## How to upgrade — from protocol 16 onward', '', 'run the chain', ''],
77+
hops: [
78+
{ major: 17, section: ['## Protocol 16 → 17', '', 'seventeen', ''] },
79+
{ major: 18, section: ['## Protocol 17 → 18', '', 'eighteen', ''] },
80+
],
81+
footer: ['---', '', '*equivalents*', ''],
82+
};
83+
}
84+
85+
describe('releaseStatus — which state a protocol major is in, from the tree’s own spec version', () => {
86+
it('a major above the package major is unreleased (the pre-mode pending major)', () => {
87+
expect(releaseStatus(18, '17.7.0')).toBe('unreleased');
88+
});
89+
90+
it('a major equal to a prerelease package major is in prerelease', () => {
91+
expect(releaseStatus(18, '18.0.0-next.3')).toBe('prerelease');
92+
});
93+
94+
it('a major at or below a released package major is released', () => {
95+
expect(releaseStatus(17, '17.7.0')).toBe('released');
96+
expect(releaseStatus(18, '18.0.0')).toBe('released');
97+
expect(releaseStatus(17, '18.0.0-next.0')).toBe('released');
98+
});
99+
100+
it('a version that is not MAJOR.MINOR.PATCH[-PRE] is refused, never guessed', () => {
101+
expect(() => releaseStatus(18, '18')).toThrow(/not MAJOR\.MINOR\.PATCH/);
102+
expect(() => releaseStatus(18, 'workspace:*')).toThrow(/not MAJOR\.MINOR\.PATCH/);
103+
});
104+
});
105+
106+
describe('buildDocsPages — the generated tree', () => {
107+
it('writes one page per major, an index and a meta.json listing them newest first', () => {
108+
const files = buildDocsPages(fixture('17.7.0'));
109+
110+
expect([...files.keys()].sort()).toEqual(['17.md', '18.md', 'index.md', 'meta.json']);
111+
expect(JSON.parse(files.get('meta.json')!)).toEqual({ title: 'Protocol Upgrade Guide', pages: ['index', '18', '17'] });
112+
});
113+
114+
it('every page carries a parseable title and description and no body h1', () => {
115+
const files = buildDocsPages(fixture('17.7.0'));
116+
117+
for (const name of ['index.md', '17.md', '18.md']) {
118+
const text = files.get(name)!;
119+
const fm = frontmatterOf(text);
120+
expect(fm, name).not.toBeNull();
121+
expect(fm!.title, name).toBeTruthy();
122+
expect(fm!.description, name).toBeTruthy();
123+
const body = text.slice(text.indexOf('\n---\n', 4) + 5);
124+
expect(proseLines(body).filter((line) => line.startsWith('# ')), name).toEqual([]);
125+
}
126+
expect(frontmatterOf(files.get('18.md')!)!.title).toBe('Protocol 17 → 18 upgrade guide');
127+
});
128+
129+
it('each page states its major’s state, and the index lists every page with its state', () => {
130+
const files = buildDocsPages(fixture('17.7.0'));
131+
132+
expect(files.get('18.md')).toContain('> **Not released yet.** Protocol 18 ships in no published');
133+
expect(files.get('17.md')).toContain('> **Released.** Protocol 17 ships in');
134+
const index = files.get('index.md')!;
135+
expect(index).toContain(`| 17 → 18 | [${majorRoute(18)}](${majorRoute(18)}) | unreleased |`);
136+
expect(index).toContain(`| 16 → 17 | [${majorRoute(17)}](${majorRoute(17)}) | released |`);
137+
138+
expect(buildDocsPages(fixture('18.0.0-next.0')).get('18.md')).toContain('> **Prerelease.** Protocol 18');
139+
});
140+
141+
it('a page is the shared sections around its own hop, and only its own', () => {
142+
const page = buildDocsPages(fixture('17.7.0')).get('18.md')!;
143+
144+
expect(page).toContain('## How to upgrade — from protocol 16 onward\n\nrun the chain\n');
145+
expect(page).toContain('## Protocol 17 → 18\n\neighteen\n');
146+
expect(page).not.toContain('seventeen');
147+
expect(page.endsWith('---\n\n*equivalents*\n')).toBe(true);
148+
});
149+
150+
it('serves the tree at /docs/protocol-upgrade, one route per major', () => {
151+
expect(DOCS_DIR).toBe('content/docs/protocol-upgrade');
152+
expect(DOCS_ROUTE).toBe('/docs/protocol-upgrade');
153+
expect(majorRoute(19)).toBe('/docs/protocol-upgrade/19');
154+
});
155+
});
156+
157+
describe('writeDocsPages — replaces only what it wrote', () => {
158+
it('drops a page whose major left the registries, and writes the rest', () => {
159+
const dir = path.join(tmp, 'protocol-upgrade');
160+
fs.mkdirSync(dir);
161+
fs.writeFileSync(path.join(dir, '16.md'), 'a hop below the support floor');
162+
163+
writeDocsPages(dir, buildDocsPages(fixture('17.7.0')));
164+
165+
expect(fs.readdirSync(dir).sort()).toEqual(['17.md', '18.md', 'index.md', 'meta.json']);
166+
});
167+
168+
it('refuses a directory holding a file it did not write, and leaves it untouched', () => {
169+
const dir = path.join(tmp, 'docs');
170+
fs.mkdirSync(dir);
171+
fs.writeFileSync(path.join(dir, 'upgrading.mdx'), 'hand-written');
172+
fs.writeFileSync(path.join(dir, '17.md'), 'old');
173+
174+
expect(() => writeDocsPages(dir, buildDocsPages(fixture('17.7.0')))).toThrow(/upgrading\.mdx.*refusing to replace it/);
175+
expect(fs.readdirSync(dir).sort()).toEqual(['17.md', 'upgrading.mdx']);
176+
expect(fs.readFileSync(path.join(dir, '17.md'), 'utf8')).toBe('old');
177+
});
178+
});
179+
180+
describe('the real generator writes the docs pages from the same renderer as the shipped file', () => {
181+
function run(args: string[]): { status: number; output: string } {
182+
const r = spawnSync(TSX, [path.join(HERE, 'build-upgrade-guide.ts'), ...args], {
183+
cwd: PKG,
184+
encoding: 'utf8',
185+
timeout: SPAWN_TIMEOUT_MS,
186+
stdio: ['ignore', 'pipe', 'pipe'],
187+
});
188+
return { status: r.status ?? -1, output: `${r.stdout ?? ''}${r.stderr ?? ''}` };
189+
}
190+
191+
/** The single file's `## Protocol N-1 → N` sections, keyed by N. */
192+
function sectionsOf(text: string): Map<number, string> {
193+
const sections = new Map<number, string>();
194+
const body = text.slice(0, text.lastIndexOf('\n---\n') + 1);
195+
const parts = body.split(/^(?=## Protocol \d+ → \d+$)/m).slice(1);
196+
for (const part of parts) sections.set(Number(/→ (\d+)/.exec(part)![1]), part);
197+
return sections;
198+
}
199+
200+
it('--docs writes one page per hop of the single-file guide, each holding that hop verbatim', () => {
201+
const file = path.join(tmp, 'guide.md');
202+
const dir = path.join(tmp, 'pages');
203+
204+
expect(run(['--out', file]).status).toBe(0);
205+
const docs = run(['--docs', dir]);
206+
expect(docs.status, docs.output).toBe(0);
207+
208+
const sections = sectionsOf(fs.readFileSync(file, 'utf8'));
209+
expect(sections.size).toBeGreaterThan(0);
210+
const majors = [...sections.keys()].sort((a, b) => b - a);
211+
expect(fs.readdirSync(dir).sort()).toEqual([...majors.map((m) => `${m}.md`), 'index.md', 'meta.json'].sort());
212+
expect(JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf8')).pages).toEqual([
213+
'index',
214+
...majors.map(String),
215+
]);
216+
for (const [major, section] of sections) {
217+
expect(fs.readFileSync(path.join(dir, `${major}.md`), 'utf8')).toContain(section);
218+
}
219+
});
220+
221+
it('--docs is deterministic across runs', () => {
222+
const first = path.join(tmp, 'first');
223+
const second = path.join(tmp, 'second');
224+
225+
expect(run(['--docs', first]).status).toBe(0);
226+
expect(run(['--docs', second]).status).toBe(0);
227+
228+
for (const name of fs.readdirSync(first)) {
229+
expect(fs.readFileSync(path.join(second, name)).equals(fs.readFileSync(path.join(first, name))), name).toBe(true);
230+
}
231+
});
232+
233+
it('--docs with --check or --out, or with no directory, is a usage error that writes nothing', () => {
234+
const dir = path.join(tmp, 'never');
235+
236+
expect(run(['--docs', dir, '--check']).status).toBe(2);
237+
expect(run(['--docs', dir, '--out', path.join(tmp, 'x.md')]).status).toBe(2);
238+
expect(run(['--docs']).status).toBe(2);
239+
expect(run(['--docs', '--check']).status).toBe(2);
240+
expect(fs.readdirSync(tmp)).toEqual([]);
241+
});
242+
243+
it('--docs at a directory holding a hand-written file is red and removes nothing', () => {
244+
const dir = path.join(tmp, 'docs');
245+
fs.mkdirSync(dir);
246+
fs.writeFileSync(path.join(dir, 'upgrading.mdx'), 'hand-written');
247+
248+
const r = run(['--docs', dir]);
249+
250+
expect(r.status).toBe(1);
251+
expect(r.output).toContain('refusing to replace it');
252+
expect(fs.readdirSync(dir)).toEqual(['upgrading.mdx']);
253+
});
254+
});

‎scripts/check-future-spec-major.mjs‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -359,17 +359,6 @@ const QUOTATION_EXEMPTIONS = Object.freeze([
359359
+ 'per-major 17 → 18 record and into the aggregate record, once each, '
360360
+ 'from protocol 18 on. A count, not a version.',
361361
},
362-
{
363-
file: 'docs/protocol-upgrade-guide.md',
364-
major: 4997,
365-
kind: 'measurement',
366-
covers: 1,
367-
witness: /controls objectstack 12966 and @objectstack\/spec 4997 on the same corpus/,
368-
why:
369-
'The generated copy of that same semantic entry\'s evidence sentence: '
370-
+ '`gen:upgrade-guide` renders step 18\'s semantic entries into the '
371-
+ 'Protocol 17 → 18 section from protocol 18 on. A count, not a version.',
372-
},
373362
{
374363
file: 'docs/adr/0021-analytics-dataset-semantic-layer.md',
375364
major: 6526,

0 commit comments

Comments
 (0)