diff --git a/README.md b/README.md index 48bc6dd9..024e40da 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,9 @@ release artifact. Current release versions are: | Package | Version | Release role | | --- | ---: | --- | -| `@openspec-ui/core` | 0.10.0 | Shared behavior and persistence contract | -| `openspec-ui-vscode` | 0.4.2 | VS Code delivery | -| `@openspec-ui/server` | 1.2.1 | Standalone server delivery | +| `@openspec-ui/core` | 0.11.0 | Shared behavior and persistence contract | +| `openspec-ui-vscode` | 0.4.3 | VS Code delivery | +| `@openspec-ui/server` | 1.2.2 | Standalone server delivery | | `@openspec-ui/webui` | 1.2.1 | Shared browser UI | ## Delivery Capability Matrix diff --git a/openspec/changes/openspec-cli-contract-validation/.openspec.yaml b/openspec/changes/openspec-cli-contract-validation/.openspec.yaml new file mode 100644 index 00000000..0907676c --- /dev/null +++ b/openspec/changes/openspec-cli-contract-validation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-08 \ No newline at end of file diff --git a/openspec/changes/openspec-cli-contract-validation/README.md b/openspec/changes/openspec-cli-contract-validation/README.md new file mode 100644 index 00000000..78a1340c --- /dev/null +++ b/openspec/changes/openspec-cli-contract-validation/README.md @@ -0,0 +1,3 @@ +# OpenSpec CLI Contract Validation + +This change validates structured OpenSpec CLI output before it reaches delivery adapters. \ No newline at end of file diff --git a/openspec/changes/openspec-cli-contract-validation/design.md b/openspec/changes/openspec-cli-contract-validation/design.md new file mode 100644 index 00000000..90410901 --- /dev/null +++ b/openspec/changes/openspec-cli-contract-validation/design.md @@ -0,0 +1,30 @@ +## Context + +TypeScript types do not validate child-process output at runtime. OpenSpec CLI +minor releases may add fields safely, while removals or type changes must fail +at the wrapper boundary rather than leak partially valid data. + +## Decisions + +- Define small structural validators for list, specs, show, validate, status, + create, and archive JSON results. +- Validators require fields consumed by current callers and allow unknown + additional fields for forward-compatible additions. +- Current status output without `progress` is normalized from artifact + completion so delivery adapters retain a stable core contract. +- `OpenSpecCliCompatibilityError` identifies the command, expected contract, + and a bounded output preview. +- Invalid JSON and valid-but-incompatible JSON use the same typed error class + with distinct diagnostic codes. +- Do not gate solely on CLI semver. Runtime output shape is the authoritative + compatibility signal; CI's pinned CLI provides the supported baseline. + +## Trade-offs + +- Validators intentionally cover consumed fields rather than every CLI field. +- New caller dependencies on optional output require updating the matching validator. + +## Architecture + +Validation remains in `packages/core` alongside CLI execution. No delivery or +protocol ownership changes, so no ADR is required. \ No newline at end of file diff --git a/openspec/changes/openspec-cli-contract-validation/proposal.md b/openspec/changes/openspec-cli-contract-validation/proposal.md new file mode 100644 index 00000000..f9456e33 --- /dev/null +++ b/openspec/changes/openspec-cli-contract-validation/proposal.md @@ -0,0 +1,19 @@ +## Why + +The core wrapper currently casts parsed JSON directly to TypeScript interfaces. +Valid JSON with a changed schema can therefore reach adapters and fail later as +missing fields, blank UI state, or misleading command results. + +## What Changes + +- Validate each supported OpenSpec JSON command at the core boundary. +- Add a typed compatibility error with command and expected contract metadata. +- Preserve a bounded output preview for diagnostics without exposing unlimited CLI output. +- Add malformed and forward-compatible fixture tests beside real CLI fixtures. +- Exercise all wrappers against the repository's pinned OpenSpec CLI. + +## Impact + +- `packages/core`: runtime contract validators and typed diagnostics. +- `packages/server` and `packages/extension`: receive actionable core errors through existing paths. +- CI remains pinned to OpenSpec CLI 1.7.0 as the verified contract baseline. \ No newline at end of file diff --git a/openspec/changes/openspec-cli-contract-validation/specs/execution-core/spec.md b/openspec/changes/openspec-cli-contract-validation/specs/execution-core/spec.md new file mode 100644 index 00000000..07bc60af --- /dev/null +++ b/openspec/changes/openspec-cli-contract-validation/specs/execution-core/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: OpenSpec CLI JSON is validated at the core boundary +The system SHALL validate fields consumed by delivery adapters before returning +structured OpenSpec CLI results. + +#### Scenario: CLI returns valid compatible output with additive fields +- **WHEN** a supported command returns all required fields plus unknown fields +- **THEN** core returns the result without discarding the additive fields + +#### Scenario: CLI returns valid JSON with an incompatible shape +- **WHEN** a required consumed field is missing or has an incompatible type +- **THEN** core rejects the result with a typed compatibility diagnostic +- **AND** identifies the command and expected contract + +#### Scenario: CLI returns malformed JSON +- **WHEN** a JSON command exits successfully but stdout is not valid JSON +- **THEN** core rejects the result with a typed invalid-JSON diagnostic + +#### Scenario: Diagnostic includes CLI output +- **WHEN** core reports an output compatibility failure +- **THEN** the diagnostic includes only a bounded output preview +- **AND** does not expose unbounded child-process output \ No newline at end of file diff --git a/openspec/changes/openspec-cli-contract-validation/tasks.md b/openspec/changes/openspec-cli-contract-validation/tasks.md new file mode 100644 index 00000000..ffdc85fe --- /dev/null +++ b/openspec/changes/openspec-cli-contract-validation/tasks.md @@ -0,0 +1,17 @@ +## 1. Core Contract Validation + +- [x] 1.1 Add typed invalid-JSON and incompatible-shape diagnostics. +- [x] 1.2 Validate required consumed fields for each JSON wrapper. +- [x] 1.3 Allow unknown additive fields and bound diagnostic output previews. + +## 2. Tests and Delivery Behavior + +- [x] 2.1 Add malformed JSON and incompatible shape tests for wrapper families. +- [x] 2.2 Confirm valid fixtures with additive fields remain accepted. +- [x] 2.3 Confirm server and extension existing error paths expose compatibility guidance. + +## 3. Verification + +- [x] 3.1 Bump affected package versions and update release documentation. +- [x] 3.2 Run focused tests, workspace verify/build, and strict OpenSpec validation. +- [x] 3.3 Run all JSON wrappers against pinned OpenSpec CLI 1.7.0 in this repository. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d5462949..671748f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10773,7 +10773,7 @@ }, "packages/core": { "name": "@openspec-ui/core", - "version": "0.10.0", + "version": "0.11.0", "dependencies": { "cross-spawn": "^7.0.6", "simple-git": "^3.27.0" @@ -10785,7 +10785,7 @@ }, "packages/extension": { "name": "openspec-ui-vscode", - "version": "0.4.2", + "version": "0.4.3", "license": "MIT", "dependencies": { "@openspec-ui/core": "*", @@ -11240,7 +11240,7 @@ }, "packages/server": { "name": "@openspec-ui/server", - "version": "1.2.1", + "version": "1.2.2", "dependencies": { "@openspec-ui/core": "*", "ws": "^8.21.0" diff --git a/packages/core/package.json b/packages/core/package.json index 0f8605bb..de2e1805 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/core", "private": true, - "version": "0.10.0", + "version": "0.11.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/src/openspec.test.ts b/packages/core/src/openspec.test.ts index ba6703e7..fd2e48b1 100644 --- a/packages/core/src/openspec.test.ts +++ b/packages/core/src/openspec.test.ts @@ -16,7 +16,17 @@ vi.mock("cross-spawn", () => ({ default: (...args: unknown[]) => spawnMock(...args), })); -const { archiveChange, createChange, initOpenSpec, listChanges, listSpecs, showChange, statusChange, validateChange } = await import("./openspec.js"); +const { + OpenSpecCliCompatibilityError, + archiveChange, + createChange, + initOpenSpec, + listChanges, + listSpecs, + showChange, + statusChange, + validateChange, +} = await import("./openspec.js"); /** Настраивает `spawnMock` на возврат фейкового процесса, который сразу же * (в следующем тике) отдаёт заданный stdout и завершается кодом 0. */ @@ -116,6 +126,24 @@ describe("openspec CLI wrapper (real CLI fixtures — task 5.3)", () => { expect(result.artifacts.length).toBeGreaterThan(0); }); + it("normalizes current status output without progress from artifact completion", async () => { + mockSuccessfulSpawn(JSON.stringify({ + changeName: "current-contract", + schemaName: "spec-driven", + artifacts: [ + { id: "proposal", outputPath: "proposal.md", status: "done", requires: [] }, + { id: "tasks", outputPath: "tasks.md", status: "blocked", requires: ["proposal"] }, + ], + root: { path: "/repo", source: "nearest" }, + additiveField: true, + })); + + const result = await statusChange("current-contract", { cwd: "/repo" }); + + expect(result.progress).toEqual({ total: 2, complete: 1, remaining: 1 }); + expect(result.additiveField).toBe(true); + }); + it("createChange calls `openspec new change --json` with optional metadata", async () => { mockSuccessfulSpawn('{"ok":true}'); @@ -183,6 +211,61 @@ describe("openspec CLI wrapper (real CLI fixtures — task 5.3)", () => { }); }); + it("rejects valid JSON with an incompatible command shape", async () => { + mockSuccessfulSpawn('{"changes":"not-an-array","root":{"path":"x","source":"nearest"}}'); + + const error = await listChanges({ cwd: "/repo" }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(OpenSpecCliCompatibilityError); + expect(error).toMatchObject({ + code: "incompatible-output", + command: "list --json", + expectedContract: "changes[] and root", + }); + }); + + it("rejects malformed JSON with a bounded output preview", async () => { + mockSuccessfulSpawn(`not-json-${"x".repeat(800)}`); + + const error = await listChanges({ cwd: "/repo" }).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ code: "invalid-json", command: "list --json" }); + if (!(error instanceof OpenSpecCliCompatibilityError)) throw new Error("Expected compatibility error"); + expect(error.outputPreview.length).toBeLessThanOrEqual(512); + }); + + it("accepts compatible output with additive unknown fields", async () => { + mockSuccessfulSpawn(JSON.stringify({ + changes: [], + root: { path: "x", source: "nearest", futureRootField: true }, + futureTopLevelField: { enabled: true }, + })); + + const result = await listChanges({ cwd: "/repo" }); + + expect(result).toMatchObject({ futureTopLevelField: { enabled: true } }); + }); + + it("rejects incompatible output across every JSON wrapper family", async () => { + const cases: Array<{ output: string; invoke: () => Promise; command: string }> = [ + { output: '{"specs":{}}', invoke: () => listSpecs({ cwd: "/repo" }), command: "list --specs --json" }, + { output: '{"id":"x","title":"x","deltaCount":0,"deltas":{}}', invoke: () => showChange("x", { cwd: "/repo" }), command: "show x --json --type change" }, + { output: '{"items":[],"summary":{}}', invoke: () => validateChange("x", { cwd: "/repo" }), command: "validate x --json --strict --type change" }, + { output: '{"changeName":"x","schemaName":"x","progress":{},"artifacts":[]}', invoke: () => statusChange("x", { cwd: "/repo" }), command: "status --change x --json" }, + { output: '[]', invoke: () => createChange("x", { cwd: "/repo" }), command: "new change x --json" }, + { output: '[]', invoke: () => archiveChange("x", { cwd: "/repo" }), command: "archive x --yes --json" }, + ]; + + for (const contractCase of cases) { + mockSuccessfulSpawn(contractCase.output); + const error = await contractCase.invoke().catch((caught: unknown) => caught); + expect(error).toMatchObject({ + code: "incompatible-output", + command: contractCase.command, + }); + } + }); + it("rejects when the process exits with a non-zero code", async () => { const child = new FakeChildProcess(); spawnMock.mockReturnValueOnce(child); diff --git a/packages/core/src/openspec.ts b/packages/core/src/openspec.ts index acf0b2b4..f7080501 100644 --- a/packages/core/src/openspec.ts +++ b/packages/core/src/openspec.ts @@ -153,6 +153,18 @@ export interface OpenSpecStatusResult { [key: string]: unknown; } +interface RawOpenSpecStatusResult { + changeName: string; + schemaName: string; + progress?: OpenSpecStatusProgress; + artifacts: OpenSpecStatusArtifact[]; + tasks?: OpenSpecStatusTask[]; + state?: string; + instruction?: string; + root: OpenSpecRoot; + [key: string]: unknown; +} + export interface CreateChangeOptions { description?: string; goal?: string; @@ -179,36 +191,247 @@ export interface ArchiveChangeOptions { skipSpecs?: boolean; } -async function runJson(args: string[], options: OpenSpecCliOptions): Promise { +export type OpenSpecCliCompatibilityErrorCode = "invalid-json" | "incompatible-output"; + +export class OpenSpecCliCompatibilityError extends Error { + constructor( + readonly code: OpenSpecCliCompatibilityErrorCode, + readonly command: string, + readonly expectedContract: string, + readonly outputPreview: string, + options: { cause?: unknown } = {}, + ) { + super( + `OpenSpec CLI returned ${code === "invalid-json" ? "invalid JSON" : "incompatible JSON"} for '${command}'. ` + + `Expected ${expectedContract}. Update OpenSpec CLI or OpenSpec UI to compatible versions.`, + { cause: options.cause }, + ); + this.name = "OpenSpecCliCompatibilityError"; + } +} + +type JsonValidator = (value: unknown) => value is T; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasString(record: Record, key: string): boolean { + return typeof record[key] === "string"; +} + +function hasNumber(record: Record, key: string): boolean { + return typeof record[key] === "number" && Number.isFinite(record[key]); +} + +function hasBoolean(record: Record, key: string): boolean { + return typeof record[key] === "boolean"; +} + +function isRoot(value: unknown): value is OpenSpecRoot { + return isRecord(value) && hasString(value, "path") && hasString(value, "source"); +} + +function isChangeListItem(value: unknown): value is OpenSpecChangeListItem { + return isRecord(value) + && hasString(value, "name") + && hasNumber(value, "completedTasks") + && hasNumber(value, "totalTasks") + && hasString(value, "lastModified") + && hasString(value, "status"); +} + +function isListResult(value: unknown): value is OpenSpecListResult { + return isRecord(value) + && Array.isArray(value.changes) + && value.changes.every(isChangeListItem) + && isRoot(value.root); +} + +function isSpecListItem(value: unknown): value is OpenSpecSpecListItem { + return isRecord(value) && hasString(value, "id") && hasNumber(value, "requirementCount"); +} + +function isListSpecsResult(value: unknown): value is OpenSpecListSpecsResult { + return isRecord(value) + && Array.isArray(value.specs) + && value.specs.every(isSpecListItem) + && isRoot(value.root); +} + +function isScenario(value: unknown): value is OpenSpecScenario { + return isRecord(value) && hasString(value, "rawText"); +} + +function isRequirement(value: unknown): value is OpenSpecRequirement { + return isRecord(value) + && hasString(value, "text") + && Array.isArray(value.scenarios) + && value.scenarios.every(isScenario); +} + +function isDelta(value: unknown): value is OpenSpecDelta { + if (!isRecord(value) + || !hasString(value, "spec") + || !hasString(value, "operation") + || !hasString(value, "description")) return false; + if (value.requirement !== undefined && !isRequirement(value.requirement)) return false; + return value.requirements === undefined + || (Array.isArray(value.requirements) && value.requirements.every(isRequirement)); +} + +function isShowResult(value: unknown): value is OpenSpecShowResult { + return isRecord(value) + && hasString(value, "id") + && hasString(value, "title") + && hasNumber(value, "deltaCount") + && Array.isArray(value.deltas) + && value.deltas.every(isDelta); +} + +function isTotals(value: unknown): boolean { + return isRecord(value) && hasNumber(value, "items") && hasNumber(value, "passed") && hasNumber(value, "failed"); +} + +function isValidationItem(value: unknown): value is OpenSpecValidationItem { + return isRecord(value) + && hasString(value, "id") + && hasString(value, "type") + && hasBoolean(value, "valid") + && Array.isArray(value.issues) + && value.issues.every((issue) => isRecord(issue) && hasString(issue, "message")) + && hasNumber(value, "durationMs"); +} + +function isValidateResult(value: unknown): value is OpenSpecValidateResult { + if (!isRecord(value) + || !Array.isArray(value.items) + || !value.items.every(isValidationItem) + || !isRecord(value.summary) + || !isTotals(value.summary.totals) + || !isRecord(value.summary.byType) + || !Object.values(value.summary.byType).every(isTotals) + || !hasString(value, "version")) return false; + return isRoot(value.root); +} + +function isStatusArtifact(value: unknown): value is OpenSpecStatusArtifact { + return isRecord(value) + && hasString(value, "id") + && hasString(value, "outputPath") + && hasString(value, "status") + && Array.isArray(value.requires) + && value.requires.every((item) => typeof item === "string") + && (value.missingDeps === undefined + || (Array.isArray(value.missingDeps) && value.missingDeps.every((item) => typeof item === "string"))); +} + +function isStatusResult(value: unknown): value is RawOpenSpecStatusResult { + if (!isRecord(value) + || !hasString(value, "changeName") + || !hasString(value, "schemaName") + || !Array.isArray(value.artifacts) + || !value.artifacts.every(isStatusArtifact) + || !isRoot(value.root)) return false; + if (value.progress !== undefined && ( + !isRecord(value.progress) + || !hasNumber(value.progress, "total") + || !hasNumber(value.progress, "complete") + || !hasNumber(value.progress, "remaining") + )) return false; + if (value.tasks !== undefined && (!Array.isArray(value.tasks) || !value.tasks.every((task) => ( + isRecord(task) && hasString(task, "id") && hasString(task, "description") && hasBoolean(task, "done") + )))) return false; + return (value.state === undefined || typeof value.state === "string") + && (value.instruction === undefined || typeof value.instruction === "string"); +} + +function normalizeStatusResult(value: RawOpenSpecStatusResult): OpenSpecStatusResult { + if (value.progress) return { ...value, progress: value.progress }; + const complete = value.artifacts.filter((artifact) => artifact.status === "done").length; + const total = value.artifacts.length; + return { + ...value, + progress: { total, complete, remaining: total - complete }, + }; +} + +const isObjectResult = (value: unknown): value is Record => isRecord(value); + +function outputPreview(stdout: string): string { + const normalized = stdout.trim().replace(/\s+/g, " "); + return normalized.length <= 512 ? normalized : `${normalized.slice(0, 509)}...`; +} + +async function runJson( + args: string[], + options: OpenSpecCliOptions, + expectedContract: string, + validator: JsonValidator, +): Promise { const binary = options.binary ?? "openspec"; const { stdout } = await execFileAsync(binary, args, { cwd: options.cwd, windowsHide: true }); - return JSON.parse(stdout) as T; + const command = args.join(" "); + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch (error) { + throw new OpenSpecCliCompatibilityError( + "invalid-json", + command, + expectedContract, + outputPreview(stdout), + { cause: error }, + ); + } + if (!validator(parsed)) { + throw new OpenSpecCliCompatibilityError( + "incompatible-output", + command, + expectedContract, + outputPreview(stdout), + ); + } + return parsed; } export async function listChanges(options: OpenSpecCliOptions): Promise { - return runJson(["list", "--json"], options); + return runJson(["list", "--json"], options, "changes[] and root", isListResult); } export async function listSpecs(options: OpenSpecCliOptions): Promise { - return runJson(["list", "--specs", "--json"], options); + return runJson(["list", "--specs", "--json"], options, "specs[] and root", isListSpecsResult); } export async function showChange(changeName: string, options: OpenSpecCliOptions): Promise { - return runJson(["show", changeName, "--json", "--type", "change"], options); + return runJson( + ["show", changeName, "--json", "--type", "change"], + options, + "change id, title, deltaCount, and deltas[]", + isShowResult, + ); } export async function validateChange( changeName: string, options: OpenSpecCliOptions, ): Promise { - return runJson( + return runJson( ["validate", changeName, "--json", "--strict", "--type", "change"], options, + "validation items, summary, version, and root", + isValidateResult, ); } export async function statusChange(changeName: string, options: OpenSpecCliOptions): Promise { - return runJson(["status", "--change", changeName, "--json"], options); + const status = await runJson( + ["status", "--change", changeName, "--json"], + options, + "changeName, schemaName, artifacts[], root, and optional progress", + isStatusResult, + ); + return normalizeStatusResult(status); } export async function createChange( @@ -223,7 +446,7 @@ export async function createChange( if (createOptions.goal) { args.push("--goal", createOptions.goal); } - return runJson(args, options); + return runJson(args, options, "a JSON object", isObjectResult); } export async function archiveChange( @@ -233,7 +456,7 @@ export async function archiveChange( ): Promise { const args = ["archive", changeName, "--yes", "--json"]; if (archiveOptions.skipSpecs) args.push("--skip-specs"); - return runJson(args, options); + return runJson(args, options, "a JSON object", isObjectResult); } export async function initOpenSpec( diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index 2236a8e6..94246eee 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.4.3 + +- Added actionable compatibility diagnostics when OpenSpec CLI JSON output no + longer matches fields consumed by the workbench. + ## 0.4.2 - Added actionable, fail-closed diagnostics when persisted run journals or diff --git a/packages/extension/package.json b/packages/extension/package.json index 6b68f663..57bef1be 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -3,7 +3,7 @@ "displayName": "OpenSpec Workbench", "description": "Native OpenSpec workflow with lifecycle controls, VS Code Chat, persistent processes, checkpoints, and conflict-safe rollback.", "publisher": "openspec-ui", - "version": "0.4.2", + "version": "0.4.3", "icon": "media/icon.png", "license": "MIT", "repository": { diff --git a/packages/server/package.json b/packages/server/package.json index 80e9be43..cecc43f0 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/server", "private": true, - "version": "1.2.1", + "version": "1.2.2", "type": "module", "main": "src/index.ts", "dependencies": { diff --git a/packages/server/src/server.test.ts b/packages/server/src/server.test.ts index 7fade811..13f80042 100644 --- a/packages/server/src/server.test.ts +++ b/packages/server/src/server.test.ts @@ -9,11 +9,18 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import WebSocket from "ws"; -import { WorkbenchRunJournal, type AgentRunner, type Command, type Event } from "@openspec-ui/core"; +import { + OpenSpecCliCompatibilityError, + WorkbenchRunJournal, + type AgentRunner, + type Command, + type Event, +} from "@openspec-ui/core"; import { createServer, type OpenSpecUiServer } from "./server.js"; const statusChangeMock = vi.fn(); const listChangesMock = vi.fn(); +const listSpecsMock = vi.fn(); const initOpenSpecMock = vi.fn(); vi.mock("@openspec-ui/core", async () => { const actual = await vi.importActual("@openspec-ui/core"); @@ -21,6 +28,7 @@ vi.mock("@openspec-ui/core", async () => { ...actual, statusChange: (...args: unknown[]) => statusChangeMock(...args), listChanges: (...args: unknown[]) => listChangesMock(...args), + listSpecs: (...args: unknown[]) => listSpecsMock(...args), initOpenSpec: (...args: unknown[]) => initOpenSpecMock(...args), }; }); @@ -92,6 +100,7 @@ async function startServer(runners: Map) { afterEach(async () => { statusChangeMock.mockReset(); listChangesMock.mockReset(); + listSpecsMock.mockReset(); initOpenSpecMock.mockReset(); await server?.close(); await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); @@ -218,6 +227,30 @@ describe("server — REST /api/status", () => { }); }); + it("returns actionable guidance for incompatible OpenSpec CLI JSON", async () => { + const cwd = await createTempWorkspace(); + await mkdir(path.join(cwd, "openspec"), { recursive: true }); + await writeFile(path.join(cwd, "openspec", "config.yaml"), "schema: spec-driven\n", "utf8"); + listChangesMock.mockRejectedValueOnce(new OpenSpecCliCompatibilityError( + "incompatible-output", + "list --json", + "changes[] and root", + '{"changes":{}}', + )); + listSpecsMock.mockResolvedValueOnce({ specs: [], root: { path: cwd, source: "nearest" } }); + + const response = await fetch(`${baseUrl}/api/overview`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ cwd }), + }); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: expect.stringContaining("Update OpenSpec CLI or OpenSpec UI to compatible versions"), + }); + }); + it("initializes OpenSpec when tools are provided", async () => { const cwd = await createTempWorkspace(); initOpenSpecMock.mockImplementationOnce(async () => {