Skip to content

Commit 5540ced

Browse files
os-zhuangzhuangjianguoclaude
authored
feat(cli): surface migration guide when specVersion trails the installed platform (#2914)
os validate, os build/compile, and os doctor now emit a non-blocking advisory when an app's authored manifest.specVersion declares an older major than the @objectstack/spec installed in its node_modules, linking the curated per-major migration guide at https://docs.objectstack.ai/docs/releases/v<major>. Downstream apps upgrading the platform previously had no on-disk pointer to the release notes — they had to reverse-engineer breaking changes from per-package CHANGELOG.md files. This surfaces the guide at the moment the upgrade is exercised. Every major from v12 on is guaranteed a releases page by scripts/check-release-notes.mjs, so the URL never 404s. - New shared util checkSpecVersionGap() (installed version injectable for tests). - Advisory is never gated by --strict and also appears in --json as specVersionGap. - Unit-tested (8 cases) and smoke-verified end-to-end against a real app whose installed spec (14.7.0) leads its declared specVersion (^12). Claude-Session: https://claude.ai/code/session_01YV2dfQ9eR8NYKLSTVLKavY Co-authored-by: os-zhuang <zhuangjianguo@steedos.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f0acf25 commit 5540ced

6 files changed

Lines changed: 208 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli): surface the migration guide when an app's specVersion trails the installed platform
6+
7+
`os validate`, `os build`/`os compile`, and `os doctor` now emit a non-blocking
8+
advisory when the app's authored `manifest.specVersion` declares an OLDER major
9+
than the `@objectstack/spec` actually installed in its `node_modules` — pointing
10+
at the curated per-major migration guide (`https://docs.objectstack.ai/docs/releases/v<major>`,
11+
guaranteed to exist by `scripts/check-release-notes.mjs`).
12+
13+
This closes a discoverability gap for downstream/third-party apps: on a platform
14+
upgrade the release notes were only reachable by reverse-engineering per-package
15+
`CHANGELOG.md` files. The advisory now surfaces the guide at the exact moment the
16+
upgrade is exercised. It never fails a build/validate and is not gated by
17+
`--strict`; it also appears in the `--json` output as `specVersionGap`. Logic
18+
lives in a new shared `checkSpecVersionGap()` util (unit-tested; installed
19+
version injectable for tests).

packages/cli/src/commands/compile.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
collectMetadataStats,
3232
printMetadataStats,
3333
} from '../utils/format.js';
34+
import { checkSpecVersionGap } from '../utils/spec-version.js';
3435

