diff --git a/.changeset/cli-spec-version-advisory.md b/.changeset/cli-spec-version-advisory.md new file mode 100644 index 0000000000..46d24abfa3 --- /dev/null +++ b/.changeset/cli-spec-version-advisory.md @@ -0,0 +1,19 @@ +--- +'@objectstack/cli': minor +--- + +feat(cli): surface the migration guide when an app's specVersion trails the installed platform + +`os validate`, `os build`/`os compile`, and `os doctor` now emit a non-blocking +advisory when the app's authored `manifest.specVersion` declares an OLDER major +than the `@objectstack/spec` actually installed in its `node_modules` — pointing +at the curated per-major migration guide (`https://docs.objectstack.ai/docs/releases/v`, +guaranteed to exist by `scripts/check-release-notes.mjs`). + +This closes a discoverability gap for downstream/third-party apps: on a platform +upgrade the release notes were only reachable by reverse-engineering per-package +`CHANGELOG.md` files. The advisory now surfaces the guide at the exact moment the +upgrade is exercised. It never fails a build/validate and is not gated by +`--strict`; it also appears in the `--json` output as `specVersionGap`. Logic +lives in a new shared `checkSpecVersionGap()` util (unit-tested; installed +version injectable for tests). diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 24336c66c8..2888043c84 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -31,6 +31,7 @@ import { collectMetadataStats, printMetadataStats, } from '../utils/format.js'; +import { checkSpecVersionGap } from '../utils/spec-version.js'; export default class Compile extends Command { static override description = 'Compile ObjectStack configuration to JSON artifact'; @@ -523,6 +524,10 @@ export default class Compile extends Command { const sizeKB = (jsonContent.length / 1024).toFixed(1); const stats = collectMetadataStats(config); + // Spec-version drift advisory (non-blocking): installed platform newer + // than the app declares → point at the migration guide. + const specGap = checkSpecVersionGap((config as { manifest?: { specVersion?: unknown } }).manifest); + if (flags.json) { console.log(JSON.stringify({ success: true, @@ -532,6 +537,7 @@ export default class Compile extends Command { runtimeModule: runtimeBundle?.outputFileName ?? null, runtimeModuleSize: runtimeBundle?.size ?? 0, warnings: widgetWarnings, + specVersionGap: specGap, stats, duration: timer.elapsed(), })); @@ -555,6 +561,11 @@ export default class Compile extends Command { `${path.join(path.dirname(output), runtimeBundle.outputFileName)} ${chalk.dim(`(${runtimeKB} KB, ${lowering.count} handler${lowering.count === 1 ? '' : 's'})`)}`, ); } + if (specGap) { + console.log(''); + console.log(chalk.yellow(` ⚠ ${specGap.message}`)); + console.log(chalk.dim(` → ${specGap.hint}`)); + } console.log(''); } catch (error: any) { diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 9daf391662..68f8b3a56f 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -8,6 +8,7 @@ import path from 'path'; import { normalizeStackInput } from '@objectstack/spec'; import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js'; import { loadConfig, configExists } from '../utils/config.js'; +import { checkSpecVersionGap } from '../utils/spec-version.js'; import { validateWidgetBindings } from '@objectstack/lint'; interface HealthCheckResult { @@ -487,6 +488,17 @@ export default class Doctor extends Command { const { config: rawConfig } = await loadConfig(); const config: any = normalizeStackInput(rawConfig as Record); + // Spec-version drift: installed platform newer than the app declares. + printStep('Checking platform spec version...'); + const specGap = checkSpecVersionGap(config.manifest); + if (specGap) { + hasWarnings = true; + printWarning(`Platform spec ${specGap.message}`); + console.log(chalk.dim(` → ${specGap.hint}`)); + } else { + printSuccess('Platform spec Declared specVersion is current with the installed platform'); + } + // Circular dependency detection if (Array.isArray(config.objects) && config.objects.length > 0) { printStep('Checking for circular dependencies...'); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index d00db4495d..d7ebf63686 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -27,6 +27,7 @@ import { collectMetadataStats, printMetadataStats, } from '../utils/format.js'; +import { checkSpecVersionGap } from '../utils/spec-version.js'; export default class Validate extends Command { static override description = @@ -378,6 +379,10 @@ export default class Validate extends Command { // 4. Collect and display stats const stats = collectMetadataStats(config); + // Spec-version drift advisory (non-blocking): if the installed platform + // is a newer major than the app declares, point at the migration guide. + const specGap = checkSpecVersionGap(config.manifest); + if (flags.json) { console.log(JSON.stringify({ valid: true, @@ -385,6 +390,7 @@ export default class Validate extends Command { stats, warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...securityAdvisories], conversions: conversionNotices, + specVersionGap: specGap, duration: timer.elapsed(), }, null, 2)); return; @@ -460,6 +466,13 @@ export default class Validate extends Command { } } + // Non-blocking upgrade advisory — never gated by --strict. + if (specGap) { + console.log(''); + console.log(chalk.yellow(` ⚠ ${specGap.message}`)); + console.log(chalk.dim(` → ${specGap.hint}`)); + } + console.log(''); } catch (error: any) { if (flags.json) { diff --git a/packages/cli/src/utils/spec-version.test.ts b/packages/cli/src/utils/spec-version.test.ts new file mode 100644 index 0000000000..4045d9b76f --- /dev/null +++ b/packages/cli/src/utils/spec-version.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { checkSpecVersionGap } from './spec-version.js'; + +describe('checkSpecVersionGap', () => { + it('flags an app declaring an older major than the installed platform', () => { + const gap = checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.7.0'); + expect(gap).not.toBeNull(); + expect(gap!.declaredMajor).toBe(12); + expect(gap!.installedMajor).toBe(14); + expect(gap!.installedVersion).toBe('14.7.0'); + expect(gap!.url).toBe('https://docs.objectstack.ai/docs/releases/v14'); + expect(gap!.hint).toContain('https://docs.objectstack.ai/docs/releases/v14'); + }); + + it('points at the guide for the INSTALLED major, not the declared one', () => { + // Two-major jump (12 → 14): the guide must be v14, the version on disk. + const gap = checkSpecVersionGap({ specVersion: '^12.0.0' }, '14.0.0'); + expect(gap!.url).toBe('https://docs.objectstack.ai/docs/releases/v14'); + }); + + it('is silent when declared major matches the installed platform', () => { + expect(checkSpecVersionGap({ specVersion: '^14.0.0' }, '14.7.0')).toBeNull(); + }); + + it('is silent when the app declares a NEWER major (stale install, out of scope)', () => { + expect(checkSpecVersionGap({ specVersion: '^15.0.0' }, '14.7.0')).toBeNull(); + }); + + it('is silent when no specVersion is declared', () => { + expect(checkSpecVersionGap({}, '14.7.0')).toBeNull(); + expect(checkSpecVersionGap(undefined, '14.7.0')).toBeNull(); + expect(checkSpecVersionGap(null, '14.7.0')).toBeNull(); + }); + + it('is silent when the installed version cannot be resolved', () => { + expect(checkSpecVersionGap({ specVersion: '^12.0.0' }, null)).toBeNull(); + }); + + it('parses the major out of assorted range spellings', () => { + for (const range of ['^12.0.0', '>=12', '12.x', '~12.3.0', '12 || 13']) { + const gap = checkSpecVersionGap({ specVersion: range }, '14.0.0'); + expect(gap, range).not.toBeNull(); + expect(gap!.declaredMajor, range).toBe(12); + } + }); + + it('ignores a non-string specVersion', () => { + expect(checkSpecVersionGap({ specVersion: 12 as unknown as string }, '14.0.0')).toBeNull(); + }); +}); diff --git a/packages/cli/src/utils/spec-version.ts b/packages/cli/src/utils/spec-version.ts new file mode 100644 index 0000000000..9ea8a6aa80 --- /dev/null +++ b/packages/cli/src/utils/spec-version.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { createRequire } from 'module'; + +/** + * Spec-version drift advisory. + * + * Surfaces the one thing an AI agent (or human) upgrading a third-party app + * almost never finds on its own: the curated per-major migration guide. When + * an app's authored `manifest.specVersion` declares an OLDER major than the + * `@objectstack/spec` actually installed in its `node_modules`, the platform + * moved ahead of the app and there is breaking-change guidance the author + * should read before proceeding. Every major from v12 on is guaranteed a + * `content/docs/releases/v.mdx` page (enforced by + * `scripts/check-release-notes.mjs`), so the URL below never 404s. + * + * The check is advisory-only — it never fails a build/validate. It exists so + * the release notes are discoverable at the exact moment of the upgrade, + * instead of being reverse-engineered from per-package `CHANGELOG.md` files. + */ + +const RELEASES_BASE = 'https://docs.objectstack.ai/docs/releases'; + +export interface SpecVersionGap { + /** Major of the `@objectstack/spec` resolved from the app's node_modules. */ + installedMajor: number; + /** Major declared by the app's `manifest.specVersion` range. */ + declaredMajor: number; + /** Full installed spec version (e.g. `14.7.0`). */ + installedVersion: string; + /** Canonical migration guide for the installed major. */ + url: string; + /** Ready-to-print one-line advisory. */ + message: string; + /** Ready-to-print follow-up pointing at the guide. */ + hint: string; +} + +/** Parse the leading major integer out of a semver range like `^12.0.0`, `>=13`, `14.x`. */ +function parseMajor(range: unknown): number | null { + if (typeof range !== 'string') return null; + const m = range.match(/\d+/); + if (!m) return null; + const major = Number.parseInt(m[0], 10); + return Number.isFinite(major) ? major : null; +} + +/** Resolve the installed `@objectstack/spec` version from the app being operated on. */ +function resolveInstalledSpecVersion(): string | null { + try { + // Resolve relative to the CWD (the app), not the CLI install, so a globally + // linked CLI still reports the app's locked spec version. Fall back to the + // CLI's own resolution if the app doesn't hoist spec to its root. + const requireFromApp = createRequire(`${process.cwd()}/package.json`); + const pkg = requireFromApp('@objectstack/spec/package.json') as { version?: string }; + if (typeof pkg.version === 'string') return pkg.version; + } catch { + // ignore — try the CLI-relative resolution below + } + try { + const requireFromCli = createRequire(import.meta.url); + const pkg = requireFromCli('@objectstack/spec/package.json') as { version?: string }; + if (typeof pkg.version === 'string') return pkg.version; + } catch { + // ignore — spec not resolvable, no advisory + } + return null; +} + +/** + * Compute a spec-version drift advisory for the given app manifest, or `null` + * when there is nothing to say (spec unresolvable, no `specVersion` declared, + * or the declared major already matches / leads the installed platform). + */ +export function checkSpecVersionGap( + manifest: { specVersion?: unknown } | undefined | null, + /** Injectable for tests; defaults to the spec resolved from the app on disk. */ + installedVersion: string | null = resolveInstalledSpecVersion(), +): SpecVersionGap | null { + const declaredMajor = parseMajor(manifest?.specVersion); + if (declaredMajor == null) return null; + + if (!installedVersion) return null; + const installedMajor = parseMajor(installedVersion); + if (installedMajor == null) return null; + + // Only the upgrade case: the platform on disk is newer than what the app + // declares. (declaredMajor > installedMajor is a stale/mismatched install — + // a different problem, out of scope for release-note discoverability.) + if (declaredMajor >= installedMajor) return null; + + const url = `${RELEASES_BASE}/v${installedMajor}`; + return { + installedMajor, + declaredMajor, + installedVersion, + url, + message: `Installed @objectstack/spec is v${installedVersion} but this app declares specVersion for v${declaredMajor}.`, + hint: `Review the v${installedMajor} migration guide before bumping specVersion: ${url}`, + }; +}