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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-08
3 changes: 3 additions & 0 deletions openspec/changes/openspec-cli-contract-validation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# OpenSpec CLI Contract Validation

This change validates structured OpenSpec CLI output before it reaches delivery adapters.
30 changes: 30 additions & 0 deletions openspec/changes/openspec-cli-contract-validation/design.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions openspec/changes/openspec-cli-contract-validation/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions openspec/changes/openspec-cli-contract-validation/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
85 changes: 84 additions & 1 deletion packages/core/src/openspec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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}');

Expand Down Expand Up @@ -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<unknown>; 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);
Expand Down
Loading
Loading