Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/cli-spec-version-advisory.md
Original file line number Diff line number Diff line change
@@ -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<major>`,
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).
11 changes: 11 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
}));
Expand All @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -487,6 +488,17 @@ export default class Doctor extends Command {
const { config: rawConfig } = await loadConfig();
const config: any = normalizeStackInput(rawConfig as Record<string, unknown>);

// 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...');
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -378,13 +379,18 @@ 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,
manifest: config.manifest,
stats,
warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...securityAdvisories],
conversions: conversionNotices,
specVersionGap: specGap,
duration: timer.elapsed(),
}, null, 2));
return;
Expand Down Expand Up @@ -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) {
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/utils/spec-version.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
101 changes: 101 additions & 0 deletions packages/cli/src/utils/spec-version.ts
Original file line number Diff line number Diff line change
@@ -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<major>.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}`,
};
}