Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale f0b1e00c65 Address review 2025-08-19 18:44:37 +10:00
Tabish Bidiwale 5fe72ddc5d remove archive file 2025-08-19 18:26:34 +10:00
Tabish Bidiwale c18f3b2b2e feat(archive): apply delta-based updates with header matching, skeleton creation, atomic writes, and per-spec counts; add requirement-block parser; add tests covering normalization, order, validation, rename+modify, and multi-spec; update proposal and tasks with product decisions 2025-08-19 18:24:27 +10:00
Tabish Bidiwale 33344727a8 Merge pull request #39 from Fission-AI/make-commands-consistent
Make change and spec commands consistent (raw-first)
2025-08-18 23:26:47 +10:00
Tabish Bidiwale 828e5ba316 chore: remove unused imports; improve no-change messaging; update README for raw-first, JSON-only filters, --long, and --no-color 2025-08-18 23:20:47 +10:00
Tabish Bidiwale 31c57f5c0f Follow-up: add debug logging and tests for raw-first behavior\n\n- change list: add conditional debug logging when reading tasks.md fails\n- spec tests: align with raw-first (JSON-only filters), add --no-color test, raw text passthrough, missing ID error\n- fix change show JSON mapping to stable keys (id/title) and types; ensure build passes 2025-08-18 22:47:03 +10:00
Tabish Bidiwale 767a0053e8 Make change and spec commands consistent (raw-first)\n\n- Require explicit IDs for show; no auto-pick\n- Text mode: raw markdown passthrough; no filters\n- JSON contracts: minimal objects (no top-level arrays)\n- change show: add --deltas-only (JSON); deprecate --requirements-only\n- change list: ids by default; --long for minimal details; unified JSON { id, title, deltaCount, taskStatus }\n- spec show: raw text; JSON { id, title, overview, requirementCount, requirements, metadata }\n- spec list: ids by default; --long for minimal details; unified JSON { id, title, requirementCount }\n- Errors: console.error + process.exitCode\n- Add global --no-color\n- Update tests to new contracts 2025-08-18 22:31:17 +10:00
Tabish Bidiwale fd65b99c91 Merge pull request #38 from Fission-AI/add-change-commands
feat: add change command with show, list, and validate subcommands
2025-08-16 17:19:35 +10:00
13 changed files with 1178 additions and 275 deletions
+76
View File
@@ -0,0 +1,76 @@
## openspec change vs spec: behavior differences and recommendations
This document compares how `openspec change` and `openspec spec` behave today (focused on `show` and `list`) and recommends a raw-first, minimal standard to keep behavior simple and predictable before adding smarter formatting later.
## Summary of key differences and recommendations
| Area | Current: change | Current: spec | Recommendation |
|---|---|---|---|
| Invocation (show) | `openspec change show [change-name]` auto-picks when only one active change | `openspec spec show <spec-id>` requires id | Require explicit IDs for both. If `change-name` is omitted, print available IDs and a short hint (e.g., use `openspec change list`) and exit non-zero. No auto-pick. No interactive picker. |
| Default text output (show) | Raw `proposal.md` content | Formatted summary | Default both to RAW: print the underlying Markdown file as-is. Provide a future `--pretty` flag (non-default) for formatted output. |
| Filtering flags (show) | `--requirements-only` affects text and JSON | `--requirements`, `--no-scenarios`, `-r/--requirement` | Raw-first: in TEXT mode, no filtering. All filtering applies only to JSON output. Deprecate text-mode filters. Keep minimal JSON filters only. |
| JSON shape (show) | Full change object; `--requirements-only` can return an array | Filtered object | Always return an OBJECT. Minimal, stable shape. Change: `{ id, title, deltaCount, deltas, taskStatus? }`. Spec: `{ id, title, overview, requirementCount, requirements, metadata }`. No top-level arrays. |
| Text output (list) | "Active Changes" with progress | "Available Specifications" with teaser | Default both to RAW/minimal: print IDs only by default. Add `--long` to show `id + title` and minimal details (counts). No teasers. |
| JSON shape (list) | `[{ name, title, deltas, taskStatus }]` | `[{ id, title, overview, requirementCount }]` | Unify minimal keys: `id`, `title`, counts only. Change: `deltaCount`, `taskStatus`. Spec: `requirementCount`. Drop `overview` from list JSON. Sort by `id`. |
| Error/exit policy | Spinner + `process.exit(1)` in some paths | `exitCode` in others | Raw-first: no spinners in errors. Use `console.error` + `process.exitCode = 1` consistently. |
| Empty states (list) | Graceful | Errors if specs missing | Raw-first: graceful empty state everywhere. Print "No items found" and exit 0. |
| Multi-selection (change show) | Auto-pick single; error when multiple | N/A | Keep auto-pick single. If multiple, print IDs inline and exit non-zero. No interactive prompts. |
| Colors/TTY | Mixed | Chalk only | Raw-first: minimal color; honor `NO_COLOR` and add `--no-color`. |
## Detailed guidance
### 1) Unify flags and semantics (raw-first)
- Text mode: no filters; just raw file content.
- JSON mode: allow minimal filters only.
- Specs: `--json` returns the structured spec. Optional: `-r/--requirement <n>` and `--requirements-only` apply to JSON only.
- Changes: `--json` returns the structured change. Optional: `--deltas-only` applies to JSON only.
- Deprecate text-mode filtering flags across both commands.
- Optional future: `--pretty` (text formatting) as a non-default enhancement.
### 2) Normalize JSON contracts (minimal and stable)
- Show (change): `{ id, title, deltaCount, deltas: [...], taskStatus?: { total, completed } }`
- Show (spec): `{ id, title, overview, requirementCount, requirements: [...], metadata: { format, version } }`
- List (change): `[{ id, title, deltaCount, taskStatus }]`
- List (spec): `[{ id, title, requirementCount }]`
- Notes:
- No top-level arrays for filtered show responses; always objects.
- Avoid derived/pretty fields (e.g., teasers, percentages). Counts only.
### 3) Standardize text UI (minimal)
- Show: print raw Markdown file contents.
- List (default): print IDs only, one per line.
- List (`--long`): print `id: title` plus minimal counts where relevant.
- Keep colors minimal; support `--no-color` and respect `NO_COLOR`.
### 4) Consistent error handling and empty states
- Use `console.error` + `process.exitCode = 1`. Avoid `process.exit(1)`.
- No spinners (`ora`) in raw-first mode.
- Empty states print a simple message and exit 0.
### 5) Discoverability and UX
- No `change-name` provided: print IDs inline and a short hint; exit non-zero. No auto-pick. No interactive prompts.
- Add `--no-color` for deterministic logs and pipelines.
### 6) Backwards compatibility and deprecation
- Keep legacy flags as aliases for one minor release.
- Print a clear deprecation warning when a legacy flag is used.
- Update CLI docs/README/specs to reflect raw-first behavior.
### 7) Test coverage updates
- Add tests asserting raw text outputs (file passthrough) for `show`.
- Add tests for minimal list outputs (IDs by default, `--long` for details).
- Add JSON contract tests asserting minimal, stable shapes and JSON-only filtering.
### 8) Library alignment with existing commands
- No new dependencies.
- Do not use `@inquirer/prompts` in `change`/`spec` show/list (keep non-interactive). Interaction remains limited to `init`, `diff`, and `archive` where already in use.
- Do not use `ora` in `change`/`spec` (including validate). Use `console.error` and `process.exitCode`.
- Use `chalk` minimally; support `--no-color` and respect `NO_COLOR`.
- Keep using the shared `Validator` where applicable.
- Do not introduce `jest-diff` into `change`/`spec` (remains specific to `diff`).
## Why this approach
- Keeps the system raw and predictable; easy to compose in scripts.
- Minimizes UI/formatting logic until real needs emerge.
- Stabilizes JSON for tooling and avoids top-level arrays.
- Simple to extend later with `--pretty` and richer filtering if needed.
+39 -7
View File
@@ -17,8 +17,9 @@ openspec init
# Update existing OpenSpec instructions (team-friendly)
openspec update
# List all specifications
openspec list
# List specs or changes
openspec spec list # specs (IDs by default; use --long for details)
openspec change list # changes (IDs by default; use --long for details)
# Show differences between specs and proposed changes
openspec diff [change-name]
@@ -47,12 +48,37 @@ Updates OpenSpec instructions to the latest version. This command is **team-frie
This allows team members to use different AI tools without conflicts. Each developer can maintain their preferred AI tool configuration file, and `openspec update` will respect their choice.
### `openspec list`
### `openspec spec`
Lists all specifications and pending changes in your project:
- Shows current specifications in `openspec/specs/`
- Shows pending changes in `openspec/changes/`
- Shows archived changes in `openspec/changes/archive/`
Manage and view specifications.
Examples:
- `openspec spec show <spec-id>`
- Text mode: prints raw `spec.md` content
- JSON mode (`--json`): returns minimal, stable shape
- Filters are JSON-only: `--requirements`, `--no-scenarios`, `-r/--requirement <1-based>`
- `openspec spec list`
- Prints IDs only by default
- Use `--long` to include `title` and `[requirements N]`
- `openspec spec validate <spec-id>`
- Text: human-readable summary to stdout/stderr
- `--json` for structured report
### `openspec change`
Manage and view change proposals.
Examples:
- `openspec change show <change-id>`
- Text mode: prints raw `proposal.md` content
- JSON mode (`--json`): `{ id, title, deltaCount, deltas }`
- Filtering is JSON-only: `--deltas-only` (alias: `--requirements-only`, deprecated)
- `openspec change list`
- Prints IDs only by default
- Use `--long` to include `title` and counts `[deltas N] [tasks x/y]`
- `openspec change validate <change-id>`
- Text: human-readable result
- `--json` for structured report
### `openspec diff [change-name]`
@@ -82,6 +108,12 @@ OpenSpec is designed for team collaboration:
See `openspec/specs/` for the current system specifications and `openspec/changes/` for pending improvements.
## Notes
- The legacy `openspec list` command is deprecated. Use `openspec spec list` and `openspec change list`.
- Text output is raw-first (no formatting or filtering). Prefer `--json` for tooling-friendly output.
- Global `--no-color` disables ANSI colors and respects `NO_COLOR`.
## License
MIT
@@ -63,4 +63,31 @@ This makes reviews focused and changes explicit.
## Conflict Resolution
Git naturally detects conflicts when two changes modify the same requirement header. This is actually better than full-state storage where Git might silently merge incompatible changes.
Git naturally detects conflicts when two changes modify the same requirement header. This is actually better than full-state storage where Git might silently merge incompatible changes.
## Decisions and Product Guidelines
To keep the archive flow lean and predictable, the following decisions apply:
- New spec creation: When a target spec does not exist, auto-generate a minimal skeleton and insert ADDED requirements only. Skeleton format:
- `# [Spec Name] Specification`
- `## Purpose` with placeholder: "TBD — created by archiving change [change-name]. Update Purpose after archive."
- `## Requirements`
- If a non-existent spec includes MODIFIED/REMOVED/RENAMED, abort with guidance to create via ADDED-only first.
- Requirement identification: Match requirements by exact header `### Requirement: [Name]` with trim-only normalization and case-sensitive comparison. Use a requirement-block extractor that preserves the exact header and captures full content (including scenarios) for both main specs and delta files.
- Application order and atomicity: Apply deltas in order RENAMED → REMOVED → MODIFIED → ADDED. Validate all operations first, apply in-memory, and write each spec once. On any validation failure, abort without writing partial results. An aggregated totals line is displayed across all specs: `Totals: + A, ~ M, - R, → N`.
- Validation matrix: Enforce that MODIFIED/REMOVED exist; ADDED do not exist; RENAMED FROM exists and TO does not; no duplicates after all operations; and no cross-section conflicts (e.g., same item in MODIFIED and REMOVED). When a rename and modify apply to the same item, MODIFIED must reference the NEW header.
- Idempotency: Keep v1 simple. Abort on precondition failures (e.g., ADDED already exists) with clear errors. Do not implement no-op detection in v1.
- Output and UX: For each spec, display operation counts using standard symbols `+ ~ - →`. Optionally include a short aggregated totals line at the end. Keep messages concise and actionable.
- Error messaging: Standardize messages as `[spec] [operation] failed for header "### Requirement: X" — reason`. On abort, explicitly state: `Aborted. No files were changed.`
- Subsections: Any subsections under a requirement (e.g., `#### Scenario: ...`) are preserved verbatim during parsing and application.
- Backward compatibility: Reject full future-state spec copies for existing specs with guidance to convert to deltas. Allow brand-new specs to be created via ADDED-only deltas using the skeleton above.
- Dry-run: Deferred for v1 to keep scope minimal.
@@ -17,20 +17,36 @@
- [ ] 2.6 Add tests for side-by-side view formatting
## 3. Update Archive Command
- [ ] 3.1 Update cli-archive spec with delta processing behavior
- [ ] 3.2 Implement normalized header matching (trim whitespace)
- [ ] 3.3 Parse delta sections (ADDED/MODIFIED/REMOVED/RENAMED)
- [ ] 3.4 Apply changes in order: RENAMED → REMOVED → MODIFIED → ADDED
- [ ] 3.5 Validate delta operations:
- [ ] 3.5.1 MODIFIED/REMOVED requirements exist
- [ ] 3.5.2 ADDED requirements don't already exist
- [ ] 3.5.3 RENAMED FROM headers exist, TO headers don't
- [ ] 3.5.4 No duplicate headers within specs
- [ ] 3.5.5 Renamed requirements aren't also in ADDED
- [ ] 3.6 Display operation counts (+ 2 added, ~ 3 modified, etc.)
- [ ] 3.7 Add tests for header normalization
- [ ] 3.8 Add tests for applying deltas in correct order
- [ ] 3.9 Add tests for validation edge cases
- [x] 3.1 Update cli-archive spec with delta processing behavior
- [x] 3.2 Implement requirement-block extractor that preserves exact headers (`### Requirement: [Name]`) and captures full content (including scenarios)
- [x] 3.3 Implement normalized header matching (trim-only, case-sensitive)
- [x] 3.4 Parse delta sections (ADDED/MODIFIED/REMOVED/RENAMED)
- [x] 3.5 New spec creation when target spec does not exist
- [x] 3.5.1 Auto-generate minimal skeleton: `# [Spec Name] Specification`, `## Purpose` placeholder, `## Requirements`
- [x] 3.5.2 Allow only ADDED operations for non-existent specs; abort if MODIFIED/REMOVED/RENAMED present
- [x] 3.6 Apply changes in order: RENAMED → REMOVED → MODIFIED → ADDED
- [x] 3.7 Validation and conflict checks
- [x] 3.7.1 MODIFIED/REMOVED requirements exist (after applying rename mappings)
- [x] 3.7.2 ADDED requirements don't already exist (consider post-rename state)
- [x] 3.7.3 RENAMED FROM headers exist; TO headers don't (including collisions with ADDED)
- [x] 3.7.4 No duplicate headers within specs after all operations
- [x] 3.7.5 Detect cross-section conflicts (e.g., same requirement in MODIFIED and REMOVED)
- [x] 3.7.6 When a rename exists, require MODIFIED to reference the NEW header
- [x] 3.8 Atomic updates
- [x] 3.8.1 Validate all deltas first; stage updates in-memory per spec
- [x] 3.8.2 Single write per spec; abort entire archive on any validation failure (no partial writes)
- [x] 3.9 Output and error messaging
- [x] 3.9.1 Display per-spec operation counts with symbols: `+` added, `~` modified, `-` removed, `→` renamed
- [x] 3.9.2 Optionally display an aggregated totals line across all specs
- [x] 3.9.3 Standardize error message format: `[spec] [operation] failed for header "### Requirement: X" — reason`; end with `Aborted. No files were changed.` on failure
- [x] 3.10 Idempotency behavior (v1): abort on precondition failures (e.g., ADDED already exists); do not implement no-op detection
- [x] 3.11 Tests
- [x] 3.11.1 Header normalization (trim-only) matching
- [x] 3.11.2 Apply in correct order (RENAMED → REMOVED → MODIFIED → ADDED)
- [x] 3.11.3 Validation edge cases (missing headers, duplicates, rename collisions, conflicting sections)
- [x] 3.11.4 Rename + modify interplay (MODIFIED uses new header)
- [x] 3.11.5 New spec creation via skeleton
- [x] 3.11.6 Multi-spec mixed operations with independent validation and write
## Notes
- Archive command is critical path - must work reliably
+22 -12
View File
@@ -17,6 +17,17 @@ program
.description('AI-native system for spec-driven development')
.version('0.0.1');
// Global options
program.option('--no-color', 'Disable color output');
// Apply global flags before any command runs
program.hook('preAction', (thisCommand) => {
const opts = thisCommand.opts();
if (opts.noColor) {
process.env.NO_COLOR = '1';
}
});
program
.command('init [path]')
.description('Initialize OpenSpec in your project')
@@ -103,15 +114,15 @@ changeCmd
.command('show [change-name]')
.description('Show a change proposal in JSON or markdown format')
.option('--json', 'Output as JSON')
.option('--requirements-only', 'Show only requirement changes')
.action(async (changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean }) => {
.option('--deltas-only', 'Show only deltas (JSON only)')
.option('--requirements-only', 'Alias for --deltas-only (deprecated)')
.action(async (changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean }) => {
try {
const changeCommand = new ChangeCommand();
await changeCommand.show(changeName, options);
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
console.error(`Error: ${(error as Error).message}`);
process.exitCode = 1;
}
});
@@ -119,14 +130,14 @@ changeCmd
.command('list')
.description('List all active changes')
.option('--json', 'Output as JSON')
.action(async (options?: { json?: boolean }) => {
.option('--long', 'Show id and title with counts')
.action(async (options?: { json?: boolean; long?: boolean }) => {
try {
const changeCommand = new ChangeCommand();
await changeCommand.list(options);
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
console.error(`Error: ${(error as Error).message}`);
process.exitCode = 1;
}
});
@@ -140,9 +151,8 @@ changeCmd
const changeCommand = new ChangeCommand();
await changeCommand.validate(changeName, options);
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
console.error(`Error: ${(error as Error).message}`);
process.exitCode = 1;
}
});
+73 -80
View File
@@ -1,7 +1,5 @@
import { promises as fs } from 'fs';
import path from 'path';
import ora from 'ora';
import chalk from 'chalk';
import { JsonConverter } from '../core/converters/json-converter.js';
import { Validator } from '../core/validation/validator.js';
import { ChangeParser } from '../core/parsers/change-parser.js';
@@ -19,19 +17,25 @@ export class ChangeCommand {
this.converter = new JsonConverter();
}
async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean }): Promise<void> {
/**
* Show a change proposal.
* - Text mode: raw markdown passthrough (no filters)
* - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas
* Note: --requirements-only is deprecated alias for --deltas-only
*/
async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean }): Promise<void> {
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
if (!changeName) {
const changes = await this.getActiveChanges(changesPath);
if (changes.length === 0) {
throw new Error('No active changes found');
}
if (changes.length === 1) {
changeName = changes[0];
console.error('No change specified. No active changes found.');
} else {
throw new Error(`Multiple active changes found. Please specify one: ${changes.join(', ')}`);
console.error(`No change specified. Available IDs: ${changes.join(', ')}`);
}
console.error('Hint: use "openspec change list" to view available changes.');
process.exitCode = 1;
return;
}
const proposalPath = path.join(changesPath, changeName, 'proposal.md');
@@ -46,42 +50,39 @@ export class ChangeCommand {
const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
if (options.requirementsOnly) {
const change: Change = JSON.parse(jsonOutput);
// Show only deltas (spec changes) for requirements-only mode
const deltas = change.deltas || [];
if (deltas.length === 0) {
console.log(JSON.stringify({ message: "No requirement changes found" }, null, 2));
} else {
console.log(JSON.stringify(deltas, null, 2));
}
console.error('Flag --requirements-only is deprecated; use --deltas-only instead.');
}
const parsed: Change = JSON.parse(jsonOutput);
const contentForTitle = await fs.readFile(proposalPath, 'utf-8');
const title = this.extractTitle(contentForTitle);
const id = parsed.name;
const deltas = parsed.deltas || [];
if (options.requirementsOnly || options.deltasOnly) {
const output = { id, title, deltaCount: deltas.length, deltas };
console.log(JSON.stringify(output, null, 2));
} else {
console.log(jsonOutput);
const output = {
id,
title,
deltaCount: deltas.length,
deltas,
};
console.log(JSON.stringify(output, null, 2));
}
} else {
const content = await fs.readFile(proposalPath, 'utf-8');
if (options?.requirementsOnly) {
const changeDir = path.join(changesPath, changeName);
const parser = new ChangeParser(content, changeDir);
const change = await parser.parseChangeWithDeltas(changeName);
console.log(chalk.bold(`\nRequirement changes from: ${changeName}\n`));
if (change.deltas.length === 0) {
console.log(chalk.yellow('No requirement changes found'));
} else {
change.deltas.forEach(delta => {
console.log(chalk.cyan(`• ${delta.spec} (${delta.operation}): ${delta.description}`));
});
}
console.log();
} else {
console.log(content);
}
console.log(content);
}
}
async list(options?: { json?: boolean }): Promise<void> {
/**
* List active changes.
* - Text default: IDs only; --long prints minimal details (title, counts)
* - JSON: array of { id, title, deltaCount, taskStatus }, sorted by id
*/
async list(options?: { json?: boolean; long?: boolean }): Promise<void> {
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
const changes = await this.getActiveChanges(changesPath);
@@ -110,58 +111,62 @@ export class ChangeCommand {
}
return {
name: changeName,
id: changeName,
title: this.extractTitle(content),
deltas: change.deltas.length,
deltaCount: change.deltas.length,
taskStatus,
};
} catch (error) {
return {
name: changeName,
id: changeName,
title: 'Unknown',
deltas: 0,
deltaCount: 0,
taskStatus: { total: 0, completed: 0 },
};
}
})
);
console.log(JSON.stringify(changeDetails, null, 2));
const sorted = changeDetails.sort((a, b) => a.id.localeCompare(b.id));
console.log(JSON.stringify(sorted, null, 2));
} else {
if (changes.length === 0) {
console.log('No active changes found');
console.log('No items found');
return;
}
console.log(chalk.bold('\nActive Changes:\n'));
for (const changeName of changes) {
const sorted = [...changes].sort();
if (!options?.long) {
// IDs only
sorted.forEach(id => console.log(id));
return;
}
// Long format: id: title and minimal counts
for (const changeName of sorted) {
const proposalPath = path.join(changesPath, changeName, 'proposal.md');
const tasksPath = path.join(changesPath, changeName, 'tasks.md');
try {
const content = await fs.readFile(proposalPath, 'utf-8');
const title = this.extractTitle(content);
let taskStatus = '';
let taskStatusText = '';
try {
const tasksContent = await fs.readFile(tasksPath, 'utf-8');
const { total, completed } = this.countTasks(tasksContent);
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
taskStatus = ` ${chalk.gray(`[${completed}/${total} tasks - ${percentage}%]`)}`;
taskStatusText = ` [tasks ${completed}/${total}]`;
} catch (error) {
// Tasks file may not exist, which is okay
if (process.env.DEBUG) {
console.error(`Failed to read tasks file at ${tasksPath}:`, error);
}
}
console.log(`• ${chalk.cyan(changeName)}: ${title}${taskStatus}`);
} catch (error) {
console.log(`• ${chalk.cyan(changeName)}: ${chalk.red('Error reading proposal')}`);
const changeDir = path.join(changesPath, changeName);
const parser = new ChangeParser(await fs.readFile(proposalPath, 'utf-8'), changeDir);
const change = await parser.parseChangeWithDeltas(changeName);
const deltaCountText = ` [deltas ${change.deltas.length}]`;
console.log(`${changeName}: ${title}${deltaCountText}${taskStatusText}`);
} catch {
console.log(`${changeName}: (unable to read)`);
}
}
console.log();
}
}
@@ -171,13 +176,13 @@ export class ChangeCommand {
if (!changeName) {
const changes = await this.getActiveChanges(changesPath);
if (changes.length === 0) {
throw new Error('No active changes found');
}
if (changes.length === 1) {
changeName = changes[0];
console.error('No change specified. No active changes found.');
} else {
throw new Error(`Multiple active changes found. Please specify one: ${changes.join(', ')}`);
console.error(`No change specified. Available IDs: ${changes.join(', ')}`);
}
console.error('Hint: use "openspec change list" to view available changes.');
process.exitCode = 1;
return;
}
const proposalPath = path.join(changesPath, changeName, 'proposal.md');
@@ -194,26 +199,14 @@ export class ChangeCommand {
if (options?.json) {
console.log(JSON.stringify(report, null, 2));
} else {
const spinner = ora();
if (report.valid) {
spinner.succeed(chalk.green(`Change "${changeName}" is valid`));
console.log(`Change "${changeName}" is valid`);
} else {
spinner.fail(chalk.red(`Change "${changeName}" has validation issues`));
console.log();
console.error(`Change "${changeName}" has validation issues`);
report.issues.forEach(issue => {
const icon = issue.level === 'ERROR' ? '✗' : '⚠';
const color = issue.level === 'ERROR' ? chalk.red : chalk.yellow;
console.log(color(` ${icon} ${issue.path}: ${issue.message}`));
});
}
const warnings = report.issues.filter(issue => issue.level === 'WARNING');
if (warnings.length > 0) {
console.log(chalk.yellow('\nWarnings:'));
warnings.forEach(warning => {
console.log(chalk.yellow(` ⚠ ${warning.path}: ${warning.message}`));
const label = issue.level === 'ERROR' ? 'ERROR' : 'WARNING';
const prefix = issue.level === 'ERROR' ? '✗' : '⚠';
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
});
}
}
+51 -89
View File
@@ -1,7 +1,6 @@
import { program } from 'commander';
import { existsSync, readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import chalk from 'chalk';
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
import { Validator } from '../core/validation/validator.js';
import type { Spec } from '../core/schemas/index.js';
@@ -15,9 +14,10 @@ export function registerSpecCommand(rootProgram: typeof program) {
interface ShowOptions {
json?: boolean;
// JSON-only filters (raw-first text has no filters)
requirements?: boolean;
scenarios?: boolean; // --no-scenarios sets this to false
requirement?: string;
scenarios?: boolean; // --no-scenarios sets this to false (JSON only)
requirement?: string; // JSON only
}
function parseSpecFromFile(specPath: string, specId: string): Spec {
@@ -57,51 +57,22 @@ export function registerSpecCommand(rootProgram: typeof program) {
};
}
function printSpecText(spec: Spec, options: ShowOptions): void {
console.log(chalk.bold.blue(`Spec: ${spec.name}`));
console.log();
console.log(chalk.bold('Purpose:'));
console.log(spec.overview);
console.log();
const requirementIndex = options.requirement
? Number.parseInt(options.requirement, 10) - 1
: undefined;
if (requirementIndex !== undefined) {
const req = spec.requirements[0]; // already filtered to single requirement
console.log(chalk.bold(`Requirement ${requirementIndex + 1}:`));
console.log(chalk.green(req.text));
if (req.scenarios.length > 0) {
console.log();
console.log(chalk.bold('Scenarios:'));
req.scenarios.forEach((scenario, sIndex) => {
console.log(chalk.gray(` Scenario ${sIndex + 1}:`));
scenario.rawText.split('\n').forEach(line => console.log(chalk.gray(` ${line}`)));
});
}
return;
}
console.log(chalk.bold('Requirements:'));
spec.requirements.forEach((req, index) => {
console.log(chalk.green(` ${index + 1}. ${req.text}`));
if (req.scenarios.length > 0) {
req.scenarios.forEach((scenario, sIndex) => {
console.log(chalk.gray(` Scenario ${sIndex + 1}:`));
scenario.rawText.split('\n').forEach(line => console.log(chalk.gray(` ${line}`)));
});
}
});
/**
* Print the raw markdown content for a spec file without any formatting.
* Raw-first behavior ensures text mode is a passthrough for deterministic output.
*/
function printSpecTextRaw(specPath: string): void {
const content = readFileSync(specPath, 'utf-8');
console.log(content);
}
specCommand
.command('show <spec-id>')
.description('Display a specific specification')
.option('--json', 'Output as JSON')
.option('--requirements', 'Show only requirements (exclude scenarios)')
.option('--no-scenarios', 'Exclude scenario content')
.option('-r, --requirement <id>', 'Show specific requirement by ID (1-based)')
.option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')
.option('--no-scenarios', 'JSON only: Exclude scenario content')
.option('-r, --requirement <id>', 'JSON only: Show specific requirement by ID (1-based)')
.action((specId: string, options: ShowOptions) => {
try {
const specPath = join(SPECS_DIR, specId, 'spec.md');
@@ -110,20 +81,27 @@ export function registerSpecCommand(rootProgram: typeof program) {
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
}
if (options.requirements && options.requirement) {
throw new Error('Options --requirements and --requirement cannot be used together');
}
const parsed = parseSpecFromFile(specPath, specId);
const filtered = filterSpec(parsed, options);
if (options.json) {
console.log(JSON.stringify(filtered, null, 2));
if (options.requirements && options.requirement) {
throw new Error('Options --requirements and --requirement cannot be used together');
}
const parsed = parseSpecFromFile(specPath, specId);
const filtered = filterSpec(parsed, options);
const output = {
id: specId,
title: parsed.name,
overview: parsed.overview,
requirementCount: filtered.requirements.length,
requirements: filtered.requirements,
metadata: parsed.metadata ?? { version: '1.0.0', format: 'openspec' as const },
};
console.log(JSON.stringify(output, null, 2));
} else {
printSpecText(filtered, options);
// raw-first text: print raw file
printSpecTextRaw(specPath);
}
} catch (error) {
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`));
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
process.exitCode = 1;
}
});
@@ -132,15 +110,14 @@ export function registerSpecCommand(rootProgram: typeof program) {
.command('list')
.description('List all available specifications')
.option('--json', 'Output as JSON')
.action((options: { json?: boolean }) => {
.option('--long', 'Show id and title with counts')
.action((options: { json?: boolean; long?: boolean }) => {
try {
if (!existsSync(SPECS_DIR)) {
throw new Error(`Specs directory not found at openspec/specs`);
console.log('No items found');
return;
}
const overviewTeaser = (text: string): string =>
text.length > 100 ? `${text.substring(0, 100)}...` : text;
const specs = readdirSync(SPECS_DIR, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => {
@@ -152,37 +129,38 @@ export function registerSpecCommand(rootProgram: typeof program) {
return {
id: dirent.name,
title: spec.name,
overview: overviewTeaser(spec.overview),
requirementCount: spec.requirements.length
};
} catch {
return {
id: dirent.name,
title: dirent.name,
overview: 'Unable to parse spec',
requirementCount: 0
};
}
}
return null;
})
.filter((spec): spec is { id: string; title: string; overview: string; requirementCount: number } => spec !== null)
.filter((spec): spec is { id: string; title: string; requirementCount: number } => spec !== null)
.sort((a, b) => a.id.localeCompare(b.id));
if (options.json) {
console.log(JSON.stringify(specs, null, 2));
} else {
console.log(chalk.bold.blue('Available Specifications:'));
console.log();
if (specs.length === 0) {
console.log('No items found');
return;
}
if (!options.long) {
specs.forEach(spec => console.log(spec.id));
return;
}
specs.forEach(spec => {
console.log(chalk.green(` ${spec.id}`));
console.log(chalk.gray(` ${spec.overview}`));
console.log(chalk.gray(` Requirements: ${spec.requirementCount}`));
console.log();
console.log(`${spec.id}: ${spec.title} [requirements ${spec.requirementCount}]`);
});
}
} catch (error) {
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`));
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
process.exitCode = 1;
}
});
@@ -206,36 +184,20 @@ export function registerSpecCommand(rootProgram: typeof program) {
if (options.json) {
console.log(JSON.stringify(report, null, 2));
} else {
console.log(chalk.bold.blue(`Validation Report for '${specId}':`));
console.log();
if (report.valid) {
console.log(chalk.green('✓ Specification is valid'));
console.log(`Specification '${specId}' is valid`);
} else {
console.log(chalk.red('✗ Specification has issues'));
}
console.log();
console.log(chalk.bold('Summary:'));
console.log(` Errors: ${report.summary.errors}`);
console.log(` Warnings: ${report.summary.warnings}`);
console.log(` Info: ${report.summary.info}`);
if (report.issues.length > 0) {
console.log();
console.log(chalk.bold('Issues:'));
console.error(`Specification '${specId}' has issues`);
report.issues.forEach(issue => {
const icon = issue.level === 'ERROR' ? '✗' :
issue.level === 'WARNING' ? '⚠' : 'ℹ';
const color = issue.level === 'ERROR' ? chalk.red :
issue.level === 'WARNING' ? chalk.yellow : chalk.blue;
console.log(color(` ${icon} [${issue.level}] ${issue.path}: ${issue.message}`));
const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
});
}
}
process.exitCode = report.valid ? 0 : 1;
} catch (error) {
console.error(chalk.red(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`));
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
process.exitCode = 1;
}
});
+278 -7
View File
@@ -4,6 +4,12 @@ import { select, confirm } from '@inquirer/prompts';
import { FileSystemUtils } from '../utils/file-system.js';
import { Validator } from './validation/validator.js';
import chalk from 'chalk';
import {
extractRequirementsSection,
parseDeltaSpec,
normalizeRequirementName,
type RequirementBlock,
} from './parsers/requirement-blocks.js';
interface SpecUpdate {
source: string;
@@ -178,10 +184,31 @@ export class ArchiveCommand {
}
if (shouldUpdateSpecs) {
// Update specs
for (const update of specUpdates) {
await this.updateSpec(update);
// Prepare all updates first (validation pass, no writes)
const prepared: Array<{ update: SpecUpdate; rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> = [];
try {
for (const update of specUpdates) {
const built = await this.buildUpdatedSpec(update, changeName!);
prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });
}
} catch (err: any) {
console.log(String(err.message || err));
console.log('Aborted. No files were changed.');
return;
}
// All validations passed; write files and display counts
let totals = { added: 0, modified: 0, removed: 0, renamed: 0 };
for (const p of prepared) {
await this.writeUpdatedSpec(p.update, p.rebuilt, p.counts);
totals.added += p.counts.added;
totals.modified += p.counts.modified;
totals.removed += p.counts.removed;
totals.renamed += p.counts.renamed;
}
console.log(
`Totals: + ${totals.added}, ~ ${totals.modified}, - ${totals.removed}, → ${totals.renamed}`
);
console.log('Specs updated successfully.');
}
}
@@ -301,14 +328,258 @@ export class ArchiveCommand {
return updates;
}
private async updateSpec(update: SpecUpdate): Promise<void> {
private async buildUpdatedSpec(update: SpecUpdate, changeName: string): Promise<{ rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> {
// Read change spec content (delta-format expected)
const changeContent = await fs.readFile(update.source, 'utf-8');
// Parse deltas from the change spec file
const plan = parseDeltaSpec(changeContent);
const specName = path.basename(path.dirname(update.target));
// Pre-validate duplicates within sections
const addedNames = new Set<string>();
for (const add of plan.added) {
const name = normalizeRequirementName(add.name);
if (addedNames.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in ADDED for header "### Requirement: ${add.name}"`
);
}
addedNames.add(name);
}
const modifiedNames = new Set<string>();
for (const mod of plan.modified) {
const name = normalizeRequirementName(mod.name);
if (modifiedNames.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in MODIFIED for header "### Requirement: ${mod.name}"`
);
}
modifiedNames.add(name);
}
const removedNamesSet = new Set<string>();
for (const rem of plan.removed) {
const name = normalizeRequirementName(rem);
if (removedNamesSet.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in REMOVED for header "### Requirement: ${rem}"`
);
}
removedNamesSet.add(name);
}
const renamedFromSet = new Set<string>();
const renamedToSet = new Set<string>();
for (const { from, to } of plan.renamed) {
const fromNorm = normalizeRequirementName(from);
const toNorm = normalizeRequirementName(to);
if (renamedFromSet.has(fromNorm)) {
throw new Error(
`${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"`
);
}
if (renamedToSet.has(toNorm)) {
throw new Error(
`${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"`
);
}
renamedFromSet.add(fromNorm);
renamedToSet.add(toNorm);
}
// Pre-validate cross-section conflicts
const conflicts: Array<{ name: string; a: string; b: string }> = [];
for (const n of modifiedNames) {
if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'REMOVED' });
if (addedNames.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'ADDED' });
}
for (const n of addedNames) {
if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'ADDED', b: 'REMOVED' });
}
// Renamed interplay: MODIFIED must reference the NEW header, not FROM
for (const { from, to } of plan.renamed) {
const fromNorm = normalizeRequirementName(from);
const toNorm = normalizeRequirementName(to);
if (modifiedNames.has(fromNorm)) {
throw new Error(
`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`
);
}
// Detect ADDED colliding with a RENAMED TO
if (addedNames.has(toNorm)) {
throw new Error(
`${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"`
);
}
}
if (conflicts.length > 0) {
const c = conflicts[0];
throw new Error(
`${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"`
);
}
const hasAnyDelta = (plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length) > 0;
if (!hasAnyDelta) {
throw new Error(
`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
`Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`
);
}
// Load or create base target content
let targetContent: string;
try {
targetContent = await fs.readFile(update.target, 'utf-8');
} catch {
// Target spec does not exist; only ADDED operations are permitted
if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) {
throw new Error(
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs.`
);
}
targetContent = this.buildSpecSkeleton(specName, changeName);
}
// Extract requirements section and build name->block map
const parts = extractRequirementsSection(targetContent);
const nameToBlock = new Map<string, RequirementBlock>();
for (const block of parts.bodyBlocks) {
nameToBlock.set(normalizeRequirementName(block.name), block);
}
// Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
// RENAMED
for (const r of plan.renamed) {
const from = normalizeRequirementName(r.from);
const to = normalizeRequirementName(r.to);
if (!nameToBlock.has(from)) {
throw new Error(
`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`
);
}
if (nameToBlock.has(to)) {
throw new Error(
`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`
);
}
const block = nameToBlock.get(from)!;
const newHeader = `### Requirement: ${to}`;
const rawLines = block.raw.split('\n');
rawLines[0] = newHeader;
const renamedBlock: RequirementBlock = {
headerLine: newHeader,
name: to,
raw: rawLines.join('\n'),
};
nameToBlock.delete(from);
nameToBlock.set(to, renamedBlock);
}
// REMOVED
for (const name of plan.removed) {
const key = normalizeRequirementName(name);
if (!nameToBlock.has(key)) {
throw new Error(
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
);
}
nameToBlock.delete(key);
}
// MODIFIED
for (const mod of plan.modified) {
const key = normalizeRequirementName(mod.name);
if (!nameToBlock.has(key)) {
throw new Error(
`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`
);
}
// Replace block with provided raw (ensure header line matches key)
const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/);
if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
throw new Error(
`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`
);
}
nameToBlock.set(key, mod);
}
// ADDED
for (const add of plan.added) {
const key = normalizeRequirementName(add.name);
if (nameToBlock.has(key)) {
throw new Error(
`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`
);
}
nameToBlock.set(key, add);
}
// Duplicates within resulting map are implicitly prevented by key uniqueness.
// Recompose requirements section preserving original ordering where possible
const keptOrder: RequirementBlock[] = [];
const seen = new Set<string>();
for (const block of parts.bodyBlocks) {
const key = normalizeRequirementName(block.name);
const replacement = nameToBlock.get(key);
if (replacement) {
keptOrder.push(replacement);
seen.add(key);
}
}
// Append any newly added that were not in original order
for (const [key, block] of nameToBlock.entries()) {
if (!seen.has(key)) {
keptOrder.push(block);
}
}
const reqBody = [
parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : ''
]
.filter(Boolean)
.concat(keptOrder.map(b => b.raw))
.join('\n\n')
.trimEnd();
const rebuilt = [
parts.before.trimEnd(),
parts.headerLine,
reqBody,
parts.after
]
.filter((s, idx) => !(idx === 0 && s === ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n');
return {
rebuilt,
counts: {
added: plan.added.length,
modified: plan.modified.length,
removed: plan.removed.length,
renamed: plan.renamed.length,
}
};
}
private async writeUpdatedSpec(update: SpecUpdate, rebuilt: string, counts: { added: number; modified: number; removed: number; renamed: number }): Promise<void> {
// Create target directory if needed
const targetDir = path.dirname(update.target);
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(update.target, rebuilt);
// Copy spec file
const content = await fs.readFile(update.source, 'utf-8');
await fs.writeFile(update.target, content);
const specName = path.basename(path.dirname(update.target));
console.log(`Applying changes to openspec/specs/${specName}/spec.md:`);
if (counts.added) console.log(` + ${counts.added} added`);
if (counts.modified) console.log(` ~ ${counts.modified} modified`);
if (counts.removed) console.log(` - ${counts.removed} removed`);
if (counts.renamed) console.log(` → ${counts.renamed} renamed`);
}
private buildSpecSkeleton(specFolderName: string, changeName: string): string {
const titleBase = specFolderName;
return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`;
}
private getArchiveDate(): string {
+201
View File
@@ -0,0 +1,201 @@
export interface RequirementBlock {
headerLine: string; // e.g., '### Requirement: Something'
name: string; // e.g., 'Something'
raw: string; // full block including headerLine and following content
}
export interface RequirementsSectionParts {
before: string;
headerLine: string; // the '## Requirements' line
preamble: string; // content between headerLine and first requirement block
bodyBlocks: RequirementBlock[]; // parsed requirement blocks in order
after: string;
}
export function normalizeRequirementName(name: string): string {
return name.trim();
}
const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/;
/**
* Extracts the Requirements section from a spec file and parses requirement blocks.
*/
export function extractRequirementsSection(content: string): RequirementsSectionParts {
const lines = content.split('\n');
const reqHeaderIndex = lines.findIndex(l => /^##\s+Requirements\s*$/i.test(l));
if (reqHeaderIndex === -1) {
// No requirements section; create an empty one at the end
const before = content.trimEnd();
const headerLine = '## Requirements';
return {
before: before ? before + '\n\n' : '',
headerLine,
preamble: '',
bodyBlocks: [],
after: '\n',
};
}
// Find end of this section: next line that starts with '## ' at same or higher level
let endIndex = lines.length;
for (let i = reqHeaderIndex + 1; i < lines.length; i++) {
if (/^##\s+/.test(lines[i])) {
endIndex = i;
break;
}
}
const before = lines.slice(0, reqHeaderIndex).join('\n');
const headerLine = lines[reqHeaderIndex];
const sectionBodyLines = lines.slice(reqHeaderIndex + 1, endIndex);
// Parse requirement blocks within section body
const blocks: RequirementBlock[] = [];
let cursor = 0;
let preambleLines: string[] = [];
// Collect preamble lines until first requirement header
while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/.test(sectionBodyLines[cursor])) {
preambleLines.push(sectionBodyLines[cursor]);
cursor++;
}
while (cursor < sectionBodyLines.length) {
const headerStart = cursor;
const headerLineCandidate = sectionBodyLines[cursor];
const headerMatch = headerLineCandidate.match(REQUIREMENT_HEADER_REGEX);
if (!headerMatch) {
// Not a requirement header; skip line defensively
cursor++;
continue;
}
const name = normalizeRequirementName(headerMatch[1]);
cursor++;
// Gather lines until next requirement header or end of section
const bodyLines: string[] = [headerLineCandidate];
while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/.test(sectionBodyLines[cursor]) && !/^##\s+/.test(sectionBodyLines[cursor])) {
bodyLines.push(sectionBodyLines[cursor]);
cursor++;
}
const raw = bodyLines.join('\n').trimEnd();
blocks.push({ headerLine: headerLineCandidate, name, raw });
}
const after = lines.slice(endIndex).join('\n');
const preamble = preambleLines.join('\n').trimEnd();
return {
before: before.trimEnd() ? before + '\n' : before,
headerLine,
preamble,
bodyBlocks: blocks,
after: after.startsWith('\n') ? after : '\n' + after,
};
}
export interface DeltaPlan {
added: RequirementBlock[];
modified: RequirementBlock[];
removed: string[]; // requirement names
renamed: Array<{ from: string; to: string }>;
}
/**
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
*/
export function parseDeltaSpec(content: string): DeltaPlan {
const sections = splitTopLevelSections(content);
const added = parseRequirementBlocksFromSection(sections['ADDED Requirements'] || '');
const modified = parseRequirementBlocksFromSection(sections['MODIFIED Requirements'] || '');
const removedNames = parseRemovedNames(sections['REMOVED Requirements'] || '');
const renamedPairs = parseRenamedPairs(sections['RENAMED Requirements'] || '');
return { added, modified, removed: removedNames, renamed: renamedPairs };
}
function splitTopLevelSections(content: string): Record<string, string> {
const lines = content.split('\n');
const result: Record<string, string> = {};
const indices: Array<{ title: string; index: number; level: number }> = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^(##)\s+(.+)$/);
if (m) {
const level = m[1].length; // only care for '##'
indices.push({ title: m[2].trim(), index: i, level });
}
}
for (let i = 0; i < indices.length; i++) {
const current = indices[i];
const next = indices[i + 1];
const body = lines.slice(current.index + 1, next ? next.index : lines.length).join('\n');
result[current.title] = body;
}
return result;
}
function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {
if (!sectionBody) return [];
const lines = sectionBody.split('\n');
const blocks: RequirementBlock[] = [];
let i = 0;
while (i < lines.length) {
// Seek next requirement header
while (i < lines.length && !/^###\s+Requirement:/.test(lines[i])) i++;
if (i >= lines.length) break;
const headerLine = lines[i];
const m = headerLine.match(REQUIREMENT_HEADER_REGEX);
if (!m) { i++; continue; }
const name = normalizeRequirementName(m[1]);
const buf: string[] = [headerLine];
i++;
while (i < lines.length && !/^###\s+Requirement:/.test(lines[i]) && !/^##\s+/.test(lines[i])) {
buf.push(lines[i]);
i++;
}
blocks.push({ headerLine, name, raw: buf.join('\n').trimEnd() });
}
return blocks;
}
function parseRemovedNames(sectionBody: string): string[] {
if (!sectionBody) return [];
const names: string[] = [];
const lines = sectionBody.split('\n');
for (const line of lines) {
const m = line.match(REQUIREMENT_HEADER_REGEX);
if (m) {
names.push(normalizeRequirementName(m[1]));
continue;
}
// Also support bullet list of headers
const bullet = line.match(/^\s*-\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
if (bullet) {
names.push(normalizeRequirementName(bullet[1]));
}
}
return names;
}
function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: string }> {
if (!sectionBody) return [];
const pairs: Array<{ from: string; to: string }> = [];
const lines = sectionBody.split('\n');
let current: { from?: string; to?: string } = {};
for (const line of lines) {
const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
if (fromMatch) {
current.from = normalizeRequirementName(fromMatch[1]);
} else if (toMatch) {
current.to = normalizeRequirementName(toMatch[1]);
if (current.from && current.to) {
pairs.push({ from: current.from, to: current.to });
current = {};
}
}
}
return pairs;
}
+47 -46
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
@@ -9,6 +9,11 @@ describe('spec command', () => {
const specsDir = path.join(testDir, 'openspec', 'specs');
const openspecBin = path.join(projectRoot, 'bin', 'openspec.js');
beforeAll(() => {
// Ensure CLI is built so bin/openspec.js loads latest logic from dist/
execSync('pnpm -s build', { stdio: 'pipe' });
});
beforeEach(async () => {
await fs.mkdir(specsDir, { recursive: true });
@@ -62,12 +67,9 @@ The system SHALL process credit card payments securely`;
encoding: 'utf-8'
});
expect(output).toContain('Spec: auth');
expect(output).toContain('Purpose:');
expect(output).toContain('test specification for the authentication system');
expect(output).toContain('Requirements:');
expect(output).toContain('The system SHALL provide secure user authentication');
expect(output).toContain('The system SHALL allow users to reset their password');
// Raw passthrough should match spec.md content
const raw = execSync(`cat ${path.join(specsDir, 'auth', 'spec.md')}`, { encoding: 'utf-8' });
expect(output.trim()).toBe(raw.trim());
} finally {
process.chdir(originalCwd);
}
@@ -82,7 +84,8 @@ The system SHALL process credit card payments securely`;
});
const json = JSON.parse(output);
expect(json.name).toBe('auth');
expect(json.id).toBe('auth');
expect(json.title).toBe('auth');
expect(json.overview).toContain('test specification');
expect(json.requirements).toHaveLength(2);
expect(json.metadata.format).toBe('openspec');
@@ -91,51 +94,50 @@ The system SHALL process credit card payments securely`;
}
});
it('should filter to show only requirements with --requirements flag', () => {
it('should filter to show only requirements with --requirements flag (JSON only)', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
const output = execSync(`node ${openspecBin} spec show auth --requirements`, {
const output = execSync(`node ${openspecBin} spec show auth --json --requirements`, {
encoding: 'utf-8'
});
expect(output).toContain('The system SHALL provide secure user authentication');
expect(output).toContain('The system SHALL allow users to reset their password');
expect(output).not.toContain('Scenario');
expect(output).not.toContain('GIVEN');
const json = JSON.parse(output);
expect(json.requirements).toHaveLength(2);
// Scenarios should be excluded when --requirements is used
expect(json.requirements.every((r: any) => Array.isArray(r.scenarios) && r.scenarios.length === 0)).toBe(true);
} finally {
process.chdir(originalCwd);
}
});
it('should exclude scenarios with --no-scenarios flag', () => {
it('should exclude scenarios with --no-scenarios flag (JSON only)', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
const output = execSync(`node ${openspecBin} spec show auth --no-scenarios`, {
const output = execSync(`node ${openspecBin} spec show auth --json --no-scenarios`, {
encoding: 'utf-8'
});
expect(output).toContain('The system SHALL provide secure user authentication');
expect(output).toContain('The system SHALL allow users to reset their password');
expect(output).not.toContain('Scenario');
const json = JSON.parse(output);
expect(json.requirements).toHaveLength(2);
expect(json.requirements.every((r: any) => Array.isArray(r.scenarios) && r.scenarios.length === 0)).toBe(true);
} finally {
process.chdir(originalCwd);
}
});
it('should show specific requirement with -r flag', () => {
it('should show specific requirement with -r flag (JSON only)', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
const output = execSync(`node ${openspecBin} spec show auth -r 1`, {
const output = execSync(`node ${openspecBin} spec show auth --json -r 1`, {
encoding: 'utf-8'
});
expect(output).toContain('Requirement 1:');
expect(output).toContain('The system SHALL provide secure user authentication');
expect(output).toContain('Scenario');
expect(output).not.toContain('Password Reset');
const json = JSON.parse(output);
expect(json.requirements).toHaveLength(1);
expect(json.requirements[0].text).toContain('The system SHALL provide secure user authentication');
} finally {
process.chdir(originalCwd);
}
@@ -159,7 +161,7 @@ The system SHALL process credit card payments securely`;
});
describe('spec list', () => {
it('should list all available specs', () => {
it('should list all available specs (IDs only by default)', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
@@ -167,11 +169,10 @@ The system SHALL process credit card payments securely`;
encoding: 'utf-8'
});
expect(output).toContain('Available Specifications:');
expect(output).toContain('auth');
expect(output).toContain('payment');
expect(output).toContain('Requirements: 2');
expect(output).toContain('Requirements: 1');
// Default should not include counts or teasers
expect(output).not.toMatch(/Requirements:\s*\d+/);
} finally {
process.chdir(originalCwd);
}
@@ -205,9 +206,7 @@ The system SHALL process credit card payments securely`;
encoding: 'utf-8'
});
expect(output).toContain('Validation Report');
expect(output).toContain('auth');
expect(output).toContain('Specification is valid');
expect(output).toContain("Specification 'auth' is valid");
} finally {
process.chdir(originalCwd);
}
@@ -301,24 +300,26 @@ This section has no actual requirements`;
}
});
it('should handle missing specs directory', async () => {
it('should handle missing specs directory gracefully', async () => {
await fs.rm(specsDir, { recursive: true, force: true });
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let error: any;
try {
execSync(`node ${openspecBin} spec list`, {
encoding: 'utf-8'
});
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.status).not.toBe(0);
const output = execSync(`node ${openspecBin} spec list`, { encoding: 'utf-8' });
expect(output.trim()).toBe('No items found');
} finally {
process.chdir(originalCwd);
}
});
it('should honor --no-color (no ANSI escapes)', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
const output = execSync(`node ${openspecBin} --no-color spec list --long`, { encoding: 'utf-8' });
// Basic ANSI escape pattern
const hasAnsi = /\u001b\[[0-9;]*m/.test(output);
expect(hasAnsi).toBe(false);
} finally {
process.chdir(originalCwd);
}
+285 -11
View File
@@ -93,21 +93,18 @@ describe('ArchiveCommand', () => {
);
});
it('should update specs when archiving', async () => {
it('should update specs when archiving (delta-based ADDED) and include change name in skeleton', async () => {
const changeName = 'spec-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create valid spec in change
const specContent = `# Test Capability Spec
// Create delta-based change spec (ADDED requirement)
const specContent = `# Test Capability Spec - Changes
## Purpose
This is a test capability specification for testing purposes.
## ADDED Requirements
## Requirements
### The system SHALL provide test capability
### Requirement: The system SHALL provide test capability
#### Scenario: Basic test
Given a test condition
@@ -118,10 +115,15 @@ Then expected result happens`;
// Execute archive with --yes flag and skip validation for speed
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Verify spec was copied to main specs
// Verify spec was created from skeleton and ADDED requirement applied
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
const copiedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(copiedContent).toBe(specContent);
const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(updatedContent).toContain('# test-capability Specification');
expect(updatedContent).toContain('## Purpose');
expect(updatedContent).toContain(`created by archiving change ${changeName}`);
expect(updatedContent).toContain('## Requirements');
expect(updatedContent).toContain('### Requirement: The system SHALL provide test capability');
expect(updatedContent).toContain('#### Scenario: Basic test');
});
it('should throw error if change does not exist', async () => {
@@ -265,6 +267,278 @@ Then expected result happens`;
expect(archives.length).toBe(1);
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
});
it('should support header trim-only normalization for matching', async () => {
const changeName = 'normalize-headers';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'alpha');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create existing main spec with a requirement (no extra trailing spaces)
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'alpha');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# alpha Specification
## Purpose
Alpha purpose.
## Requirements
### Requirement: Important Rule
Some details.`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Change attempts to modify the same requirement but with trailing spaces after the name
const deltaContent = `# Alpha - Changes
## MODIFIED Requirements
### Requirement: Important Rule
Updated details.`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(updated).toContain('### Requirement: Important Rule');
expect(updated).toContain('Updated details.');
});
it('should apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED', async () => {
const changeName = 'apply-order';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'beta');
await fs.mkdir(changeSpecDir, { recursive: true });
// Main spec with two requirements A and B
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'beta');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# beta Specification
## Purpose
Beta purpose.
## Requirements
### Requirement: A
content A
### Requirement: B
content B`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Rename A->C, Remove B, Modify C, Add D
const deltaContent = `# Beta - Changes
## RENAMED Requirements
- FROM: \`### Requirement: A\`
- TO: \`### Requirement: C\`
## REMOVED Requirements
### Requirement: B
## MODIFIED Requirements
### Requirement: C
updated C
## ADDED Requirements
### Requirement: D
content D`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(updated).toContain('### Requirement: C');
expect(updated).toContain('updated C');
expect(updated).toContain('### Requirement: D');
expect(updated).not.toContain('### Requirement: A');
expect(updated).not.toContain('### Requirement: B');
});
it('should abort with error when MODIFIED/REMOVED reference non-existent requirements', async () => {
const changeName = 'validate-missing';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'gamma');
await fs.mkdir(changeSpecDir, { recursive: true });
// Main spec with no requirements
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'gamma');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# gamma Specification
## Purpose
Gamma purpose.
## Requirements`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Delta tries to modify and remove non-existent requirement
const deltaContent = `# Gamma - Changes
## MODIFIED Requirements
### Requirement: Missing
new text
## REMOVED Requirements
### Requirement: Another Missing`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Should not change the main spec and should not archive the change dir
const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(still).toBe(mainContent);
// Change dir should still exist since operation aborted
await expect(fs.access(changeDir)).resolves.not.toThrow();
});
it('should require MODIFIED to reference the NEW header when a rename exists (error format)', async () => {
const changeName = 'rename-modify-new-header';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'delta');
await fs.mkdir(changeSpecDir, { recursive: true });
// Main spec with Old
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'delta');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# delta Specification
## Purpose
Delta purpose.
## Requirements
### Requirement: Old
old body`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Delta: rename Old->New, but MODIFIED references Old (should abort)
const badDelta = `# Delta - Changes
## RENAMED Requirements
- FROM: \`### Requirement: Old\`
- TO: \`### Requirement: New\`
## MODIFIED Requirements
### Requirement: Old
new body`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), badDelta);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const unchanged = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(unchanged).toBe(mainContent);
// Assert error message format and abort notice
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('delta validation failed')
);
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Aborted. No files were changed.')
);
// Fix MODIFIED to reference New (should succeed)
const goodDelta = `# Delta - Changes
## RENAMED Requirements
- FROM: \`### Requirement: Old\`
- TO: \`### Requirement: New\`
## MODIFIED Requirements
### Requirement: New
new body`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), goodDelta);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(updated).toContain('### Requirement: New');
expect(updated).toContain('new body');
expect(updated).not.toContain('### Requirement: Old');
});
it('should process multiple specs atomically (any failure aborts all)', async () => {
const changeName = 'multi-spec-atomic';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const spec1Dir = path.join(changeDir, 'specs', 'epsilon');
const spec2Dir = path.join(changeDir, 'specs', 'zeta');
await fs.mkdir(spec1Dir, { recursive: true });
await fs.mkdir(spec2Dir, { recursive: true });
// Existing main specs
const epsilonMain = path.join(tempDir, 'openspec', 'specs', 'epsilon', 'spec.md');
await fs.mkdir(path.dirname(epsilonMain), { recursive: true });
await fs.writeFile(epsilonMain, `# epsilon Specification
## Purpose
Epsilon purpose.
## Requirements
### Requirement: E1
e1`);
const zetaMain = path.join(tempDir, 'openspec', 'specs', 'zeta', 'spec.md');
await fs.mkdir(path.dirname(zetaMain), { recursive: true });
await fs.writeFile(zetaMain, `# zeta Specification
## Purpose
Zeta purpose.
## Requirements
### Requirement: Z1
z1`);
// Delta: epsilon is valid modification; zeta tries to remove non-existent -> should abort both
await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Epsilon - Changes
## MODIFIED Requirements
### Requirement: E1
E1 updated`);
await fs.writeFile(path.join(spec2Dir, 'spec.md'), `# Zeta - Changes
## REMOVED Requirements
### Requirement: Missing`);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const e1 = await fs.readFile(epsilonMain, 'utf-8');
const z1 = await fs.readFile(zetaMain, 'utf-8');
expect(e1).toContain('### Requirement: E1');
expect(e1).not.toContain('E1 updated');
expect(z1).toContain('### Requirement: Z1');
// changeDir should still exist
await expect(fs.access(changeDir)).resolves.not.toThrow();
});
it('should display aggregated totals across multiple specs', async () => {
const changeName = 'multi-spec-totals';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const spec1Dir = path.join(changeDir, 'specs', 'omega');
const spec2Dir = path.join(changeDir, 'specs', 'psi');
await fs.mkdir(spec1Dir, { recursive: true });
await fs.mkdir(spec2Dir, { recursive: true });
// Existing main specs
const omegaMain = path.join(tempDir, 'openspec', 'specs', 'omega', 'spec.md');
await fs.mkdir(path.dirname(omegaMain), { recursive: true });
await fs.writeFile(omegaMain, `# omega Specification\n\n## Purpose\nOmega purpose.\n\n## Requirements\n\n### Requirement: O1\no1`);
const psiMain = path.join(tempDir, 'openspec', 'specs', 'psi', 'spec.md');
await fs.mkdir(path.dirname(psiMain), { recursive: true });
await fs.writeFile(psiMain, `# psi Specification\n\n## Purpose\nPsi purpose.\n\n## Requirements\n\n### Requirement: P1\np1`);
// Deltas: omega add one, psi rename and modify -> totals: +1, ~1, -0, →1
await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Omega - Changes\n\n## ADDED Requirements\n\n### Requirement: O2\nnew`);
await fs.writeFile(path.join(spec2Dir, 'spec.md'), `# Psi - Changes\n\n## RENAMED Requirements\n- FROM: \`### Requirement: P1\`\n- TO: \`### Requirement: P2\`\n\n## MODIFIED Requirements\n### Requirement: P2\nupdated`);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Verify aggregated totals line was printed
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Totals: + 1, ~ 1, - 0, → 1')
);
});
});
describe('error handling', () => {
+22 -2
View File
@@ -27,9 +27,9 @@ describe('ChangeCommand.list', () => {
expect(Array.isArray(parsed)).toBe(true);
if (parsed.length > 0) {
const item = parsed[0];
expect(item).toHaveProperty('name');
expect(item).toHaveProperty('id');
expect(item).toHaveProperty('title');
expect(item).toHaveProperty('deltas');
expect(item).toHaveProperty('deltaCount');
expect(item).toHaveProperty('taskStatus');
expect(item.taskStatus).toHaveProperty('total');
expect(item.taskStatus).toHaveProperty('completed');
@@ -38,4 +38,24 @@ describe('ChangeCommand.list', () => {
console.log = origLog;
}
});
it('prints IDs by default and details with --long', async () => {
const logs: string[] = [];
const origLog = console.log;
try {
console.log = (msg?: any, ...args: any[]) => {
logs.push([msg, ...args].filter(Boolean).join(' '));
};
await cmd.list({});
const idsOnly = logs.join('\n');
expect(idsOnly).toMatch(/\w+/);
logs.length = 0;
await cmd.list({ long: true });
const longOut = logs.join('\n');
expect(longOut).toMatch(/:\s/);
expect(longOut).toMatch(/\[deltas\s\d+\]/);
} finally {
console.log = origLog;
}
});
});
@@ -47,7 +47,26 @@ describe('ChangeCommand.show/validate', () => {
}
});
it('show --json --requirements-only prints only deltas array', async () => {
it('error when no change specified: prints available IDs', async () => {
const logsErr: string[] = [];
const origErr = console.error;
try {
console.error = (msg?: any, ...args: any[]) => {
logsErr.push([msg, ...args].filter(Boolean).join(' '));
};
await cmd.show(undefined as unknown as string, { json: false } as any);
// Should have set exit code and printed hint
expect(process.exitCode).toBe(1);
const errOut = logsErr.join('\n');
expect(errOut).toMatch(/No change specified/);
expect(errOut).toMatch(/Available IDs/);
} finally {
console.error = origErr;
process.exitCode = 0;
}
});
it('show --json --requirements-only returns minimal object with deltas (deprecated alias)', async () => {
if (!changeName) return; // skip if no changes present
const logs: string[] = [];
@@ -61,11 +80,12 @@ describe('ChangeCommand.show/validate', () => {
const output = logs.join('\n');
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
if (parsed.length > 0) {
expect(parsed[0]).toHaveProperty('spec');
expect(parsed[0]).toHaveProperty('operation');
expect(parsed[0]).toHaveProperty('description');
expect(parsed).toHaveProperty('deltas');
expect(Array.isArray(parsed.deltas)).toBe(true);
if (parsed.deltas.length > 0) {
expect(parsed.deltas[0]).toHaveProperty('spec');
expect(parsed.deltas[0]).toHaveProperty('operation');
expect(parsed.deltas[0]).toHaveProperty('description');
}
} finally {
console.log = origLog;