Skip to content

Commit 0e756a1

Browse files
committed
wip(spec): the upgrade guide's no-flag mode writes per-major docs pages
Claude-Session: https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN Co-authored-by: Claude <noreply@anthropic.com>
1 parent ebbb8f9 commit 0e756a1

3 files changed

Lines changed: 378 additions & 66 deletions

File tree

Lines changed: 160 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* build-upgrade-guide.ts — generate `docs/protocol-upgrade-guide.md` as a pure
4+
* build-upgrade-guide.ts — generate the protocol upgrade guide as a pure
55
* projection of the ADR-0087 registries (D4: "the upgrade guide for major N is
66
* GENERATED from the change registry … it can never drift because it is a
77
* projection of it").
@@ -17,43 +17,53 @@
1717
* against a fresh generation and attaches the same bytes to the GitHub Release.
1818
* The pull-request stage generates it IN MEMORY (`--check`) and compares no
1919
* committed copy; `render-projection-diff.ts` renders the generated diff
20-
* against the base on the pull request. The command-line contract is
21-
* `lib/projection-cli.ts`.
20+
* against the base on the pull request. The command-line contract of those two
21+
* flags is `lib/projection-cli.ts`.
2222
*
23-
* pnpm --filter @objectstack/spec gen:upgrade-guide # write the committed copy (until it is deleted)
23+
* ITS PUBLIC ADDRESS (#22449 B′, condition 2) is the docs site: one page per
24+
* protocol major plus an index, generated at docs build into the gitignored
25+
* `content/docs/protocol-upgrade/` and served at
26+
* `https://objectstack.ai/docs/protocol-upgrade/<major>`. That is what this
27+
* script writes with no flag — the `@objectstack/docs` build runs it before
28+
* `next build` — and `lib/upgrade-guide-docs.ts` assembles the pages from the
29+
* same section renderers `build()` uses. `docs/protocol-upgrade-guide.md` is a
30+
* committed pointer stub naming that address; nothing here writes it.
31+
*
32+
* pnpm --filter @objectstack/spec gen:upgrade-guide # write the docs pages (content/docs/protocol-upgrade/)
2433
* pnpm --filter @objectstack/spec check:upgrade-guide # CI: generate in memory, red when generation fails
25-
* tsx scripts/build-upgrade-guide.ts --out <file> # write the generated bytes anywhere
34+
* tsx scripts/build-upgrade-guide.ts --out <file> # write the single-file guide anywhere (the publish lane)
35+
* tsx scripts/build-upgrade-guide.ts --docs <dir> # write the docs pages somewhere else
2636
*/
2737

38+
import { readFileSync } from 'node:fs';
2839
import { resolve } from 'node:path';
2940
import { fileURLToPath } from 'node:url';
3041

3142
import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry';
3243
import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '../src/kernel/protocol-version';
3344
import { MIGRATIONS_BY_MAJOR, MIGRATION_SUPPORT_FLOOR } from '../src/migrations/registry';
34-
import { exitWith, runProjectionCli } from './lib/projection-cli';
45+
import { exitWith, runProjectionCli, type ProjectionCliResult } from './lib/projection-cli';
46+
import { buildDocsPages, DOCS_DIR, writeDocsPages, type HopInput } from './lib/upgrade-guide-docs';
3547

36-
const REPO_ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)), '..', '..', '..');
37-
const GUIDE = resolve(REPO_ROOT, 'docs', 'protocol-upgrade-guide.md');
48+
const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
49+
const REPO_ROOT = resolve(PKG_DIR, '..', '..');
3850

