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").
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' ;
2839import { resolve } from 'node:path' ;
2940import { fileURLToPath } from 'node:url' ;
3041
3142import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry' ;
3243import { PROTOCOL_MAJOR , PROTOCOL_VERSION } from '../src/kernel/protocol-version' ;
3344import { 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+
143240exitWith (
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) ;
0 commit comments