3536
export default class Compile extends Command {
3637
static override description = 'Compile ObjectStack configuration to JSON artifact';
@@ -523,6 +524,10 @@ export default class Compile extends Command {
523524
const sizeKB = (jsonContent.length / 1024).toFixed(1);
524525
const stats = collectMetadataStats(config);
525526

527+
// Spec-version drift advisory (non-blocking): installed platform newer
528+
// than the app declares → point at the migration guide.
529+
const specGap = checkSpecVersionGap((config as { manifest?: { specVersion?: unknown } }).manifest);
530+
526531
if (flags.json) {
527532
console.log(JSON.stringify({
528533
success: true,
@@ -532,6 +537,7 @@ export default class Compile extends Command {
532537
runtimeModule: runtimeBundle?.outputFileName ?? null,
533538
runtimeModuleSize: runtimeBundle?.size ?? 0,
534539
warnings: widgetWarnings,
540+
specVersionGap: specGap,
535541
stats,
536542
duration: timer.elapsed(),
537543
}));
@@ -555,6 +561,11 @@ export default class Compile extends Command {
555561
`${path.join(path.dirname(output), runtimeBundle.outputFileName)} ${chalk.dim(`(${runtimeKB} KB, ${lowering.count} handler${lowering.count === 1 ? '' : 's'})`)}`,
556562
);
557563
}
564+
if (specGap) {
565+
console.log('');
566+
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
567+
console.log(chalk.dim(` → ${specGap.hint}`));
568+
}
558569
console.log('');
559570

560571
} catch (error: any) {

packages/cli/src/commands/doctor.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import path from 'path';
88
import { normalizeStackInput } from '@objectstack/spec';
99
import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js';
1010
import { loadConfig, configExists } from '../utils/config.js';
11+
import { checkSpecVersionGap } from '../utils/spec-version.js';
1112
import { validateWidgetBindings } from '@objectstack/lint';
1213

1314
interface HealthCheckResult {
@@ -487,6 +488,17 @@ export default class Doctor extends Command {
487488
const { config: rawConfig } = await loadConfig();
488489
const config: any = normalizeStackInput(rawConfig as Record<string, unknown>);
489490

491+
// Spec-version drift: installed platform newer than the app declares.
492+
printStep('Checking platform spec version...');
493+
const specGap = checkSpecVersionGap(config.manifest);
494+
if (specGap) {
495+
hasWarnings = true;
496+
printWarning(`Platform spec ${specGap.message}`);
497+
console.log(chalk.dim(` → ${specGap.hint}`));
498+
} else {
499+
printSuccess('Platform spec Declared specVersion is current with the installed platform');
500+
}
501+
490502
// Circular dependency detection
491503
if (Array.isArray(config.objects) && config.objects.length > 0) {
492504
printStep('Checking for circular dependencies...');

packages/cli/src/commands/validate.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
collectMetadataStats,
2828
printMetadataStats,
2929
} from '../utils/format.js';
30+
import { checkSpecVersionGap } from '../utils/spec-version.js';
3031

3132
export default class Validate extends Command {
3233
static override description =
@@ -378,13 +379,18 @@ export default class Validate extends Command {
378379
// 4. Collect and display stats
379380
const stats = collectMetadataStats(config);
380381

382+
// Spec-version drift advisory (non-blocking): if the installed platform
383+
// is a newer major than the app declares, point at the migration guide.
384+
const specGap = checkSpecVersionGap(config.manifest);
385+
381386
if (flags.json) {
382387
console.log(JSON.stringify({
383388
valid: true,
384389
manifest: config.manifest,
385390
stats,
386391
warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...securityAdvisories],
387392
conversions: conversionNotices,
393+
specVersionGap: specGap,
388394
duration: timer.elapsed(),
389395
}, null, 2));
390396
return;
@@ -460,6 +466,13 @@ export default class Validate extends Command {
460466
}
461467
}
462468

469+
// Non-blocking upgrade advisory — never gated by --strict.
470+
if (specGap) {
471+
console.log('');
472+
console.log(chalk.yellow(` ⚠ ${specGap.message}`));
473+
console.log(chalk.dim(` → ${specGap.hint}`));
474+
}
475+
463476
console.log('');
464477
} catch (error: any) {
465478
if (flags.json) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { checkSpecVersionGap } from './spec-version.js';
5+
6+
describe('checkSpecVersionGap', () => {
7+
it('flags an app declaring an older major than the installed platform', () => {
8+
const gap = checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.7.0');
9+
expect(gap).not.toBeNull();
10+
expect(gap!.declaredMajor).toBe(12);
11+
expect(gap!.installedMajor).toBe(14);
12+
expect(gap!.installedVersion).toBe('14.7.0');
13+
expect(gap!.url).toBe('https://docs.objectstack.ai/docs/releases/v14');
14+
expect(gap!.hint).toContain('https://docs.objectstack.ai/docs/releases/v14');
15+
});
16+
17+
it('points at the guide for the INSTALLED major, not the declared one', () => {
18+
// Two-major jump (12 → 14): the guide must be v14, the version on disk.
19+
const gap = checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.0.0');
20+
expect(gap!.url).toBe('https://docs.objectstack.ai/docs/releases/v14');
21+
});
22+
23+
it('is silent when declared major matches the installed platform', () => {
24+
expect(checkSpecVersionGap({ specVersion: '^14.0.0' }, '14.7.0')).toBeNull();
25+
});
26+
27+
it('is silent when the app declares a NEWER major (stale install, out of scope)', () => {
28+
expect(checkSpecVersionGap({ specVersion: '^15.0.0' }, '14.7.0')).toBeNull();
29+
});
30+
31+
it('is silent when no specVersion is declared', () => {
32+
expect(checkSpecVersionGap({}, '14.7.0')).toBeNull();
33+
expect(checkSpecVersionGap(undefined, '14.7.0')).toBeNull();
34+
expect(checkSpecVersionGap(null, '14.7.0')).toBeNull();
35+
});
36+
37+
it('is silent when the installed version cannot be resolved', () => {
38+
expect(checkSpecVersionGap({ specVersion: '^12.0.0' }, null)).toBeNull();
39+
});
40+
41+
it('parses the major out of assorted range spellings', () => {
42+
for (const range of ['^12.0.0', '>=12', '12.x', '~12.3.0', '12 || 13']) {
43+
const gap = checkSpecVersionGap({ specVersion: range }, '14.0.0');
44+
expect(gap, range).not.toBeNull();
45+
expect(gap!.declaredMajor, range).toBe(12);
46+
}
47+
});
48+
49+
it('ignores a non-string specVersion', () => {
50+
expect(checkSpecVersionGap({ specVersion: 12 as unknown as string }, '14.0.0')).toBeNull();
51+
});
52+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { createRequire } from 'module';
4+
5+
/**
6+
* Spec-version drift advisory.
7+
*
8+
* Surfaces the one thing an AI agent (or human) upgrading a third-party app
9+
* almost never finds on its own: the curated per-major migration guide. When
10+
* an app's authored `manifest.specVersion` declares an OLDER major than the
11+
* `@objectstack/spec` actually installed in its `node_modules`, the platform
12+
* moved ahead of the app and there is breaking-change guidance the author
13+
* should read before proceeding. Every major from v12 on is guaranteed a
14+
* `content/docs/releases/v<major>.mdx` page (enforced by
15+
* `scripts/check-release-notes.mjs`), so the URL below never 404s.
16+
*
17+
* The check is advisory-only — it never fails a build/validate. It exists so
18+
* the release notes are discoverable at the exact moment of the upgrade,
19+
* instead of being reverse-engineered from per-package `CHANGELOG.md` files.
20+
*/
21+
22+
const RELEASES_BASE = 'https://docs.objectstack.ai/docs/releases';
23+
24+
export interface SpecVersionGap {
25+
/** Major of the `@objectstack/spec` resolved from the app's node_modules. */
26+
installedMajor: number;
27+
/** Major declared by the app's `manifest.specVersion` range. */
28+
declaredMajor: number;
29+
/** Full installed spec version (e.g. `14.7.0`). */
30+
installedVersion: string;
31+
/** Canonical migration guide for the installed major. */
32+
url: string;
33+
/** Ready-to-print one-line advisory. */
34+
message: string;
35+
/** Ready-to-print follow-up pointing at the guide. */
36+
hint: string;
37+
}
38+
39+
/** Parse the leading major integer out of a semver range like `^12.0.0`, `>=13`, `14.x`. */
40+
function parseMajor(range: unknown): number | null {
41+
if (typeof range !== 'string') return null;
42+
const m = range.match(/\d+/);
43+
if (!m) return null;
44+
const major = Number.parseInt(m[0], 10);
45+
return Number.isFinite(major) ? major : null;
46+
}
47+
48+
/** Resolve the installed `@objectstack/spec` version from the app being operated on. */
49+
function resolveInstalledSpecVersion(): string | null {
50+
try {
51+
// Resolve relative to the CWD (the app), not the CLI install, so a globally
52+
// linked CLI still reports the app's locked spec version. Fall back to the
53+
// CLI's own resolution if the app doesn't hoist spec to its root.
54+
const requireFromApp = createRequire(`${process.cwd()}/package.json`);
55+
const pkg = requireFromApp('@objectstack/spec/package.json') as { version?: string };
56+
if (typeof pkg.version === 'string') return pkg.version;
57+
} catch {
58+
// ignore — try the CLI-relative resolution below
59+
}
60+
try {
61+
const requireFromCli = createRequire(import.meta.url);
62+
const pkg = requireFromCli('@objectstack/spec/package.json') as { version?: string };
63+
if (typeof pkg.version === 'string') return pkg.version;
64+
} catch {
65+
// ignore — spec not resolvable, no advisory
66+
}
67+
return null;
68+
}
69+
70+
/**
71+
* Compute a spec-version drift advisory for the given app manifest, or `null`
72+
* when there is nothing to say (spec unresolvable, no `specVersion` declared,
73+
* or the declared major already matches / leads the installed platform).
74+
*/
75+
export function checkSpecVersionGap(
76+
manifest: { specVersion?: unknown } | undefined | null,
77+
/** Injectable for tests; defaults to the spec resolved from the app on disk. */
78+
installedVersion: string | null = resolveInstalledSpecVersion(),
79+
): SpecVersionGap | null {
80+
const declaredMajor = parseMajor(manifest?.specVersion);
81+
if (declaredMajor == null) return null;
82+
83+
if (!installedVersion) return null;
84+
const installedMajor = parseMajor(installedVersion);
85+
if (installedMajor == null) return null;
86+
87+
// Only the upgrade case: the platform on disk is newer than what the app
88+
// declares. (declaredMajor > installedMajor is a stale/mismatched install —
89+
// a different problem, out of scope for release-note discoverability.)
90+
if (declaredMajor >= installedMajor) return null;
91+
92+
const url = `${RELEASES_BASE}/v${installedMajor}`;
93+
return {
94+
installedMajor,
95+
declaredMajor,
96+
installedVersion,
97+
url,
98+
message: `Installed @objectstack/spec is v${installedVersion} but this app declares specVersion for v${declaredMajor}.`,
99+
hint: `Review the v${installedMajor} migration guide before bumping specVersion: ${url}`,
100+
};
101+
}

0 commit comments

Comments
 (0)