39-
function build(): string {
51+
/**
52+
* `## How to upgrade`, shared by the single-file guide and every docs page. The
53+
* single file lists every hop below this section; a docs page holds one hop, so
54+
* its first command says which hops the replay covers instead.
55+
*/
56+
function howToUpgrade(form: 'file' | 'page'): string[] {
4057
const lines: string[] = [];
4158
const out = (s = '') => lines.push(s);
42-
43-
out('<!-- GENERATED (ADR-0087 D4) — do not edit by hand. -->');
44-
out('<!-- Regenerate: pnpm --filter @objectstack/spec gen:upgrade-guide -->');
45-
out();
46-
out('# Metadata protocol upgrade guide');
47-
out();
48-
out(
49-
`Current protocol: **${PROTOCOL_VERSION}** · chain support floor: **protocol ${MIGRATION_SUPPORT_FLOOR}** · ` +
50-
'generated from the ADR-0087 registries (`@objectstack/spec` `conversions/` + `migrations/`).',
51-
);
52-
out();
5359
out(`## How to upgrade — from protocol ${MIGRATION_SUPPORT_FLOOR} onward`);
5460
out();
5561
out('```bash');
56-
out(`objectstack migrate meta --from <your-major> # replays every step below, in order`);
62+
out(
63+
form === 'file'
64+
? `objectstack migrate meta --from <your-major> # replays every step below, in order`
65+
: `objectstack migrate meta --from <your-major> # replays every hop from your major to ${PROTOCOL_MAJOR}, in order`,
66+
);
5767
out(
5868
`objectstack migrate meta --from ${MIGRATION_SUPPORT_FLOOR} --step ` +
5969
'# checkpoint after each major (bisect a failure)',
@@ -84,53 +94,92 @@ function build(): string {
8494
);
8595
out();
8696

87-
for (let major = MIGRATION_SUPPORT_FLOOR + 1; major <= PROTOCOL_MAJOR; major++) {
88-
const step = MIGRATIONS_BY_MAJOR[major];
89-
const conversions = CONVERSIONS_BY_MAJOR[major] ?? [];
90-
out(`## Protocol ${major - 1} → ${major}`);
97+
return lines;
98+
}
99+
100+
/** One hop's own section: `## Protocol N-1 → N` and everything under it. */
101+
function hopSection(major: number): string[] {
102+
const lines: string[] = [];
103+
const out = (s = '') => lines.push(s);
104+
const step = MIGRATIONS_BY_MAJOR[major];
105+
const conversions = CONVERSIONS_BY_MAJOR[major] ?? [];
106+
out(`## Protocol ${major - 1} → ${major}`);
107+
out();
108+
if (!step && conversions.length === 0) {
109+
out('No metadata-facing break shipped in this major — nothing to do.');
91110
out();
92-
if (!step && conversions.length === 0) {
93-
out('No metadata-facing break shipped in this major — nothing to do.');
94-
out();
95-
continue;
96-
}
97-
if (step) {
98-
out(step.rationale);
99-
out();
100-
}
101-
if (conversions.length > 0) {
102-
out('### Mechanical (applied for you)');
103-
out();
104-
out('| Conversion | Surface | Change | Load window |');
105-
out('|---|---|---|---|');
106-
for (const c of conversions) {
107-
const window = c.retiredFromLoadPath
108-
? 'retired — `migrate meta` only'
109-
: `live — protocol ${c.toMajor} loader accepts the old shape`;
110-
out(`| \`${c.id}\` | \`${c.surface}\` | ${c.summary} | ${window} |`);
111-
}
112-
out();
111+
return lines;
112+
}
113+
if (step) {
114+
out(step.rationale);
115+
out();
116+
}
117+
if (conversions.length > 0) {
118+
out('### Mechanical (applied for you)');
119+
out();
120+
out('| Conversion | Surface | Change | Load window |');
121+
out('|---|---|---|---|');
122+
for (const c of conversions) {
123+
const window = c.retiredFromLoadPath
124+
? 'retired — `migrate meta` only'
125+
: `live — protocol ${c.toMajor} loader accepts the old shape`;
126+
out(`| \`${c.id}\` | \`${c.surface}\` | ${c.summary} | ${window} |`);
113127
}
114-
const semantic = step?.semantic ?? [];
115-
if (semantic.length > 0) {
116-
out('### Semantic (delegated to you, with acceptance criteria)');
117-
out();
118-
for (const s of semantic) {
119-
out(`- **\`${s.id}\`** — \`${s.surface}\` → ${s.replacement}`);
120-
out(` - Why not automatic: ${s.reason}`);
121-
out(` - Done when: ${s.acceptanceCriteria}`);
122-
}
123-
out();
128+
out();
129+
}
130+
const semantic = step?.semantic ?? [];
131+
if (semantic.length > 0) {
132+
out('### Semantic (delegated to you, with acceptance criteria)');
133+
out();
134+
for (const s of semantic) {
135+
out(`- **\`${s.id}\`** — \`${s.surface}\` → ${s.replacement}`);
136+
out(` - Why not automatic: ${s.reason}`);
137+
out(` - Done when: ${s.acceptanceCriteria}`);
124138
}
139+
out();
125140
}
141+
return lines;
142+
}
126143

144+
/** The closing lines: the machine-readable equivalents. */
145+
function footer(): string[] {
146+
const lines: string[] = [];
147+
const out = (s = '') => lines.push(s);
127148
out('---');
128149
out();
129150
out(
130151
'*Machine-readable equivalents: `spec-changes.json` (shipped in `@objectstack/spec` and attached to ' +
131152
'each GitHub Release) and the structured output of `objectstack migrate meta --json`.*',
132153
);
133154
out();
155+
return lines;
156+
}
157+
158+
/** The majors the chain carries a hop for, oldest first. */
159+
function hopMajors(): number[] {
160+
const majors: number[] = [];
161+
for (let major = MIGRATION_SUPPORT_FLOOR + 1; major <= PROTOCOL_MAJOR; major++) majors.push(major);
162+
return majors;
163+
}
164+
165+
/** The single-file guide: what the package ships and the Release carries. */
166+
function build(): string {
167+
const lines: string[] = [];
168+
const out = (s = '') => lines.push(s);
169+
170+
out('<!-- GENERATED (ADR-0087 D4) — do not edit by hand. -->');
171+
out('<!-- Regenerate: pnpm --filter @objectstack/spec exec tsx scripts/build-upgrade-guide.ts --out FILE -->');
172+
out();
173+
out('# Metadata protocol upgrade guide');
174+
out();
175+
out(
176+
`Current protocol: **${PROTOCOL_VERSION}** · chain support floor: **protocol ${MIGRATION_SUPPORT_FLOOR}** · ` +
177+
'generated from the ADR-0087 registries (`@objectstack/spec` `conversions/` + `migrations/`).',
178+
);
179+
out();
180+
lines.push(...howToUpgrade('file'));
181+
for (const major of hopMajors()) lines.push(...hopSection(major));
182+
lines.push(...footer());
134183
return lines.join('\n');
135184
}
136185

@@ -140,12 +189,60 @@ function describeGuide(text: string): string {
140189
return `protocol ${PROTOCOL_VERSION}, ${hops} major hop section(s), ${Buffer.byteLength(text)} bytes`;
141190
}
142191

192+
/** The docs pages: one per hop the chain carries, plus the index (`lib/upgrade-guide-docs.ts`). */
193+
function buildDocs(): Map<string, string> {
194+
const { version } = JSON.parse(readFileSync(resolve(PKG_DIR, 'package.json'), 'utf8')) as { version: string };
195+
const hops: HopInput[] = hopMajors().map((major) => ({ major, section: hopSection(major) }));
196+
return buildDocsPages({ specVersion: version, howToUpgrade: howToUpgrade('page'), hops, footer: footer() });
197+
}
198+
199+
/**
200+
* `--docs <dir>`, or no flag at all: write the docs pages. `--check` and
201+
* `--out` stay `lib/projection-cli.ts`'s, untouched.
202+
*/
203+
function runDocs(argv: readonly string[]): ProjectionCliResult | null {
204+
const at = argv.indexOf('--docs');
205+
const modeFlag = argv.includes('--check') || argv.includes('--out');
206+
if (at < 0 && modeFlag) return null;
207+
const usage = (line: string): ProjectionCliResult => ({ code: 2, stdout: [], stderr: [line], wrote: null });
208+
if (at >= 0 && modeFlag) return usage('--docs writes the docs pages; --check and --out are the single-file modes. Pass one.');
209+
const value = at >= 0 ? argv[at + 1] : undefined;
210+
if (at >= 0 && (value === undefined || value.startsWith('--'))) return usage('--docs needs a directory: --docs <dir>.');
211+
const dir = resolve(value ?? resolve(REPO_ROOT, DOCS_DIR));
212+
213+
let files: Map<string, string>;
214+
try {
215+
files = buildDocs();
216+
} catch (err) {
217+
return {
218+
code: 1,
219+
stdout: [],
220+
stderr: [
221+
'✗ the protocol upgrade guide could not be generated from the ADR-0087 registries. Nothing was written.',
222+
` ${(err instanceof Error ? (err.stack ?? err.message) : String(err)).split('\n').join('\n ')}`,
223+
],
224+
wrote: null,
225+
};
226+
}
227+
try {
228+
writeDocsPages(dir, files);
229+
} catch (err) {
230+
return { code: 1, stdout: [], stderr: [`✗ ${err instanceof Error ? err.message : String(err)}`], wrote: null };
231+
}
232+
return {
233+
code: 0,
234+
stdout: [`Wrote ${files.size} docs file(s) to ${dir}: ${[...files.keys()].join(', ')}`],
235+
stderr: [],
236+
wrote: dir,
237+
};
238+
}
239+
143240
exitWith(
144-
runProjectionCli({
145-
name: 'protocol-upgrade-guide.md',
146-
committedPath: GUIDE,
147-
build,
148-
describe: describeGuide,
149-
argv: process.argv,
150-
}),
241+
runDocs(process.argv) ??
242+
runProjectionCli({
243+
name: 'protocol-upgrade-guide.md',
244+
build,
245+
describe: describeGuide,
246+
argv: process.argv,
247+
}),
151248
);

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
*
1313
* (no flag) write the bytes to the committed path. Kept only until the
1414
* committed copies are deleted; nothing gates on that copy.
15+
* A projection with no committed copy (the upgrade guide,
16+
* whose no-flag mode writes its docs pages instead) passes
17+
* no `committedPath`, and a bare run here is a usage error.
1518
* --check generate in memory and write NOTHING. Green means the
1619
* registries project; red means they do not. ⛔ No committed
1720
* copy is read, so a pull request that only adds a registry
@@ -35,8 +38,12 @@ import { dirname, resolve } from 'node:path';
3538
export interface ProjectionCliOptions {
3639
/** The projection's name in every message, e.g. `spec-changes.json`. */
3740
name: string;
38-
/** The committed copy the no-flag mode writes. */
39-
committedPath: string;
41+
/**
42+
* The committed copy the no-flag mode writes. Absent when the projection has
43+
* none — the upgrade guide's no-flag mode is its own (the docs pages,
44+
* `build-upgrade-guide.ts`) — and then a bare run is a usage error.
45+
*/
46+
committedPath?: string;
4047
/** The projection's bytes, from the registries. Throws when it cannot. */
4148
build: () => string;
4249
/** One clause describing generated bytes, for `--check`'s success line. */
@@ -72,6 +79,9 @@ export function runProjectionCli(opts: ProjectionCliOptions): ProjectionCliResul
7279
const usage = (line: string): ProjectionCliResult => ({ code: 2, stdout: [], stderr: [line], wrote: null });
7380

7481
if (out === null) return usage(`--out needs a path: --out <file> (${opts.name}).`);
82+
if (!check && out === undefined && opts.committedPath === undefined) {
83+
return usage(`${opts.name} has no committed copy: pass --check (generate in memory) or --out <file>.`);
84+
}
7585
if (check && out !== undefined) {
7686
return usage(
7787
`--check generates ${opts.name} in memory and writes nothing; --out writes it. Pass one, not both.`,
@@ -107,7 +117,8 @@ export function runProjectionCli(opts: ProjectionCliOptions): ProjectionCliResul
107117
};
108118
}
109119

110-
const target = resolve(out ?? opts.committedPath);
120+
// `out ?? committedPath` is never undefined here: the usage check above refused that.
121+
const target = resolve((out ?? opts.committedPath) as string);
111122
mkdirSync(dirname(target), { recursive: true });
112123
writeFileSync(target, bytes);
113124
return { code: 0, stdout: [`Wrote ${target}`], stderr: [], wrote: target };

0 commit comments

Comments
 (0)