mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-02 05:24:34 +08:00
feat(status): add --all for batch status of every active change (#1301)
* feat(status): add --all for batch status of every active change
`openspec status --all --json` reports every active change in one
process instead of one CLI spawn (~500ms module-load) per change,
mirroring the existing `validate --all`. Emits a single
`{ changes: [ChangeStatus, ...], root }` envelope sorted by change
name; a change that fails to load contributes a per-change error entry
instead of failing the sweep. `--all` and `--change` are mutually
exclusive, honoring the --json null-shape on failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(status): harden --all per adversarial review findings
- Validate --schema before the no-changes early return so a bogus
schema fails consistently whether or not any change exists.
- Text mode now exits 1 when any change fails to load (mirrors
validate --all); JSON mode still exits 0 with per-change diagnostics.
- Add tests for the --all --schema interaction (unknown schema
null-shape, override propagation, broken-metadata precedence) and
text-mode failure rendering.
- Changeset heading to "### New Features" per repo convention; add
status --all to the agent quick-reference table in docs/cli.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(status): thread batch null-shape through root resolution, align sort with validate
Code-review findings on --all:
- Pass failurePayload: { changes: [] } to resolveRootForCommand so a
root-selection failure under --all --json still emits the documented
batch null-shape (siblings like list/doctor/context already do this).
- Sort with localeCompare to match validate --all's ordering for
mixed-case change names.
- Extract a shared loadStatus helper so the batch and single-change
payloads cannot drift apart.
- Changeset no longer claims exact validate --all parity (JSON exit
semantics deliberately differ).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(status): give the --all failure null-shape a single owner
Simplify pass on the --all diff: hoist the { changes: [] } batch
null-shape into an exported BATCH_STATUS_FAILURE_PAYLOAD constant so
the root-resolution and CLI-wrapper failure paths cannot drift, replace
the conditional spread with the plain ternary the sibling call site
already uses, drop a redundant array copy before sort, and narrow the
text-mode failure counter to the boolean it actually is.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(status): point the missing-target error at --all, correct the docs
`openspec status` with neither --change nor --all listed the available
changes and named only --change, so the batch path was discoverable
only from --help. The error now offers both.
Also corrects two stale claims in the status section of docs/cli.md
that the new row sits next to: the command never prompts for a change
(it errors), and bare `openspec status` is not an interactive check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(status): assert --all carries store context like the single-change path
The batch sweep resolves the root once and threads the store id into
every entry. Nothing pinned that: a regression would have shown up only
as a wrong path inside an agent's JSON. Assert the sweep's envelope root
and per-change payload match `status --change` in a registered store.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(status): fail incomplete batch reports
* docs(status): clarify empty and batch output
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Clay Good <hi@claygood.com>
This commit is contained in:
co-authored by
Claude Opus 5
Clay Good
parent
c0c50f9a4c
commit
a7353aea9a
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@fission-ai/openspec": minor
|
||||
---
|
||||
|
||||
Add `openspec status --all`, which reports every active change in one process instead of one CLI spawn per change. `--all --json` emits a single `{ "changes": [ <status>, ... ], "root" }` envelope sorted by change name; a change that fails to load contributes `{ "changeName", "status": [diagnostic] }` in place rather than aborting the sweep. A partial failure exits 1 in both text and JSON modes while preserving the complete JSON envelope. Mutually exclusive with `--change`.
|
||||
@@ -31,7 +31,7 @@ Your agent runs most of these during the workflow.
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| [`openspec new`](#openspec-new) | Create a new change directory. |
|
||||
| [`openspec status`](#openspec-status) | Artifact completion status for a change. |
|
||||
| [`openspec status`](#openspec-status) | Artifact completion status for one or every active change. |
|
||||
| [`openspec instructions`](#openspec-instructions) | Instructions for creating an artifact, applying, or archiving. |
|
||||
| [`openspec templates`](#openspec-templates) | Resolved template paths for a schema's artifacts. |
|
||||
| [`openspec schemas`](#openspec-schemas) | List available workflow schemas. |
|
||||
@@ -866,25 +866,30 @@ With `--json`:
|
||||
|
||||
## openspec status
|
||||
|
||||
Reports artifact completion status for a change.
|
||||
Reports artifact completion status for one change or every active change.
|
||||
|
||||
```bash
|
||||
openspec status --change add-rate-limit # checklist view
|
||||
openspec status --change add-rate-limit --json # structured report
|
||||
openspec status --all # every active change
|
||||
openspec status --all --json # one batch report
|
||||
```
|
||||
|
||||
`--change` is required. Without it, status exits 1 and lists the available changes, even when only one exists:
|
||||
When active changes exist, use exactly one of `--change` or `--all`. Without either, status exits 1 and lists the available changes, even when only one exists:
|
||||
|
||||
```
|
||||
✖ Error: Missing required option --change. Available changes:
|
||||
```text
|
||||
✖ Error: Missing required option --change (or --all for every active change). Available changes:
|
||||
add-rate-limit
|
||||
```
|
||||
|
||||
When the project has no active changes, status prints `No active changes. Create one with: openspec new change <name>` and exits 0 even without either flag. With `--all --json`, the same empty state is `{ "changes": [], "message": "No active changes.", "root": ... }`.
|
||||
|
||||
**Options**
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `--change <id>` | The change to report on, by folder name. |
|
||||
| `--all` | Report every active change, sorted by name. Can't be combined with `--change`. |
|
||||
| `--schema <name>` | Override the schema auto-detected from `openspec/config.yaml`. An unknown name is an error. |
|
||||
| `--json` | Print a structured report instead of text. |
|
||||
| `--store <id>` | Use a registered store as the OpenSpec root instead of the current project. |
|
||||
@@ -946,10 +951,30 @@ Progress: 2/4 artifacts complete
|
||||
}
|
||||
```
|
||||
|
||||
With `--all --json`, `changes` contains the same status object for each change, without a per-change `root`. The selected root appears once on the envelope. This example trims the per-change status fields shown above:
|
||||
|
||||
```json
|
||||
{
|
||||
"changes": [
|
||||
{
|
||||
"changeName": "add-rate-limit",
|
||||
"schemaName": "spec-driven",
|
||||
"artifacts": []
|
||||
}
|
||||
],
|
||||
"root": {
|
||||
"path": "/Users/you/projects/my-app",
|
||||
"source": "nearest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If one change can't load, the batch continues. Its entry contains `changeName` and a `status` diagnostic while the other entries remain available. The command exits 1, including in JSON mode, so CI doesn't accept an incomplete report as successful. JSON output remains one parseable document.
|
||||
|
||||
**Exit codes**
|
||||
|
||||
- `0`: status printed.
|
||||
- `1`: `--change` missing, the change doesn't exist, or the schema override is unknown.
|
||||
- `0`: every requested status printed; an empty `--all` report also exits 0.
|
||||
- `1`: a requested change failed to load, `--change` or `--all` is missing, the two flags were combined, the change doesn't exist, or the schema override is unknown.
|
||||
|
||||
## openspec instructions
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ Change: `{ "id", "title", "deltaCount", "deltas": [...], "root" }`. Spec: `{ "id
|
||||
### 4.4 `status --json`
|
||||
`{ "changeName", "schemaName", "planningHome"?: { "kind", "root", "changesDir", "defaultSchema" }, "changeRoot", "artifactPaths": { "<id>": {outputPath, resolvedOutputPath, existingOutputPaths} }, "nextSteps": ["..."], "actionContext": { "mode": "repo-local", "sourceOfTruth": "repo", "planningArtifacts", "linkedContext", "allowedEditRoots", "requiresAffectedAreaSelection", "constraints" }, "isPlanningComplete", "isComplete", "applyRequires", "artifacts": [ {id, outputPath, status: "done"|"skipped"|"ready"|"blocked", requires, missingDeps?} ], "root" }`. `isPlanningComplete` means every non-skipped planning artifact exists; skipped artifacts count as satisfied without being created. It does not mean implementation tasks are complete. `isComplete` is retained as a compatibility alias with the same value. Each artifact's `requires` is its direct dependency ids (present for every status, so the transitive required set is computable even when the artifact is `done`); `missingDeps` appears only when `blocked`. The `artifacts` array is in dependency order, with the schema's `artifacts:` declaration order breaking ties between artifacts that become ready at the same time (never alphabetical), so the first `ready` entry is the artifact to write next; `missingDeps` uses that same order. `"skipped"` marks an artifact whose `generates` path is under `specs/` in a change whose `.openspec.yaml` declares `skip_specs: true`; it satisfies dependencies but must not be created. No active changes: `{ "changes": [], "message", "root" }`, exit 0.
|
||||
|
||||
`--all` (batch, mutually exclusive with `--change` — combining them is an error with the `{ "changes": [], "root": null, "status": [d] }` null-shape): `{ "changes": [ <per-change status object, no per-change root>, ... ], "root" }`, sorted by change name. A change that fails to load contributes `{ "changeName", "status": [d] }` in place; the sweep continues, preserves the complete envelope, and exits 1 in both text and JSON modes. An invalid `--schema` fails the whole invocation with the null-shape, even when no changes exist.
|
||||
|
||||
### 4.5 `instructions <artifact> --json`
|
||||
`{ "changeName", "artifactId", "schemaName", "changeDir", "planningHome"?, "outputPath", "resolvedOutputPath", "existingOutputPaths", "description", "instruction"?, "context"?, "rules"?, "references"?: ReferenceIndexEntry[], "skipped"?, "warning"?, "template", "dependencies": [{id,done,path,description,skipped?}], "unlocks", "root" }`. `unlocks` lists the artifacts this one makes ready, in the schema's declaration order (the same order `status` recommends them). `"skipped": true` (with `"warning"`) appears when the change declares `skip_specs: true` and this artifact is skipped — do not create its files. A dependency entry with `skipped: true` is satisfied without files — do not try to read its paths.
|
||||
|
||||
|
||||
+9
-1
@@ -35,6 +35,7 @@ import { registerContextCommand } from '../commands/context.js';
|
||||
import { registerWorksetCommand } from '../commands/workset.js';
|
||||
import {
|
||||
statusCommand,
|
||||
BATCH_STATUS_FAILURE_PAYLOAD,
|
||||
instructionsCommand,
|
||||
applyInstructionsCommand,
|
||||
archiveInstructionsCommand,
|
||||
@@ -640,6 +641,7 @@ program
|
||||
.command('status')
|
||||
.description('Display artifact completion status for a change')
|
||||
.option('--change <id>', 'Change name to show status for')
|
||||
.option('--all', 'Show status for all active changes')
|
||||
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--store <id>', STORE_OPTION_DESCRIPTION)
|
||||
@@ -648,7 +650,13 @@ program
|
||||
try {
|
||||
await statusCommand(options);
|
||||
} catch (error) {
|
||||
failWithError(error, { enabled: options.json, fallbackCode: 'change_error' });
|
||||
failWithError(error, {
|
||||
enabled: options.json,
|
||||
// The batch null-shape; the single-change failure shape is
|
||||
// pre-existing contract and stays payload-free.
|
||||
payload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined,
|
||||
fallbackCode: 'change_error',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Commands for the artifact-driven workflow: status, instructions, templates, schemas, new change.
|
||||
*/
|
||||
|
||||
export { statusCommand } from './status.js';
|
||||
export { statusCommand, BATCH_STATUS_FAILURE_PAYLOAD } from './status.js';
|
||||
export type { StatusOptions } from './status.js';
|
||||
|
||||
export {
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
formatChangeStatus,
|
||||
type ChangeStatus,
|
||||
} from '../../core/artifact-graph/index.js';
|
||||
import { asStatus } from '../shared-output.js';
|
||||
import type { StoreDiagnostic } from '../../core/store/errors.js';
|
||||
import {
|
||||
validateChangeExists,
|
||||
validateSchemaExists,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
|
||||
export interface StatusOptions {
|
||||
change?: string;
|
||||
all?: boolean;
|
||||
schema?: string;
|
||||
store?: string;
|
||||
storePath?: string;
|
||||
@@ -43,10 +46,30 @@ export interface StatusOptions {
|
||||
// Command Implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// A batch entry is either a fully loaded status or, for a change that failed
|
||||
// to load, the change name plus the diagnostic — the sweep never aborts.
|
||||
type BatchStatusEntry = ChangeStatus | { changeName: string; status: StoreDiagnostic[] };
|
||||
|
||||
// The --all --json failure null-shape. Root-selection failures (handled in
|
||||
// resolveRootForCommand) and thrown errors (caught by the CLI wrapper) must
|
||||
// emit the same shape, so both call sites reference this one constant.
|
||||
export const BATCH_STATUS_FAILURE_PAYLOAD: Record<string, unknown> = {
|
||||
changes: [],
|
||||
root: null,
|
||||
};
|
||||
|
||||
export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
if (options.all && options.change) {
|
||||
throw new Error('The --all and --change options are mutually exclusive.');
|
||||
}
|
||||
|
||||
// The root resolves (and the store banner prints) before the spinner starts
|
||||
// so the two do not fight over stderr.
|
||||
const root = await resolveRootForCommand(options, { json: options.json });
|
||||
// so the two do not fight over stderr. The batch null-shape rides along so
|
||||
// a root-selection failure under --all --json still carries `changes: []`.
|
||||
const root = await resolveRootForCommand(options, {
|
||||
json: options.json,
|
||||
failurePayload: options.all ? BATCH_STATUS_FAILURE_PAYLOAD : undefined,
|
||||
});
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
@@ -59,9 +82,26 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
const rootOutput = toRootOutput(root);
|
||||
const newChangeHint = withStoreFlag(root, 'openspec new change <name>');
|
||||
|
||||
// Single definition of "load one change's status" so the batch and
|
||||
// single-change payloads can never drift apart.
|
||||
const loadStatus = (changeName: string): ChangeStatus =>
|
||||
formatChangeStatus(
|
||||
loadChangeContext(projectRoot, changeName, options.schema, {
|
||||
changeDir: getChangeDir(planningHome, changeName),
|
||||
planningHome,
|
||||
}),
|
||||
isStoreSelectedRoot(root) ? { storeId: root.storeId } : {}
|
||||
);
|
||||
|
||||
// Handle no-changes case gracefully — status is informational,
|
||||
// so "no changes" is a valid state, not an error.
|
||||
if (!options.change) {
|
||||
// Validate before the no-changes early return so a bogus --schema
|
||||
// fails the same way whether or not any change exists yet.
|
||||
if (options.all && options.schema) {
|
||||
validateSchemaExists(options.schema, projectRoot);
|
||||
}
|
||||
|
||||
const available = await getAvailableChanges(projectRoot, root.changesDir);
|
||||
if (available.length === 0) {
|
||||
spinner?.stop();
|
||||
@@ -78,10 +118,58 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
console.log(`No active changes. Create one with: ${newChangeHint}`);
|
||||
return;
|
||||
}
|
||||
// Changes exist but --change not provided
|
||||
|
||||
if (options.all) {
|
||||
// readdir order is platform-dependent; sort for deterministic output,
|
||||
// with the same comparator validate --all uses so the two batch
|
||||
// commands order a given change set identically.
|
||||
const entries: BatchStatusEntry[] = [];
|
||||
for (const changeName of available.sort((a, b) => a.localeCompare(b))) {
|
||||
try {
|
||||
entries.push(loadStatus(changeName));
|
||||
} catch (error) {
|
||||
// One malformed change must not blank the sweep; carry its
|
||||
// diagnostic in place and keep going.
|
||||
entries.push({ changeName, status: [asStatus(error, 'change_error')] });
|
||||
}
|
||||
}
|
||||
|
||||
spinner?.stop();
|
||||
const failed = entries.some((entry) => !('artifacts' in entry));
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ changes: entries, root: rootOutput }, null, 2));
|
||||
if (failed) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
if (index > 0) {
|
||||
console.log();
|
||||
}
|
||||
if ('artifacts' in entry) {
|
||||
printStatusText(entry);
|
||||
} else {
|
||||
console.log(chalk.red(`✗ ${entry.changeName}: ${entry.status[0]?.message}`));
|
||||
}
|
||||
});
|
||||
// A partial load is still a failed command in both output modes;
|
||||
// JSON callers can parse the complete envelope independently of
|
||||
// the process exit code.
|
||||
if (failed) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Changes exist but neither --change nor --all provided. Name --all
|
||||
// here too: it is the other way to answer this prompt, and a caller
|
||||
// who wants every change should not have to find it in --help.
|
||||
spinner?.stop();
|
||||
throw new Error(
|
||||
`Missing required option --change. Available changes:\n ${available.join('\n ')}`
|
||||
`Missing required option --change (or --all for every active change). Available changes:\n ${available.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,14 +186,7 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
|
||||
}
|
||||
|
||||
// loadChangeContext will auto-detect schema from metadata if not provided
|
||||
const context = loadChangeContext(projectRoot, changeName, options.schema, {
|
||||
changeDir: getChangeDir(planningHome, changeName),
|
||||
planningHome,
|
||||
});
|
||||
const status = formatChangeStatus(
|
||||
context,
|
||||
isStoreSelectedRoot(root) ? { storeId: root.storeId } : {}
|
||||
);
|
||||
const status = loadStatus(changeName);
|
||||
|
||||
spinner?.stop();
|
||||
|
||||
|
||||
@@ -190,6 +190,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
|
||||
description: 'Change name to show status for',
|
||||
takesValue: true,
|
||||
},
|
||||
{
|
||||
name: 'all',
|
||||
description: 'Show status for all active changes',
|
||||
},
|
||||
{
|
||||
name: 'schema',
|
||||
description: 'Schema override',
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { runCLI } from '../helpers/run-cli.js';
|
||||
|
||||
describe('status --all', () => {
|
||||
let tempDir: string;
|
||||
let changesDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-status-all-'));
|
||||
changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function getOutput(result: { stdout: string; stderr: string }): string {
|
||||
return result.stdout + result.stderr;
|
||||
}
|
||||
|
||||
async function createTestChange(
|
||||
changeName: string,
|
||||
artifacts: ('design' | 'specs' | 'tasks')[] = []
|
||||
): Promise<string> {
|
||||
const changeDir = path.join(changesDir, changeName);
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
|
||||
// proposal.md marks the change as active
|
||||
await fs.writeFile(
|
||||
path.join(changeDir, 'proposal.md'),
|
||||
'## Why\nMinimal proposal.\n\n## What Changes\n- **test:** Placeholder'
|
||||
);
|
||||
|
||||
if (artifacts.includes('design')) {
|
||||
await fs.writeFile(path.join(changeDir, 'design.md'), '# Design\n\nTechnical design.');
|
||||
}
|
||||
|
||||
if (artifacts.includes('specs')) {
|
||||
const specsDir = path.join(changeDir, 'specs');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'test-spec.md'), '## Purpose\nTest spec.');
|
||||
}
|
||||
|
||||
if (artifacts.includes('tasks')) {
|
||||
await fs.writeFile(path.join(changeDir, 'tasks.md'), '## Tasks\n- [ ] Task 1');
|
||||
}
|
||||
|
||||
return changeDir;
|
||||
}
|
||||
|
||||
it('reports every active change in alphabetical order', async () => {
|
||||
// Created out of order to prove the output sort is not readdir order
|
||||
await createTestChange('zebra-change', ['design']);
|
||||
await createTestChange('alpha-change');
|
||||
await createTestChange('mid-change', ['design', 'specs', 'tasks']);
|
||||
|
||||
const result = await runCLI(['status', '--all', '--json'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changes.map((c: any) => c.changeName)).toEqual([
|
||||
'alpha-change',
|
||||
'mid-change',
|
||||
'zebra-change',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits the empty envelope when no changes exist', async () => {
|
||||
const result = await runCLI(['status', '--all', '--json'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changes).toEqual([]);
|
||||
expect(json.message).toBe('No active changes.');
|
||||
expect(json.root).toBeDefined();
|
||||
});
|
||||
|
||||
it('hoists root to the envelope and carries a full ChangeStatus per change', async () => {
|
||||
await createTestChange('json-change', ['design']);
|
||||
|
||||
const result = await runCLI(['status', '--all', '--json'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.root).toBeDefined();
|
||||
expect(typeof json.root.path).toBe('string');
|
||||
expect(json.changes).toHaveLength(1);
|
||||
|
||||
const entry = json.changes[0];
|
||||
// Full ChangeStatus shape, same as the single-change payload
|
||||
expect(entry.changeName).toBe('json-change');
|
||||
expect(entry.schemaName).toBe('spec-driven');
|
||||
expect(entry.isComplete).toBe(false);
|
||||
expect(Array.isArray(entry.artifacts)).toBe(true);
|
||||
expect(entry.artifacts).toHaveLength(4);
|
||||
expect(Array.isArray(entry.nextSteps)).toBe(true);
|
||||
expect(entry.actionContext).toBeDefined();
|
||||
expect(entry.artifactPaths).toBeDefined();
|
||||
// root lives on the envelope only
|
||||
expect(entry.root).toBeUndefined();
|
||||
|
||||
const designArtifact = entry.artifacts.find((a: any) => a.id === 'design');
|
||||
expect(designArtifact.status).toBe('done');
|
||||
});
|
||||
|
||||
it('rejects --all combined with --change', async () => {
|
||||
await createTestChange('some-change');
|
||||
|
||||
const result = await runCLI(['status', '--all', '--change', 'some-change'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(getOutput(result)).toContain('mutually exclusive');
|
||||
});
|
||||
|
||||
it('offers --all when neither --change nor --all is given', async () => {
|
||||
await createTestChange('some-change');
|
||||
|
||||
const result = await runCLI(['status'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(getOutput(result)).toContain('--all');
|
||||
expect(getOutput(result)).toContain('some-change');
|
||||
});
|
||||
|
||||
it('honors the JSON null-shape when root selection fails under --all', async () => {
|
||||
const result = await runCLI(['status', '--all', '--json', '--store', 'no-such-store'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
|
||||
// The failure document must still carry the batch null-shape.
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changes).toEqual([]);
|
||||
expect(json.root).toBeNull();
|
||||
expect(Array.isArray(json.status)).toBe(true);
|
||||
expect(json.status[0].severity).toBe('error');
|
||||
});
|
||||
|
||||
it('honors the JSON null-shape when --all and --change are combined', async () => {
|
||||
await createTestChange('some-change');
|
||||
|
||||
const result = await runCLI(
|
||||
['status', '--all', '--change', 'some-change', '--json'],
|
||||
{ cwd: tempDir }
|
||||
);
|
||||
expect(result.exitCode).toBe(1);
|
||||
|
||||
// Exactly one JSON document: null-shape plus status array
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changes).toEqual([]);
|
||||
expect(json.root).toBeNull();
|
||||
expect(Array.isArray(json.status)).toBe(true);
|
||||
expect(json.status[0].severity).toBe('error');
|
||||
expect(json.status[0].message).toContain('mutually exclusive');
|
||||
});
|
||||
|
||||
it('keeps sweeping when one change fails to load', async () => {
|
||||
await createTestChange('good-change', ['design']);
|
||||
const brokenDir = await createTestChange('broken-change');
|
||||
// An unknown schema in the metadata makes loadChangeContext throw
|
||||
await fs.writeFile(
|
||||
path.join(brokenDir, '.openspec.yaml'),
|
||||
'schema: no-such-schema\n'
|
||||
);
|
||||
|
||||
const result = await runCLI(['status', '--all', '--json'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changes).toHaveLength(2);
|
||||
expect(json.changes.map((c: any) => c.changeName)).toEqual([
|
||||
'broken-change',
|
||||
'good-change',
|
||||
]);
|
||||
|
||||
const broken = json.changes.find((c: any) => c.changeName === 'broken-change');
|
||||
expect(Array.isArray(broken.status)).toBe(true);
|
||||
expect(broken.status[0].code).toBe('change_error');
|
||||
expect(broken.status[0].severity).toBe('error');
|
||||
expect(broken.artifacts).toBeUndefined();
|
||||
|
||||
const good = json.changes.find((c: any) => c.changeName === 'good-change');
|
||||
expect(good.schemaName).toBe('spec-driven');
|
||||
expect(good.artifacts).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('prints one text block per change with --all', async () => {
|
||||
await createTestChange('first-change');
|
||||
await createTestChange('second-change', ['design']);
|
||||
|
||||
const result = await runCLI(['status', '--all'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Change: first-change');
|
||||
expect(result.stdout).toContain('Change: second-change');
|
||||
expect(result.stdout).toContain('1/4 artifacts complete');
|
||||
expect(result.stdout).toContain('2/4 artifacts complete');
|
||||
});
|
||||
|
||||
it('exits 1 in text mode when a change fails to load, still printing the others', async () => {
|
||||
await createTestChange('good-change', ['design']);
|
||||
const brokenDir = await createTestChange('broken-change');
|
||||
await fs.writeFile(
|
||||
path.join(brokenDir, '.openspec.yaml'),
|
||||
'schema: no-such-schema\n'
|
||||
);
|
||||
|
||||
const result = await runCLI(['status', '--all'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toContain('✗ broken-change:');
|
||||
expect(result.stdout).toContain('Change: good-change');
|
||||
expect(result.stdout).toContain('2/4 artifacts complete');
|
||||
});
|
||||
|
||||
describe('--schema interaction', () => {
|
||||
/** Writes a minimal project-local schema so an override is distinguishable from the default. */
|
||||
async function createProjectSchema(schemaName: string): Promise<void> {
|
||||
const schemaDir = path.join(tempDir, 'openspec', 'schemas', schemaName);
|
||||
await fs.mkdir(schemaDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(schemaDir, 'schema.yaml'),
|
||||
[
|
||||
`name: ${schemaName}`,
|
||||
'version: 1',
|
||||
'description: Minimal test schema',
|
||||
'artifacts:',
|
||||
' - id: proposal',
|
||||
' generates: proposal.md',
|
||||
' description: Proposal document',
|
||||
' template: proposal.md',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
it('fails with the null-shape when --schema names an unknown schema', async () => {
|
||||
await createTestChange('some-change');
|
||||
|
||||
const result = await runCLI(
|
||||
['status', '--all', '--schema', 'no-such-schema', '--json'],
|
||||
{ cwd: tempDir }
|
||||
);
|
||||
expect(result.exitCode).toBe(1);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changes).toEqual([]);
|
||||
expect(json.root).toBeNull();
|
||||
expect(Array.isArray(json.status)).toBe(true);
|
||||
expect(json.status[0].severity).toBe('error');
|
||||
expect(json.status[0].message).toContain("'no-such-schema' not found");
|
||||
});
|
||||
|
||||
it('rejects an unknown --schema even when no changes exist', async () => {
|
||||
const result = await runCLI(
|
||||
['status', '--all', '--schema', 'no-such-schema', '--json'],
|
||||
{ cwd: tempDir }
|
||||
);
|
||||
expect(result.exitCode).toBe(1);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changes).toEqual([]);
|
||||
expect(json.root).toBeNull();
|
||||
expect(json.status[0].message).toContain("'no-such-schema' not found");
|
||||
});
|
||||
|
||||
it('applies a valid --schema override to every change', async () => {
|
||||
await createProjectSchema('mini');
|
||||
await createTestChange('first-change');
|
||||
await createTestChange('second-change');
|
||||
|
||||
const result = await runCLI(['status', '--all', '--schema', 'mini', '--json'], {
|
||||
cwd: tempDir,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.changes).toHaveLength(2);
|
||||
for (const entry of json.changes) {
|
||||
expect(entry.schemaName).toBe('mini');
|
||||
expect(entry.artifacts).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not rescue a change with broken metadata via an explicit --schema', async () => {
|
||||
await createTestChange('good-change');
|
||||
const brokenDir = await createTestChange('broken-change');
|
||||
await fs.writeFile(
|
||||
path.join(brokenDir, '.openspec.yaml'),
|
||||
'schema: no-such-schema\n'
|
||||
);
|
||||
|
||||
const result = await runCLI(
|
||||
['status', '--all', '--schema', 'spec-driven', '--json'],
|
||||
{ cwd: tempDir }
|
||||
);
|
||||
expect(result.exitCode).toBe(1);
|
||||
|
||||
const json = JSON.parse(result.stdout);
|
||||
const broken = json.changes.find((c: any) => c.changeName === 'broken-change');
|
||||
expect(broken.status[0].code).toBe('change_error');
|
||||
|
||||
const good = json.changes.find((c: any) => c.changeName === 'good-change');
|
||||
expect(good.schemaName).toBe('spec-driven');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -229,6 +229,20 @@ describe('store root selection for normal commands', () => {
|
||||
store_id: 'team-context',
|
||||
});
|
||||
|
||||
// The batch sweep must select the same root and carry the same store
|
||||
// context per change as the single-change path above.
|
||||
const batch = await runCLI(['status', '--all', '--store', 'team-context', '--json'], {
|
||||
cwd: appRepo,
|
||||
env,
|
||||
});
|
||||
expect(batch.exitCode).toBe(0);
|
||||
const batchJson = parseJson(batch);
|
||||
expect(batchJson.root).toEqual(statusJson.root);
|
||||
expect(batchJson.changes.map((change: { changeName: string }) => change.changeName)).toEqual([
|
||||
'store-change',
|
||||
]);
|
||||
expect(batchJson.changes[0]).toEqual({ ...statusJson, root: undefined });
|
||||
|
||||
const instructions = await runCLI(
|
||||
['instructions', 'design', '--change', 'store-change', '--store', 'team-context', '--json'],
|
||||
{ cwd: appRepo, env }
|
||||
|
||||
Reference in New Issue
Block a user