Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale 8ac50289f0 Add tests 2025-08-19 23:22:18 +10:00
Tabish Bidiwale 1f295cec52 feat: add unified validate command with interactive selection and bulk operations 2025-08-19 22:58:17 +10:00
Tabish Bidiwale ad8e213cf9 Merge pull request #42 from Fission-AI/feat/bulk-validation-and-interactive-selection
feat: add bulk validation and interactive selection for OpenSpec commands
2025-08-19 22:35:25 +10:00
Tabish Bidiwale a6c1a90165 docs: consolidate retrospective documents into comprehensive analysis 2025-08-19 22:30:13 +10:00
Tabish Bidiwale 21b5a3e680 refactor: split validation and show commands into separate change proposals 2025-08-19 22:06:09 +10:00
Tabish Bidiwale 1bda5be96c refactor: use single validate command with flags for better UX 2025-08-19 21:40:27 +10:00
Tabish Bidiwale 0faf44807e refactor: simplify change to modify existing command specs instead of creating new ones 2025-08-19 21:29:38 +10:00
Tabish Bidiwale f9c1d07edb feat: add change proposal for bulk validation and interactive selection 2025-08-19 21:10:56 +10:00
Tabish Bidiwale 2bd1a4417c Merge pull request #41 from Fission-AI/chore/fix-change-validations
feat: Chore/fix change validations
2025-08-19 20:51:22 +10:00
Tabish Bidiwale 1db19ac3d8 remove delta from proposal 2025-08-19 20:49:02 +10:00
Tabish Bidiwale 25018786e4 chore(conventions): remove delta sections from proposals; keep deltas only in change specs; all changes pass --strict validation 2025-08-19 20:46:13 +10:00
Tabish Bidiwale 5fd9173ad9 remove md files 2025-08-19 20:40:14 +10:00
Tabish Bidiwale 0a611747bc fix(change-validate): ensure delta specs emit requirements arrays; add missing Why/What sections and delta content for changes; refine removed requirement text and scenarios; all changes pass --strict validation 2025-08-19 20:31:41 +10:00
Tabish Bidiwale 49e422724f update test commands 2025-08-19 19:51:43 +10:00
Tabish Bidiwale c22d6bce1c show completion for tasks when archiving 2025-08-19 19:48:54 +10:00
Tabish Bidiwale 1c0dc09dc9 Merge pull request #40 from Fission-AI/update-archive-command
feat: Update archive command
2025-08-19 18:57:51 +10:00
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
Tabish Bidiwale e170df9f41 test(change): add minimal tests for change parser and command; fix async converter usage 2025-08-16 17:18:32 +10:00
Tabish Bidiwale b91040e8c2 chore(cli): tighten change show help text and remove unused imports 2025-08-16 17:18:32 +10:00
Tabish Bidiwale 8824bd2a42 feat: implement change-specific parser for delta format 2025-08-16 17:18:32 +10:00
Tabish Bidiwale 1d3c292d94 fix: address code review feedback for change commands
- Replace any types with proper Change and Delta types from schemas
- Add error logging in catch blocks when DEBUG env var is set
- Include file paths in error messages for better debugging
- Extract regex patterns and magic strings as constants
- Improve code maintainability and type safety
2025-08-16 17:18:32 +10:00
Tabish Bidiwale cfe6da96ac feat: add change command with show, list, and validate subcommands
- Add new `openspec change` command with three subcommands
- Implement JSON output capability for change proposals
- Add deprecation warning to legacy `openspec list` command
- Enable --requirements-only filtering for change show
- Support --strict mode and --json flags for validation

This provides programmatic access to change proposals through JSON output
and establishes a consistent resource-based command structure.
2025-08-16 17:18:32 +10:00
Tabish Bidiwale c3c78551d0 Merge pull request #37 from Fission-AI/add-spec-commands
Add spec command for programmatic access to specifications
2025-08-16 11:40:25 +10:00
48 changed files with 3374 additions and 401 deletions
+343
View File
@@ -0,0 +1,343 @@
# Comprehensive Retrospective: Creating an OpenSpec Change Proposal
## Executive Summary
This document consolidates learnings from creating the `bulk-validation-interactive-selection` change proposal for OpenSpec. The process revealed critical gaps in documentation, unhelpful error messages, and areas where the system could be more user-friendly. While OpenSpec's core functionality works correctly, the user experience for creating changes needs significant improvement.
## Table of Contents
1. [Errors Encountered](#errors-encountered)
2. [System Issues vs User Errors](#system-issues-vs-user-errors)
3. [Documentation Gaps Analysis](#documentation-gaps-analysis)
4. [Key Learnings](#key-learnings)
5. [Recommendations](#recommendations)
6. [Conclusion](#conclusion)
---
## Errors Encountered
### Error 1: Misunderstanding Delta Structure
**Error Message:** `✗ [ERROR] deltas: Change must have at least one delta`
**What Happened:**
Initially attempted to define deltas directly in the `proposal.md` file using markdown sections like:
```markdown
### Delta: Add validate-all command
**Type**: Feature addition
**Effort**: Small (< 100 lines)
```
**Root Cause:**
Fundamental misunderstanding of how OpenSpec processes deltas. Deltas are derived from spec files in the change's `specs/` directory, not from the proposal itself.
**Discovery Process:**
- Examined `ChangeParser` class in `/src/core/parsers/change-parser.ts`
- Found that `parseDeltaSpecs()` method looks for spec files in `specs/` subdirectory
- Learned that deltas are extracted by comparing spec files against existing specs
### Error 2: Missing Operation Prefix in Section Headers
**Error Message:** `✗ [ERROR] deltas: Change must have at least one delta`
**What Happened:**
Created new spec files with standard `## Requirements` headers instead of operation-prefixed headers.
**Root Cause:**
Failed to understand that ALL spec files in a change need operation prefixes (`ADDED`, `MODIFIED`, etc.) in their section headers, regardless of whether they're new specs or modifications.
**Discovery Process:**
- Created new specs with `## Requirements` → No deltas detected
- Changed to `## ADDED Requirements` → Deltas detected successfully!
- Realized creating new specs works perfectly fine once properly formatted
**Important Clarification:**
OpenSpec fully supports creating new specs. They appear as ADDED operations in the deltas. My initial analysis incorrectly suggested this was a limitation, but it was actually just a formatting issue.
### Error 3: Improper Scenario Formatting
**Error Message:**
```
✗ [ERROR] deltas.0.requirements.0.scenarios: Requirement must have at least one scenario
✗ [ERROR] deltas.1.requirements.0.scenarios: Requirement must have at least one scenario
```
**What Happened:**
Formatted scenarios as bullet lists under a bold "Scenarios:" label:
```markdown
**Scenarios:**
- **WHEN** executing command
- **THEN** expected behavior
```
**Root Cause:**
OpenSpec's parser expects scenarios to be defined as level 4 headers (`####`) with specific formatting:
```markdown
#### Scenario: Descriptive scenario name
- **WHEN** executing command
- **THEN** expected behavior
```
**Discovery Process:**
- Checked parsed JSON output: `npx openspec change show bulk-validation-interactive-selection --json`
- Saw `"scenarios": []` empty array despite having scenario content
- Examined working spec files and found the `#### Scenario:` header pattern
### Error 4: File Path Confusion
**Initial Confusion:**
Wasn't clear whether to create specs that would become part of the main `openspec/specs/` or just define them in the change.
**Resolution:**
Learned that changes can:
1. Create new specs (they start in `changes/{change-name}/specs/` and move to `openspec/specs/` when archived)
2. Modify existing specs (by creating a spec file with the same name as one in `openspec/specs/`)
3. The validation system detects both patterns and creates appropriate deltas
---
## System Issues vs User Errors
### System Issues / Bugs
#### 1. Unhelpful Error Messages ⚠️
**Issue:** `✗ [ERROR] deltas: Change must have at least one delta`
**Why This Is a System Problem:**
- Error message provides no guidance on HOW to create deltas
- Doesn't mention that deltas come from `specs/` subdirectory
- Doesn't explain the required section headers
- A better error would be: "No deltas found. Ensure your change has a specs/ directory with .md files containing sections like '## ADDED Requirements'"
#### 2. Silent Scenario Parsing Failures ⚠️
**Issue:** When scenarios were formatted incorrectly, they were silently ignored
**Why This Is a System Problem:**
- Parser silently returns empty scenarios array instead of warning
- No validation error explaining the format issue
- User gets "Requirement must have at least one scenario" without knowing their scenarios exist but aren't parsed
**Evidence:**
```json
{
"text": "The CLI SHALL provide a top-level `show` command with interactive selection.",
"scenarios": [] // Silent failure - scenarios existed but weren't parsed
}
```
#### 3. No Validation for Proposal Structure During Creation
**Issue:** System allows creating invalid proposals without early feedback
**Why This Is a System Problem:**
- No scaffolding or template commands
- No incremental validation as you build
- Must fully create the change before discovering structural issues
### User Errors (My Mistakes)
#### 1. Trying to Define Deltas in Proposal.md
- Incorrectly assumed deltas could be inline in the proposal
- System correctly expects deltas in separate spec files
#### 2. Using Wrong Section Headers
- Used `## Requirements` instead of `## ADDED Requirements`
- Convention is documented in existing changes, but I didn't examine carefully
#### 3. Wrong Scenario Format
- Used bullet lists instead of `#### Scenario:` headers
- Made assumptions instead of checking existing patterns
### Gray Areas
1. **Documentation gaps** - While examples exist, there's no comprehensive "How to Create a Change" guide
2. **Lack of tooling** - No scaffolding commands to create properly structured changes
---
## Documentation Gaps Analysis
### Critical Gaps in openspec/README.md
#### 1. Scenario Format - COMPLETELY MISSING ⚠️
**What README Shows:** No scenario examples at all
**What's Actually Required:**
```markdown
#### Scenario: Descriptive name
- **WHEN** condition
- **THEN** expected outcome
- **AND** additional outcomes
```
**Impact:** This was the biggest struggle. The README mentions requirements but never shows how to write scenarios. Without this, requirements fail validation.
#### 2. Complete Spec File Example - MISSING
**What README Shows:** Only fragments
**What's Actually Needed:** A complete working example showing:
- Full spec file structure
- Proper requirement format
- Scenario formatting
- All required elements
#### 3. Validation Commands - NOT MENTIONED
**Missing from README:**
- `npx openspec change validate <change-name>`
- `npx openspec change show <change-name> --json`
- The `--strict` flag for catching warnings
#### 4. Delta Detection Explanation - INCOMPLETE
**What's Missing:**
- WHERE the system looks for specs (specs/ subdirectory)
- THAT deltas are automatically extracted
- HOW to debug when deltas aren't detected
- WHAT error messages mean
### Misleading Documentation
#### "Store only the changes" - MISLEADING
**Line 145:** `# - Store only the changes (not complete future state)`
**Problem:** This suggests storing diffs or partial content. In reality, you need:
- Complete requirements in their final form
- Full scenario definitions
- The entire requirement text
### Documentation That Was Helpful
1. Delta section headers (`## ADDED Requirements`) - clearly documented
2. Directory structure - excellent visualization
3. When to create proposals - well defined
---
## Key Learnings
### 1. OpenSpec's Delta Detection Algorithm
The system follows this process:
1. Scans `openspec/changes/{change-name}/specs/` directory
2. For each spec file found, parses for delta sections (`ADDED`, `MODIFIED`, `REMOVED`, `RENAMED`)
3. Creates delta objects with operation type, affected spec, and requirements
4. Validates that at least one delta exists for the change to be valid
**Important:** Creating entirely new specs is fully supported! New specs use `## ADDED Requirements` and appear as ADDED operations in the deltas.
### 2. Spec File Structure Requirements
Valid spec files must follow this structure:
```markdown
# Spec Title
## [ADDED|MODIFIED|REMOVED|RENAMED] Requirements
### Requirement: Clear requirement statement
The requirement description using SHALL/SHOULD/MAY.
#### Scenario: Scenario name
- **WHEN** condition
- **THEN** expected outcome
- **AND** additional outcomes
```
### 3. Change Proposal Structure
A valid change must have:
- `## Why` section - explaining the motivation
- `## What Changes` section - summarizing the changes
- `specs/` directory with properly formatted spec files containing deltas
- Each delta must have at least one requirement with at least one scenario
### 4. Validation Commands Are Essential
```bash
# Basic validation
npx openspec change validate {change-name}
# Strict validation (recommended)
npx openspec change validate {change-name} --strict
# Debug delta detection
npx openspec change show {change-name} --json | jq '.deltas'
```
---
## Recommendations
### High Priority (System Bugs to Fix)
1. **Improve Error Messages**
- Add actionable guidance to error messages
- Example: "No deltas found. Check: 1) specs/ directory exists, 2) Files use ## ADDED Requirements headers, 3) Each requirement has #### Scenario: sections"
2. **Add Warnings for Malformed Content**
- Warn when scenarios exist but aren't properly formatted
- Show which line/file has the issue
3. **Add Delta Detection Debugging**
- Command like `openspec change debug-deltas {change-name}`
- Show which files were scanned, what was found, what was rejected
### Medium Priority (Documentation Improvements)
1. **Add Complete Working Example to README**
- Full change proposal with all files
- Properly formatted specs with scenarios
- Show the validation output
2. **Add Troubleshooting Section**
- Common errors and their solutions
- How to debug delta detection
- Scenario formatting requirements
3. **Add Validation Best Practices**
- When to use `--strict`
- How to use JSON output for debugging
- Common validation patterns
### Low Priority (Developer Experience)
1. **Add Scaffolding Command**
```bash
openspec change scaffold {change-name}
```
- Creates proper directory structure
- Includes template files with correct formatting
- Adds example scenarios
2. **Add Interactive Creation Wizard**
- Guide users through change creation
- Validate as they go
- Suggest fixes for common issues
3. **Add Auto-fix Capability**
- `--fix` flag to correct common formatting issues
- Convert bullet list scenarios to proper headers
- Add missing operation prefixes
---
## Conclusion
The OpenSpec system works correctly for its intended design, but the user experience for creating changes needs significant improvement. The core issues stem from:
### System Issues
- **Unhelpful error messages** that don't guide users to solutions
- **Silent parsing failures** that provide no feedback about malformed content
- **Lack of debugging tools** to understand what went wrong
### Documentation Issues
- **Critical formatting requirements missing** (especially scenario format)
- **No complete working examples** showing all required elements
- **Validation commands not documented** despite being essential
### User Issues
- **Incorrect assumptions** about how the system works
- **Not examining existing patterns** carefully enough
- **Trying to shortcut** instead of following established conventions
### The Path Forward
With better error messages, complete documentation, and basic tooling support, most of the errors encountered could be prevented. The system's delta-centric approach is powerful and ensures changes are atomic and trackable, but it needs to be more discoverable and user-friendly.
The most impactful improvements would be:
1. Adding scenario format documentation to the README
2. Improving error messages with actionable guidance
3. Creating a scaffolding command for new changes
These changes would transform OpenSpec from a system that works correctly but is hard to use, into one that actively helps developers succeed.
+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
@@ -6,11 +6,8 @@ OpenSpec change proposals currently can only be viewed as markdown files, creati
## What Changes
- Add new `openspec change` command with three subcommands: `show`, `list`, and `validate`
- Implement JSON output capability for change proposals
- Add Zod schemas for change structure validation
- Maintain backward compatibility with existing `openspec list` command
- Enable filtering options specific to changes (requirements-only view)
- **cli-change:** Add new command for managing change proposals with show, list, and validate subcommands
- **cli-list:** Add deprecation notice for legacy list command to guide users to the new change list command
## Impact
+24 -24
View File
@@ -1,34 +1,34 @@
# Implementation Tasks (Phase 2: Builds on add-zod-validation)
## 1. Command Implementation
- [ ] 1.1 Create src/commands/change.ts
- [ ] 1.2 Import ChangeSchema and DeltaSchema from src/core/schemas/change.schema.ts
- [ ] 1.3 Import markdown parser from src/core/parsers/markdown-parser.ts
- [ ] 1.4 Import ChangeValidator from src/core/validation/validator.ts
- [ ] 1.5 Import JSON converter from src/core/converters/json-converter.ts
- [ ] 1.6 Implement show subcommand with JSON output using existing converter
- [ ] 1.7 Implement list subcommand
- [ ] 1.8 Implement validate subcommand using existing ChangeValidator
- [ ] 1.9 Add --requirements-only filtering option
- [ ] 1.10 Add --strict mode support (leveraging existing validation infrastructure)
- [ ] 1.11 Add --json flag for validation reports
- [x] 1.1 Create src/commands/change.ts
- [x] 1.2 Import ChangeSchema and DeltaSchema from src/core/schemas/change.schema.ts
- [x] 1.3 Import markdown parser from src/core/parsers/markdown-parser.ts
- [x] 1.4 Import ChangeValidator from src/core/validation/validator.ts
- [x] 1.5 Import JSON converter from src/core/converters/json-converter.ts
- [x] 1.6 Implement show subcommand with JSON output using existing converter
- [x] 1.7 Implement list subcommand
- [x] 1.8 Implement validate subcommand using existing ChangeValidator
- [x] 1.9 Add --requirements-only filtering option
- [x] 1.10 Add --strict mode support (leveraging existing validation infrastructure)
- [x] 1.11 Add --json flag for validation reports
## 2. Change-Specific Parser Extensions
- [ ] 2.1 Create src/core/parsers/change-parser.ts (extends base markdown parser)
- [ ] 2.2 Parse proposal structure (Why, What Changes sections)
- [ ] 2.3 Extract ADDED/MODIFIED/REMOVED/RENAMED sections
- [ ] 2.4 Parse delta operations within each section
- [ ] 2.5 Add tests for change parser
- [x] 2.1 Create src/core/parsers/change-parser.ts (extends base markdown parser)
- [x] 2.2 Parse proposal structure (Why, What Changes sections)
- [x] 2.3 Extract ADDED/MODIFIED/REMOVED/RENAMED sections
- [x] 2.4 Parse delta operations within each section
- [x] 2.5 Add tests for change parser
## 3. Legacy Compatibility
- [ ] 3.1 Update src/core/list.ts to add deprecation notice
- [ ] 3.2 Ensure existing list command continues to work
- [ ] 3.3 Add console warning for deprecated command usage
- [x] 3.1 Update src/core/list.ts to add deprecation notice
- [x] 3.2 Ensure existing list command continues to work
- [x] 3.3 Add console warning for deprecated command usage
## 4. Integration
- [ ] 4.1 Register change command in src/cli/index.ts
- [x] 4.1 Register change command in src/cli/index.ts
- [ ] 4.2 Add integration tests for all subcommands
- [ ] 4.3 Test JSON output for changes
- [ ] 4.4 Test legacy compatibility
- [ ] 4.5 Test validation with strict mode
- [ ] 4.6 Update CLI help documentation (add 'change' command to main help, document subcommands: show, list, validate)
- [x] 4.3 Test JSON output for changes
- [x] 4.4 Test legacy compatibility
- [x] 4.5 Test validation with strict mode
- [x] 4.6 Update CLI help documentation (add 'change' command to main help, document subcommands: show, list, validate)
@@ -0,0 +1,20 @@
## Why
Users frequently need to view changes and specs but must know in advance whether they're looking at a change or spec. The current subcommand structure (`change show`, `spec show`) creates friction when:
- Users want to quickly view an item without remembering its type
- Exploring the codebase requires switching between different show commands
- Show commands without arguments return errors instead of helpful guidance
## What Changes
- Add new top-level `show` command for displaying changes or specs with intelligent selection
- Support direct item display: `openspec show <item>` with automatic type detection
- Interactive selection when no arguments provided
- Enhance existing `change show` and `spec show` to support interactive selection (backwards compatibility)
- Maintain all existing format options (--json, --deltas-only, --requirements, etc.)
## Impact
- New specs to create: cli-show
- Specs to enhance: cli-change, cli-spec (for backwards compatibility)
- Affected code: src/cli/index.ts, src/commands/show.ts (new), src/commands/spec.ts, src/commands/change.ts
@@ -0,0 +1,23 @@
# CLI Change Command Spec
## ADDED Requirements
### Requirement: Interactive show selection
The change show command SHALL support interactive selection when no change name is provided.
#### Scenario: Interactive change selection for show
- **WHEN** executing `openspec change show` without arguments
- **THEN** display an interactive list of available changes
- **AND** allow the user to select a change to show
- **AND** display the selected change content
- **AND** maintain all existing show options (--json, --deltas-only)
#### Scenario: Non-interactive fallback keeps current behavior
- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`
- **WHEN** executing `openspec change show` without a change name
- **THEN** do not prompt interactively
- **AND** print the existing hint including available change IDs
- **AND** set `process.exitCode = 1`
@@ -0,0 +1,83 @@
# CLI Show Command Spec
## ADDED Requirements
### Requirement: Top-level show command
The CLI SHALL provide a top-level `show` command for displaying changes and specs with intelligent selection.
#### Scenario: Interactive show selection
- **WHEN** executing `openspec show` without arguments
- **THEN** prompt user to select type (change or spec)
- **AND** display list of available items for selected type
- **AND** show the selected item's content
#### Scenario: Non-interactive environments do not prompt
- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`
- **WHEN** executing `openspec show` without arguments
- **THEN** do not prompt
- **AND** print a helpful hint with examples for `openspec show <item>` or `openspec change/spec show`
- **AND** exit with code 1
#### Scenario: Direct item display
- **WHEN** executing `openspec show <item-name>`
- **THEN** automatically detect if item is a change or spec
- **AND** display the item's content
- **AND** use appropriate formatting based on item type
#### Scenario: Type detection and ambiguity handling
- **WHEN** executing `openspec show <item-name>`
- **THEN** if `<item-name>` uniquely matches a change or a spec, show that item
- **AND** if it matches both, print an ambiguity error and suggest `--type change|spec` or using `openspec change show`/`openspec spec show`
- **AND** if it matches neither, print not-found with nearest-match suggestions
#### Scenario: Explicit type override
- **WHEN** executing `openspec show --type change <item>`
- **THEN** treat `<item>` as a change ID and show it (skipping auto-detection)
- **WHEN** executing `openspec show --type spec <item>`
- **THEN** treat `<item>` as a spec ID and show it (skipping auto-detection)
### Requirement: Output format options
The show command SHALL support various output formats consistent with existing commands.
#### Scenario: JSON output
- **WHEN** executing `openspec show <item> --json`
- **THEN** output the item in JSON format
- **AND** include parsed metadata and structure
- **AND** maintain format consistency with existing change/spec show commands
#### Scenario: Flag scoping and delegation
- **WHEN** showing a change or a spec via the top-level command
- **THEN** accept common flags such as `--json`
- **AND** pass through type-specific flags to the corresponding implementation
- Change-only flags: `--deltas-only` (alias `--requirements-only` deprecated)
- Spec-only flags: `--requirements`, `--no-scenarios`, `-r/--requirement`
- **AND** ignore irrelevant flags for the detected type with a warning
### Requirement: Interactivity controls
- The CLI SHALL respect `--no-interactive` to disable prompts.
- The CLI SHALL respect `OPEN_SPEC_INTERACTIVE=0` to disable prompts globally.
- Interactive prompts SHALL only be shown when stdin is a TTY and interactivity is not disabled.
#### Scenario: Change-specific options
- **WHEN** showing a change with `openspec show <change-name> --deltas-only`
- **THEN** display only the deltas in JSON format
- **AND** maintain compatibility with existing change show options
#### Scenario: Spec-specific options
- **WHEN** showing a spec with `openspec show <spec-id> --requirements`
- **THEN** display only requirements in JSON format
- **AND** support other spec options (--no-scenarios, -r)
- **AND** maintain compatibility with existing spec show options
@@ -0,0 +1,23 @@
# CLI Spec Command Spec
## ADDED Requirements
### Requirement: Interactive spec show
The spec show command SHALL support interactive selection when no spec-id is provided.
#### Scenario: Interactive spec selection for show
- **WHEN** executing `openspec spec show` without arguments
- **THEN** display an interactive list of available specs
- **AND** allow the user to select a spec to show
- **AND** display the selected spec content
- **AND** maintain all existing show options (--json, --requirements, --no-scenarios, -r)
#### Scenario: Non-interactive fallback keeps current behavior
- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`
- **WHEN** executing `openspec spec show` without a spec-id
- **THEN** do not prompt interactively
- **AND** print the existing error message for missing spec-id
- **AND** set non-zero exit code
@@ -164,4 +164,28 @@ The command SHALL handle various error conditions gracefully.
**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified
**Non-blocking confirmation**: Declining spec updates doesn't cancel archiving - users can review specs and choose to update them separately if needed
**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use
**--skip-specs flag**: Enables archiving of changes that don't modify specs (like infrastructure, tooling, or documentation changes) without unnecessary spec update prompts or operations
**--skip-specs flag**: Enables archiving of changes that don't modify specs (like infrastructure, tooling, or documentation changes) without unnecessary spec update prompts or operations
## ADDED Requirements
### Requirement: Skip Specs Option
The archive command SHALL support a `--skip-specs` flag that skips all spec update operations and proceeds directly to archiving.
#### Scenario: Skipping spec updates with flag
- **WHEN** executing `openspec archive <change> --skip-specs`
- **THEN** skip spec discovery and update confirmation
- **AND** proceed directly to moving the change to archive
- **AND** display a message indicating specs were skipped
### Requirement: Non-blocking confirmation
The archive operation SHALL proceed when the user declines spec updates instead of cancelling the entire operation.
#### Scenario: User declines spec update confirmation
- **WHEN** the user declines spec update confirmation
- **THEN** skip spec updates
- **AND** continue with the archive operation
- **AND** display a success message indicating specs were not updated
@@ -16,4 +16,4 @@ Currently, OpenSpec specs can only be viewed as markdown files. This makes progr
- **Affected specs**: None (new capability)
- **Affected code**:
- src/cli/index.ts (register new command)
- package.json (add zod dependency)
- package.json (add zod dependency)
@@ -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.
@@ -4,8 +4,16 @@
### Requirement: Display Format
The diff command SHALL display unified diff output in text format.
**Reason for removal**: The standard unified diff format is replaced by requirement-level side-by-side comparison that better shows semantic changes rather than line-by-line text differences.
#### Scenario: Unified diff output (deprecated)
- **WHEN** running `openspec diff <change>`
- **THEN** show a unified text diff of files
- **AND** include `+`/`-` prefixed lines representing additions and removals
## MODIFIED Requirements
### Requirement: Diff Output
@@ -104,6 +104,14 @@ The archive process SHALL programmatically apply delta changes to current specif
### Requirement: Future State Storage
The system SHALL no longer store complete future-state specifications in change proposals.
**Reason for removal**: Replaced by delta-based change storage which provides better review experience and clearer change tracking.
**Migration path**: All new changes must use delta format.
**Migration path**: All new changes must use delta format.
#### Scenario: Deprecate future state storage
- **WHEN** creating a new change proposal
- **THEN** do not include full future-state specs
- **AND** include only ADDED/MODIFIED/REMOVED/RENAMED requirements under the change's `specs/` directory
@@ -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
@@ -0,0 +1,20 @@
## Why
Currently, users must validate changes and specs individually by specifying each ID. This creates friction when:
- Teams want to validate all changes/specs before a release
- Developers need to ensure consistency across multiple related changes
- Users run validation commands without arguments and receive errors instead of helpful guidance
- The subcommand structure requires users to know in advance whether they're validating a change or spec
## What Changes
- Add new top-level `validate` command with intuitive flags (--all, --changes, --specs)
- Enhance existing `change validate` and `spec validate` to support interactive selection (backwards compatibility)
- Interactive selection by default when no arguments provided
- Support direct item validation: `openspec validate <item>` with automatic type detection
## Impact
- New specs to create: cli-validate
- Specs to enhance: cli-change, cli-spec (for backwards compatibility)
- Affected code: src/cli/index.ts, src/commands/validate.ts (new), src/commands/spec.ts, src/commands/change.ts
@@ -0,0 +1,22 @@
# CLI Change Command Spec
## ADDED Requirements
### Requirement: Interactive validation selection
The change validate command SHALL support interactive selection when no change name is provided.
#### Scenario: Interactive change selection for validation
- **WHEN** executing `openspec change validate` without arguments
- **THEN** display an interactive list of available changes
- **AND** allow the user to select a change to validate
- **AND** validate the selected change
#### Scenario: Non-interactive fallback keeps current behavior
- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`
- **WHEN** executing `openspec change validate` without a change name
- **THEN** do not prompt interactively
- **AND** print the existing hint including available change IDs
- **AND** set `process.exitCode = 1`
@@ -0,0 +1,23 @@
# CLI Spec Command Spec
## ADDED Requirements
### Requirement: Interactive spec validation
The spec validate command SHALL support interactive selection when no spec-id is provided.
#### Scenario: Interactive spec selection for validation
- **WHEN** executing `openspec spec validate` without arguments
- **THEN** display an interactive list of available specs
- **AND** allow the user to select a spec to validate
- **AND** validate the selected spec
- **AND** maintain all existing validation options (--strict, --json)
#### Scenario: Non-interactive fallback keeps current behavior
- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`
- **WHEN** executing `openspec spec validate` without a spec-id
- **THEN** do not prompt interactively
- **AND** print the existing error message for missing spec-id
- **AND** set non-zero exit code
@@ -0,0 +1,141 @@
# CLI Validate Command Spec
## ADDED Requirements
### Requirement: Top-level validate command
The CLI SHALL provide a top-level `validate` command for validating changes and specs with flexible selection options.
#### Scenario: Interactive validation selection
- **WHEN** executing `openspec validate` without arguments
- **THEN** prompt user to select what to validate (all, changes, specs, or specific item)
- **AND** perform validation based on selection
- **AND** display results with appropriate formatting
#### Scenario: Non-interactive environments do not prompt
- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0`
- **WHEN** executing `openspec validate` without arguments
- **THEN** do not prompt interactively
- **AND** print a helpful hint listing available commands/flags and exit with code 1
#### Scenario: Direct item validation
- **WHEN** executing `openspec validate <item-name>`
- **THEN** automatically detect if item is a change or spec
- **AND** validate the specified item
- **AND** display validation results
### Requirement: Bulk and filtered validation
The validate command SHALL support flags for bulk validation (--all) and filtered validation by type (--changes, --specs).
#### Scenario: Validate everything
- **WHEN** executing `openspec validate --all`
- **THEN** validate all changes in openspec/changes/ (excluding archive)
- **AND** validate all specs in openspec/specs/
- **AND** display a summary showing passed/failed items
- **AND** exit with code 1 if any validation fails
#### Scenario: Scope of bulk validation
- **WHEN** validating with `--all` or `--changes`
- **THEN** include all change proposals under `openspec/changes/`
- **AND** exclude the `openspec/changes/archive/` directory
- **WHEN** validating with `--specs`
- **THEN** include all specs that have a `spec.md` under `openspec/specs/<id>/spec.md`
#### Scenario: Validate all changes
- **WHEN** executing `openspec validate --changes`
- **THEN** validate all changes in openspec/changes/ (excluding archive)
- **AND** display results for each change
- **AND** show summary statistics
#### Scenario: Validate all specs
- **WHEN** executing `openspec validate --specs`
- **THEN** validate all specs in openspec/specs/
- **AND** display results for each spec
- **AND** show summary statistics
### Requirement: Validation options and progress indication
The validate command SHALL support standard validation options (--strict, --json) and display progress during bulk operations.
#### Scenario: Strict validation
- **WHEN** executing `openspec validate --all --strict`
- **THEN** apply strict validation to all items
- **AND** treat warnings as errors
- **AND** fail if any item has warnings or errors
#### Scenario: JSON output
- **WHEN** executing `openspec validate --all --json`
- **THEN** output validation results as JSON
- **AND** include detailed issues for each item
- **AND** include summary statistics
#### Scenario: JSON output schema for bulk validation
- **WHEN** executing `openspec validate --all --json` (or `--changes` / `--specs`)
- **THEN** output a JSON object with the following shape:
- `items`: Array of objects with fields `{ id: string, type: "change"|"spec", valid: boolean, issues: Issue[], durationMs: number }`
- `summary`: Object `{ totals: { items: number, passed: number, failed: number }, byType: { change?: { items: number, passed: number, failed: number }, spec?: { items: number, passed: number, failed: number } } }`
- `version`: String identifier for the schema (e.g., `"1.0"`)
- **AND** exit with code 1 if any `items[].valid === false`
Where `Issue` follows the existing per-item validation report shape `{ level: "ERROR"|"WARNING"|"INFO", path: string, message: string }`.
#### Scenario: Show validation progress
- **WHEN** validating multiple items (--all, --changes, or --specs)
- **THEN** show progress indicator or status updates
- **AND** indicate which item is currently being validated
- **AND** display running count of passed/failed items
#### Scenario: Concurrency limits for performance
- **WHEN** validating multiple items
- **THEN** run validations with a bounded concurrency (e.g., 4–8 in parallel)
- **AND** ensure progress indicators remain responsive
### Requirement: Item type detection and ambiguity handling
#### Scenario: Direct item validation with automatic type detection
- **WHEN** executing `openspec validate <item-name>`
- **THEN** if `<item-name>` uniquely matches a change or a spec, validate that item
#### Scenario: Ambiguity between change and spec names
- **GIVEN** `<item-name>` exists both as a change and as a spec
- **WHEN** executing `openspec validate <item-name>`
- **THEN** print an ambiguity error explaining both matches
- **AND** suggest passing `--type change` or `--type spec`, or using `openspec change validate` / `openspec spec validate`
- **AND** exit with code 1 without performing validation
#### Scenario: Unknown item name
- **WHEN** the `<item-name>` matches neither a change nor a spec
- **THEN** print a not-found error
- **AND** show nearest-match suggestions when available
- **AND** exit with code 1
#### Scenario: Explicit type override
- **WHEN** executing `openspec validate --type change <item>`
- **THEN** treat `<item>` as a change ID and validate it (skipping auto-detection)
- **WHEN** executing `openspec validate --type spec <item>`
- **THEN** treat `<item>` as a spec ID and validate it (skipping auto-detection)
### Requirement: Interactivity controls
- The CLI SHALL respect `--no-interactive` to disable prompts.
- The CLI SHALL respect `OPEN_SPEC_INTERACTIVE=0` to disable prompts globally.
- Interactive prompts SHALL only be shown when stdin is a TTY and interactivity is not disabled.
@@ -0,0 +1,81 @@
# Implementation Tasks
## 1. Change Command: Interactive Validation Selection
- [x] 1.1 Add `--no-interactive` flag to `change validate` in `src/cli/index.ts`
- [x] 1.2 Implement interactivity gate respecting TTY and `OPEN_SPEC_INTERACTIVE=0` in `src/commands/change.ts`
- [x] 1.3 When no `[change-name]` is provided and interactivity is allowed, prompt with a list of active changes (exclude `archive/`) and validate the selected one
- [x] 1.4 Preserve current non-interactive fallback: print available change IDs and hint, set `process.exitCode = 1`
- [x] 1.5 Tests: add coverage for interactive and non-interactive flows
- Added `test/commands/change.interactive-validate.test.ts`
## 2. Spec Command: Interactive Validation Selection
- [x] 2.1 Make `spec validate` accept optional `[spec-id]` in `src/commands/spec.ts` registration
- [x] 2.2 Add `--no-interactive` flag to `spec validate`
- [x] 2.3 Implement interactivity gate respecting TTY and `OPEN_SPEC_INTERACTIVE=0`
- [x] 2.4 When no `[spec-id]` provided and interactivity allowed, prompt to select from `openspec/specs/*/spec.md` and validate the selected spec
- [x] 2.5 Preserve current non-interactive fallback when no spec-id and no interactivity: print existing error and exit code non-zero
- [x] 2.6 Tests: add coverage for interactive and non-interactive flows
- Added `test/commands/spec.interactive-validate.test.ts`
## 3. New Top-level `validate` Command
- [x] 3.1 Add `validate` command in `src/cli/index.ts`
- Options: `--all`, `--changes`, `--specs`, `--type <change|spec>`, `--strict`, `--json`, `--no-interactive`
- Usage: `openspec validate [item-name]`
- [x] 3.2 Create `src/commands/validate.ts` implementing:
- [x] 3.2.1 Interactive selector when no args (choices: All, Changes, Specs, Specific item)
- [x] 3.2.2 Non-interactive fallback with helpful hint and exit code 1
- [x] 3.2.3 Direct item validation with automatic type detection
- [x] 3.2.4 Ambiguity error when name exists as both change and spec; suggest `--type` or subcommands
- [x] 3.2.5 Unknown item handling with nearest-match suggestions
- [x] 3.2.6 Bulk validation for `--all`, `--changes`, `--specs` (exclude `openspec/changes/archive/`)
- [x] 3.2.7 Respect `--strict` and `--json` options; JSON shape per spec
- [x] 3.2.8 Exit with code 1 if any validation fails
- [x] 3.2.9 Bounded concurrency (default 4–8) for bulk validation
- [x] 3.2.10 Progress indication during bulk runs (current item, running counts)
## 4. Utilities and Shared Helpers
- [x] 4.1 Add `src/utils/interactive.ts` with `isInteractive(stdin: NodeJS.ReadStream, noInteractiveFlag?: boolean): boolean`
- Considers: `process.stdin.isTTY`, `--no-interactive`, `OPEN_SPEC_INTERACTIVE=0`
- [x] 4.2 Add `src/utils/item-discovery.ts` with:
- `getActiveChangeIds(root = process.cwd()): Promise<string[]>` (exclude `archive/`)
- `getSpecIds(root = process.cwd()): Promise<string[]>` (folders with `spec.md`)
- [ ] 4.3 Optional: `src/utils/concurrency.ts` helper for bounded parallelism
- [x] 4.4 Reuse `src/core/validation/validator.ts` for item validation
## 5. JSON Output (Bulk Validation)
- [x] 5.1 Implement JSON schema:
- `items: Array<{ id: string, type: "change"|"spec", valid: boolean, issues: Issue[], durationMs: number }>`
- `summary: { totals: { items: number, passed: number, failed: number }, byType: { change?: { items: number, passed: number, failed: number }, spec?: { items: number, passed: number, failed: number } } }`
- `version: "1.0"`
- [x] 5.2 Ensure process exit code is 1 if any `items[].valid === false`
- [x] 5.3 Tests for JSON shape (keys, types, counts) and exit code behavior
- Added `test/commands/validate.test.ts`
## 6. Progress and UX
- [x] 6.1 Use `ora` or minimal console progress to show current item and running counts
- [x] 6.2 Keep output stable in `--json` mode (no extra logs to stdout; use stderr for progress if needed)
- [x] 6.3 Ensure responsiveness with concurrency limits
## 7. Tests
- [x] 7.1 Add top-level validate tests: `test/commands/validate.test.ts`
- Includes non-interactive hint, --all JSON, --specs with concurrency, ambiguity error
- [ ] 7.2 Add unit tests for `isInteractive` and item discovery helpers
- [x] 7.3 Extend existing change/spec command tests to cover interactive `validate`
- Added `test/commands/change.interactive-validate.test.ts`, `test/commands/spec.interactive-validate.test.ts`
## 8. CLI Help and Docs
- [x] 8.1 Update command descriptions/options in `src/cli/index.ts`
- [x] 8.2 Verify help output includes `validate` command and flags
- [x] 8.3 Ensure existing specs under `openspec/changes/bulk-validation-interactive-selection/specs/*` remain satisfied
## 9. Non-functional
- [x] 9.1 Code style and types: explicit types for exported APIs; avoid `any`
- [x] 9.2 No linter errors; stable formatting; avoid unrelated refactors
- [x] 9.3 Maintain existing behavior for unaffected commands
## 10. Acceptance Criteria Mapping
- [x] AC-1: `openspec change validate` interactive selection when no arg (TTY only; respects `--no-interactive`/env) — matches cli-change spec
- [x] AC-2: `openspec spec validate` interactive selection when no arg (TTY only; respects `--no-interactive`/env) — matches cli-spec spec
- [x] AC-3: New `openspec validate` supports interactive selection, bulk/filtered validation, JSON schema, progress, concurrency, exit codes — matches cli-validate spec
@@ -25,4 +25,16 @@ Modify the update command to:
- Update command only modifies existing AI tool configuration files
- No new AI tool files created during update
- Team members can use different AI tools without conflicts
- Existing projects continue to work (backward compatibility)
- Existing projects continue to work (backward compatibility)
## Why
Users need predictable, tool-agnostic behavior from `openspec update`. Creating or forcing updates for AI tool files that a project does not use causes confusion and merge conflicts. Restricting updates to existing files and always updating core OpenSpec files keeps the workflow consistent for mixed-tool teams.
## What Changes
- **cli-update:** Modify update behavior to update only existing AI tool configuration files and never create new ones; always update core OpenSpec files and display an ASCII-safe success message.
## ADDED Requirements
Removed from proposal to follow conventions. See `specs/cli-update/spec.md` for the delta requirements content.
@@ -1,113 +1,23 @@
# Update Command Specification
## Purpose
As a developer using OpenSpec, I want to update the OpenSpec instructions in my project when new versions are released, so that I can benefit from improvements to AI agent instructions.
## Core Requirements
### Requirement: Update Behavior
The update command SHALL update OpenSpec instruction files to the latest templates.
#### Scenario: Running update command
- **WHEN** a user runs `openspec update`
- **THEN** the command SHALL:
- Check if the `openspec` directory exists
- Replace `openspec/README.md` with the latest template (complete replacement)
- For each supported AI tool configuration file:
- Check if the file exists (e.g., CLAUDE.md, COPILOT.md)
- If it exists, update it using appropriate markers
- If it doesn't exist, skip it (do NOT create)
- Preserve user content outside markers
- Display ASCII-safe success message: "Updated OpenSpec instructions"
### Requirement: Prerequisites
The command SHALL require an existing OpenSpec structure before allowing updates.
#### Scenario: Checking prerequisites
- **GIVEN** the command requires an existing `openspec` directory (created by `openspec init`)
- **WHEN** the `openspec` directory does not exist
- **THEN** display error: "No OpenSpec directory found. Run 'openspec init' first."
- **AND** exit with code 1
### Requirement: File Handling
The update command SHALL handle file updates in a predictable and safe manner.
#### Scenario: Updating files
- **WHEN** updating files
- **THEN** completely replace `openspec/README.md` with the latest template
- **AND** update only the AI tool configuration files that already exist
- **AND** use the default directory name `openspec`
- **AND** be idempotent (repeated runs have no additional effect)
## ADDED Requirements
### Requirement: Tool-Agnostic Updates
The update command SHALL work for any team member regardless of their AI tool choice.
The update command SHALL update only existing AI tool configuration files and SHALL NOT create new ones.
#### Scenario: Team member using Claude
#### Scenario: Updating existing tool files
- **GIVEN** a team member has CLAUDE.md in their project
- **WHEN** running `openspec update`
- **THEN** update the CLAUDE.md file with the latest template
- **AND** preserve user content outside OpenSpec markers
- **AND** NOT create files for other tools
#### Scenario: Team member using different tool
- **GIVEN** a team member has COPILOT.md but no CLAUDE.md
- **WHEN** running `openspec update`
- **THEN** update the COPILOT.md file if implementation exists
- **AND** NOT create CLAUDE.md
- **WHEN** a user runs `openspec update`
- **THEN** update each AI tool configuration file that exists (e.g., CLAUDE.md, COPILOT.md)
- **AND** do not create missing tool configuration files
- **AND** preserve user content outside OpenSpec markers
#### Scenario: Mixed team environment
### Requirement: Core Files Always Updated
- **GIVEN** a repository with both CLAUDE.md and COPILOT.md (different team members)
- **WHEN** any team member runs `openspec update`
- **THEN** update all existing AI tool configuration files
- **AND** NOT create new AI tool configuration files
- **AND** each team member's preferred tool remains configured
The update command SHALL always update the core OpenSpec files and display an ASCII-safe success message.
## Edge Cases
#### Scenario: Successful update
### Requirement: Error Handling
The command SHALL handle edge cases gracefully.
#### Scenario: File permission errors
- **WHEN** file write fails
- **THEN** let the error bubble up naturally with file path
#### Scenario: No AI tool files exist
- **GIVEN** no AI tool configuration files exist
- **WHEN** running update
- **THEN** only update openspec/README.md
- **AND** display success message
#### Scenario: Custom directory names
- **WHEN** considering custom directory names
- **THEN** not supported in this change
- **AND** the default directory name `openspec` SHALL be used
## Success Criteria
Users SHALL be able to:
- Update OpenSpec instructions with a single command
- Get the latest AI agent instructions for their existing tools
- Work in teams where members use different AI tools
- NOT have unwanted AI tool configuration files created
The update process SHALL be:
- Simple and fast (no version checking)
- Predictable (same result every time)
- Self-contained (no network required)
- Team-friendly (respects individual tool choices)
- **WHEN** the update completes successfully
- **THEN** replace `openspec/README.md` with the latest template
- **AND** update existing AI tool configuration files within markers
- **AND** display the message: "Updated OpenSpec instructions"
@@ -33,4 +33,4 @@ OpenSpec specifications lack a consistent structure that makes sections visually
- Affected specs: openspec-conventions (enhancement to existing capability)
- Affected code: None initially - this is a documentation standard enhancement
- Migration: Gradual - existing specs migrate as they're modified
- Tooling: Enables future parsing tools but doesn't require them
- Tooling: Enables future parsing tools but doesn't require them
@@ -1,5 +1,17 @@
# OpenSpec Conventions Specification
## ADDED Requirements
### Requirement: Structured Format Adoption
Behavioral specifications SHALL adopt the structured format with `### Requirement:` and `#### Scenario:` headers as the default.
#### Scenario: Use structured headings for behavior
- **WHEN** documenting behavioral requirements
- **THEN** use `### Requirement:` for requirements
- **AND** use `#### Scenario:` for scenarios with bold WHEN/THEN/AND keywords
## Purpose
OpenSpec conventions SHALL define how system capabilities are documented, how changes are proposed and tracked, and how specifications evolve over time. This meta-specification serves as the source of truth for OpenSpec's own conventions.
+2 -1
View File
@@ -37,7 +37,8 @@
"build": "node build.js",
"dev": "tsc --watch",
"dev:cli": "pnpm build && node bin/openspec.js",
"test": "vitest",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"prepare": "npm run build"
+90 -1
View File
@@ -8,6 +8,8 @@ import { DiffCommand } from '../core/diff.js';
import { ListCommand } from '../core/list.js';
import { ArchiveCommand } from '../core/archive.js';
import { registerSpecCommand } from '../commands/spec.js';
import { ChangeCommand } from '../commands/change.js';
import { ValidateCommand } from '../commands/validate.js';
const program = new Command();
@@ -16,6 +18,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')
@@ -80,9 +93,10 @@ program
program
.command('list')
.description('List all active changes with their task status')
.description('List all active changes with their task status (DEPRECATED: use "openspec change list" instead)')
.action(async () => {
try {
console.log('\x1b[33m%s\x1b[0m', 'Warning: The "openspec list" command is deprecated. Please use "openspec change list" instead.\n');
const listCommand = new ListCommand();
await listCommand.execute();
} catch (error) {
@@ -92,6 +106,58 @@ program
}
});
// Change command with subcommands
const changeCmd = program
.command('change')
.description('Manage OpenSpec change proposals');
changeCmd
.command('show [change-name]')
.description('Show a change proposal in JSON or markdown format')
.option('--json', 'Output as JSON')
.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.error(`Error: ${(error as Error).message}`);
process.exitCode = 1;
}
});
changeCmd
.command('list')
.description('List all active changes')
.option('--json', 'Output as JSON')
.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.error(`Error: ${(error as Error).message}`);
process.exitCode = 1;
}
});
changeCmd
.command('validate [change-name]')
.description('Validate a change proposal')
.option('--strict', 'Enable strict validation mode')
.option('--json', 'Output validation report as JSON')
.option('--no-interactive', 'Disable interactive prompts')
.action(async (changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
try {
const changeCommand = new ChangeCommand();
await changeCommand.validate(changeName, options);
} catch (error) {
console.error(`Error: ${(error as Error).message}`);
process.exitCode = 1;
}
});
program
.command('archive [change-name]')
.description('Archive a completed change and update main specs')
@@ -111,4 +177,27 @@ program
registerSpecCommand(program);
// Top-level validate command
program
.command('validate [item-name]')
.description('Validate changes and specs')
.option('--all', 'Validate all changes and specs')
.option('--changes', 'Validate all changes')
.option('--specs', 'Validate all specs')
.option('--type <type>', 'Specify item type when ambiguous: change|spec')
.option('--strict', 'Enable strict validation mode')
.option('--json', 'Output validation results as JSON')
.option('--concurrency <n>', 'Max concurrent validations (defaults to env OPENSPEC_CONCURRENCY or 6)')
.option('--no-interactive', 'Disable interactive prompts')
.action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string }) => {
try {
const validateCommand = new ValidateCommand();
await validateCommand.execute(itemName, options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
program.parse();
+260
View File
@@ -0,0 +1,260 @@
import { promises as fs } from 'fs';
import path from 'path';
import { select } from '@inquirer/prompts';
import { JsonConverter } from '../core/converters/json-converter.js';
import { Validator } from '../core/validation/validator.js';
import { ChangeParser } from '../core/parsers/change-parser.js';
import { Change } from '../core/schemas/index.js';
import { isInteractive } from '../utils/interactive.js';
import { getActiveChangeIds } from '../utils/item-discovery.js';
// Constants for better maintainability
const ARCHIVE_DIR = 'archive';
const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;
export class ChangeCommand {
private converter: JsonConverter;
constructor() {
this.converter = new JsonConverter();
}
/**
* 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) {
console.error('No change specified. No active changes found.');
} else {
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');
try {
await fs.access(proposalPath);
} catch {
throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
}
if (options?.json) {
const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
if (options.requirementsOnly) {
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 {
const output = {
id,
title,
deltaCount: deltas.length,
deltas,
};
console.log(JSON.stringify(output, null, 2));
}
} else {
const content = await fs.readFile(proposalPath, 'utf-8');
console.log(content);
}
}
/**
* 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);
if (options?.json) {
const changeDetails = await Promise.all(
changes.map(async (changeName) => {
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 changeDir = path.join(changesPath, changeName);
const parser = new ChangeParser(content, changeDir);
const change = await parser.parseChangeWithDeltas(changeName);
let taskStatus = { total: 0, completed: 0 };
try {
const tasksContent = await fs.readFile(tasksPath, 'utf-8');
taskStatus = this.countTasks(tasksContent);
} catch (error) {
// Tasks file may not exist, which is okay
if (process.env.DEBUG) {
console.error(`Failed to read tasks file at ${tasksPath}:`, error);
}
}
return {
id: changeName,
title: this.extractTitle(content),
deltaCount: change.deltas.length,
taskStatus,
};
} catch (error) {
return {
id: changeName,
title: 'Unknown',
deltaCount: 0,
taskStatus: { total: 0, completed: 0 },
};
}
})
);
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 items found');
return;
}
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 taskStatusText = '';
try {
const tasksContent = await fs.readFile(tasksPath, 'utf-8');
const { total, completed } = this.countTasks(tasksContent);
taskStatusText = ` [tasks ${completed}/${total}]`;
} catch (error) {
if (process.env.DEBUG) {
console.error(`Failed to read tasks file at ${tasksPath}:`, error);
}
}
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)`);
}
}
}
}
async validate(changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }): Promise<void> {
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
if (!changeName) {
const canPrompt = isInteractive(options?.noInteractive);
const changes = await getActiveChangeIds();
if (canPrompt && changes.length > 0) {
const selected = await select({
message: 'Select a change to validate',
choices: changes.map(id => ({ name: id, value: id })),
});
changeName = selected;
} else {
if (changes.length === 0) {
console.error('No change specified. No active changes found.');
} else {
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');
try {
await fs.access(proposalPath);
} catch {
throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
}
const validator = new Validator(options?.strict || false);
const report = await validator.validateChange(proposalPath);
if (options?.json) {
console.log(JSON.stringify(report, null, 2));
} else {
if (report.valid) {
console.log(`Change "${changeName}" is valid`);
} else {
console.error(`Change "${changeName}" has validation issues`);
report.issues.forEach(issue => {
const label = issue.level === 'ERROR' ? 'ERROR' : 'WARNING';
const prefix = issue.level === 'ERROR' ? '✗' : '⚠';
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
});
}
}
}
private async getActiveChanges(changesPath: string): Promise<string[]> {
try {
const entries = await fs.readdir(changesPath, { withFileTypes: true });
return entries
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== ARCHIVE_DIR)
.map(entry => entry.name)
.sort();
} catch {
return [];
}
}
private extractTitle(content: string): string {
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/m);
return match ? match[1].trim() : 'Untitled Change';
}
private countTasks(content: string): { total: number; completed: number } {
const lines = content.split('\n');
let total = 0;
let completed = 0;
for (const line of lines) {
if (line.match(TASK_PATTERN)) {
total++;
if (line.match(COMPLETED_TASK_PATTERN)) {
completed++;
}
}
}
return { total, completed };
}
}
+70 -91
View File
@@ -1,10 +1,12 @@
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';
import { select } from '@inquirer/prompts';
import { isInteractive } from '../utils/interactive.js';
import { getSpecIds } from '../utils/item-discovery.js';
const SPECS_DIR = 'openspec/specs';
@@ -15,9 +17,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 +60,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 +84,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 +113,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,48 +132,63 @@ 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;
}
});
specCommand
.command('validate <spec-id>')
.command('validate [spec-id]')
.description('Validate a specification structure')
.option('--strict', 'Enable strict validation mode')
.option('--json', 'Output validation report as JSON')
.action(async (specId: string, options: { strict?: boolean; json?: boolean }) => {
.option('--no-interactive', 'Disable interactive prompts')
.action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
try {
if (!specId) {
const canPrompt = isInteractive(options?.noInteractive);
const specIds = await getSpecIds();
if (canPrompt && specIds.length > 0) {
specId = await select({
message: 'Select a spec to validate',
choices: specIds.map(id => ({ name: id, value: id })),
});
} else {
throw new Error('Missing required argument <spec-id>');
}
}
const specPath = join(SPECS_DIR, specId, 'spec.md');
if (!existsSync(specPath)) {
@@ -206,36 +201,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;
}
});
+312
View File
@@ -0,0 +1,312 @@
import { select } from '@inquirer/prompts';
import ora from 'ora';
import path from 'path';
import { Validator } from '../core/validation/validator.js';
import { isInteractive } from '../utils/interactive.js';
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
type ItemType = 'change' | 'spec';
interface ExecuteOptions {
all?: boolean;
changes?: boolean;
specs?: boolean;
type?: string;
strict?: boolean;
json?: boolean;
noInteractive?: boolean;
concurrency?: string;
}
interface BulkItemResult {
id: string;
type: ItemType;
valid: boolean;
issues: { level: 'ERROR' | 'WARNING' | 'INFO'; path: string; message: string }[];
durationMs: number;
}
export class ValidateCommand {
async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise<void> {
const interactive = isInteractive(options.noInteractive);
// Handle bulk flags first
if (options.all || options.changes || options.specs) {
await this.runBulkValidation({
changes: !!options.all || !!options.changes,
specs: !!options.all || !!options.specs,
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
return;
}
// No item and no flags
if (!itemName) {
if (interactive) {
await this.runInteractiveSelector({ strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
return;
}
this.printNonInteractiveHint();
process.exitCode = 1;
return;
}
// Direct item validation with type detection or override
const typeOverride = this.normalizeType(options.type);
await this.validateDirectItem(itemName, { typeOverride, strict: !!options.strict, json: !!options.json });
}
private normalizeType(value?: string): ItemType | undefined {
if (!value) return undefined;
const v = value.toLowerCase();
if (v === 'change' || v === 'spec') return v;
return undefined;
}
private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
const choice = await select({
message: 'What would you like to validate?',
choices: [
{ name: 'All (changes + specs)', value: 'all' },
{ name: 'All changes', value: 'changes' },
{ name: 'All specs', value: 'specs' },
{ name: 'Pick a specific change or spec', value: 'one' },
],
});
if (choice === 'all') return this.runBulkValidation({ changes: true, specs: true }, opts);
if (choice === 'changes') return this.runBulkValidation({ changes: true, specs: false }, opts);
if (choice === 'specs') return this.runBulkValidation({ changes: false, specs: true }, opts);
// one
const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);
const items: { name: string; value: { type: ItemType; id: string } }[] = [];
items.push(...changes.map(id => ({ name: `change/${id}`, value: { type: 'change' as const, id } })));
items.push(...specs.map(id => ({ name: `spec/${id}`, value: { type: 'spec' as const, id } })));
if (items.length === 0) {
console.error('No items found to validate.');
process.exitCode = 1;
return;
}
const picked = await select<{ type: ItemType; id: string }>({ message: 'Pick an item', choices: items });
await this.validateByType(picked.type, picked.id, opts);
}
private printNonInteractiveHint(): void {
console.error('Nothing to validate. Try one of:');
console.error(' openspec validate --all');
console.error(' openspec validate --changes');
console.error(' openspec validate --specs');
console.error(' openspec validate <item-name>');
console.error('Or run in an interactive terminal.');
}
private async validateDirectItem(itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise<void> {
const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);
const isChange = changes.includes(itemName);
const isSpec = specs.includes(itemName);
const type = opts.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined);
if (!type) {
console.error(`Unknown item '${itemName}'`);
const suggestions = nearestMatches(itemName, [...changes, ...specs]);
if (suggestions.length) console.error(`Did you mean: ${suggestions.join(', ')}?`);
process.exitCode = 1;
return;
}
if (!opts.typeOverride && isChange && isSpec) {
console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`);
console.error('Pass --type change|spec, or use: openspec change validate / openspec spec validate');
process.exitCode = 1;
return;
}
await this.validateByType(type, itemName, opts);
}
private async validateByType(type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise<void> {
const validator = new Validator(opts.strict);
if (type === 'change') {
const file = path.join(process.cwd(), 'openspec', 'changes', id, 'proposal.md');
const start = Date.now();
const report = await validator.validateChange(file);
const durationMs = Date.now() - start;
this.printReport('change', id, report, durationMs, opts.json);
process.exitCode = report.valid ? 0 : 1;
return;
}
const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md');
const start = Date.now();
const report = await validator.validateSpec(file);
const durationMs = Date.now() - start;
this.printReport('spec', id, report, durationMs, opts.json);
process.exitCode = report.valid ? 0 : 1;
}
private printReport(type: ItemType, id: string, report: { valid: boolean; issues: any[] }, durationMs: number, json: boolean): void {
if (json) {
const out = { items: [{ id, type, valid: report.valid, issues: report.issues, durationMs }], summary: { totals: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 }, byType: { [type]: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 } } }, version: '1.0' };
console.log(JSON.stringify(out, null, 2));
return;
}
if (report.valid) {
console.log(`${type === 'change' ? 'Change' : 'Specification'} '${id}' is valid`);
} else {
console.error(`${type === 'change' ? 'Change' : 'Specification'} '${id}' has issues`);
for (const issue of report.issues) {
const label = issue.level === 'ERROR' ? 'ERROR' : issue.level;
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
}
}
}
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
const spinner = !opts.json ? ora('Validating...').start() : undefined;
const [changeIds, specIds] = await Promise.all([
scope.changes ? getActiveChangeIds() : Promise.resolve<string[]>([]),
scope.specs ? getSpecIds() : Promise.resolve<string[]>([]),
]);
const DEFAULT_CONCURRENCY = 6;
const maxSuggestions = 5; // used by nearestMatches
const concurrency = normalizeConcurrency(opts.concurrency) ?? normalizeConcurrency(process.env.OPENSPEC_CONCURRENCY) ?? DEFAULT_CONCURRENCY;
const validator = new Validator(opts.strict);
const queue: Array<() => Promise<BulkItemResult>> = [];
for (const id of changeIds) {
queue.push(async () => {
const start = Date.now();
const file = path.join(process.cwd(), 'openspec', 'changes', id, 'proposal.md');
const report = await validator.validateChange(file);
const durationMs = Date.now() - start;
return { id, type: 'change' as const, valid: report.valid, issues: report.issues, durationMs };
});
}
for (const id of specIds) {
queue.push(async () => {
const start = Date.now();
const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md');
const report = await validator.validateSpec(file);
const durationMs = Date.now() - start;
return { id, type: 'spec' as const, valid: report.valid, issues: report.issues, durationMs };
});
}
const results: BulkItemResult[] = [];
let index = 0;
let running = 0;
let passed = 0;
let failed = 0;
await new Promise<void>((resolve) => {
const next = () => {
while (running < concurrency && index < queue.length) {
const currentIndex = index++;
const task = queue[currentIndex];
running++;
if (spinner) spinner.text = `Validating (${currentIndex + 1}/${queue.length})...`;
task()
.then(res => {
results.push(res);
if (res.valid) passed++; else failed++;
})
.catch((error: any) => {
const message = error?.message || 'Unknown error';
const res: BulkItemResult = { id: getPlannedId(currentIndex, changeIds, specIds) ?? 'unknown', type: getPlannedType(currentIndex, changeIds, specIds) ?? 'change', valid: false, issues: [{ level: 'ERROR', path: 'file', message }], durationMs: 0 };
results.push(res);
failed++;
})
.finally(() => {
running--;
if (index >= queue.length && running === 0) resolve();
else next();
});
}
};
next();
});
spinner?.stop();
results.sort((a, b) => a.id.localeCompare(b.id));
const summary = {
totals: { items: results.length, passed, failed },
byType: {
...(scope.changes ? { change: summarizeType(results, 'change') } : {}),
...(scope.specs ? { spec: summarizeType(results, 'spec') } : {}),
},
} as const;
if (opts.json) {
const out = { items: results, summary, version: '1.0' };
console.log(JSON.stringify(out, null, 2));
} else {
for (const res of results) {
if (res.valid) console.log(`✓ ${res.type}/${res.id}`);
else console.error(`✗ ${res.type}/${res.id}`);
}
console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);
}
process.exitCode = failed > 0 ? 1 : 0;
}
}
function summarizeType(results: BulkItemResult[], type: ItemType) {
const filtered = results.filter(r => r.type === type);
const items = filtered.length;
const passed = filtered.filter(r => r.valid).length;
const failed = items - passed;
return { items, passed, failed };
}
function nearestMatches(input: string, candidates: string[], max: number = 5): string[] {
const scored = candidates.map(c => ({ c, d: levenshtein(input, c) }));
scored.sort((a, b) => a.d - b.d);
return scored.slice(0, max).map(s => s.c);
}
function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
);
}
}
return dp[m][n];
}
function normalizeConcurrency(value?: string): number | undefined {
if (!value) return undefined;
const n = parseInt(value, 10);
if (Number.isNaN(n) || n <= 0) return undefined;
return n;
}
function getPlannedId(index: number, changeIds: string[], specIds: string[]): string | undefined {
const totalChanges = changeIds.length;
if (index < totalChanges) return changeIds[index];
const specIndex = index - totalChanges;
return specIds[specIndex];
}
function getPlannedType(index: number, changeIds: string[], specIds: string[]): ItemType | undefined {
const totalChanges = changeIds.length;
if (index < totalChanges) return 'change';
const specIndex = index - totalChanges;
if (specIndex >= 0 && specIndex < specIds.length) return 'spec';
return undefined;
}
+306 -33
View File
@@ -2,8 +2,15 @@ import { promises as fs } from 'fs';
import path from 'path';
import { select, confirm } from '@inquirer/prompts';
import { FileSystemUtils } from '../utils/file-system.js';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.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;
@@ -132,10 +139,12 @@ export class ArchiveCommand {
console.log(chalk.yellow(`Affected files: ${changeDir}`));
}
// Check for incomplete tasks
const tasksPath = path.join(changeDir, 'tasks.md');
const incompleteTasks = await this.checkIncompleteTasks(tasksPath);
// Show progress and check for incomplete tasks
const progress = await getTaskProgressForChange(changesDir, changeName);
const status = formatTaskStatus(progress);
console.log(`Task status: ${status}`);
const incompleteTasks = Math.max(progress.total - progress.completed, 0);
if (incompleteTasks > 0) {
if (!options.yes) {
const proceed = await confirm({
@@ -178,10 +187,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.');
}
}
@@ -223,11 +253,24 @@ export class ArchiveCommand {
return null;
}
console.log('Available changes:');
const choices = changeDirs.map(name => ({
name: name,
value: name
}));
// Build choices with progress inline to avoid duplicate lists
let choices: Array<{ name: string; value: string }> = changeDirs.map(name => ({ name, value: name }));
try {
const progressList: Array<{ id: string; status: string }> = [];
for (const id of changeDirs) {
const progress = await getTaskProgressForChange(changesDir, id);
const status = formatTaskStatus(progress);
progressList.push({ id, status });
}
const nameWidth = Math.max(...progressList.map(p => p.id.length));
choices = progressList.map(p => ({
name: `${p.id.padEnd(nameWidth)} ${p.status}`,
value: p.id
}));
} catch {
// If anything fails, fall back to simple names
choices = changeDirs.map(name => ({ name, value: name }));
}
try {
const answer = await select({
@@ -241,23 +284,9 @@ export class ArchiveCommand {
}
}
private async checkIncompleteTasks(tasksPath: string): Promise<number> {
try {
const content = await fs.readFile(tasksPath, 'utf-8');
const lines = content.split('\n');
let incompleteTasks = 0;
for (const line of lines) {
if (line.includes('- [ ]')) {
incompleteTasks++;
}
}
return incompleteTasks;
} catch {
// No tasks.md file or error reading it
return 0;
}
// Deprecated: replaced by shared task-progress utilities
private async checkIncompleteTasks(_tasksPath: string): Promise<number> {
return 0;
}
private async findSpecUpdates(changeDir: string, mainSpecsDir: string): Promise<SpecUpdate[]> {
@@ -301,14 +330,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 {
+6 -3
View File
@@ -1,5 +1,7 @@
import { readFileSync } from 'fs';
import path from 'path';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { ChangeParser } from '../parsers/change-parser.js';
import { Spec, Change } from '../schemas/index.js';
export class JsonConverter {
@@ -21,12 +23,13 @@ export class JsonConverter {
return JSON.stringify(jsonSpec, null, 2);
}
convertChangeToJson(filePath: string): string {
async convertChangeToJson(filePath: string): Promise<string> {
const content = readFileSync(filePath, 'utf-8');
const parser = new MarkdownParser(content);
const changeName = this.extractNameFromPath(filePath);
const changeDir = path.dirname(filePath);
const parser = new ChangeParser(content, changeDir);
const change = parser.parseChange(changeName);
const change = await parser.parseChangeWithDeltas(changeName);
const jsonChange = {
...change,
+5 -35
View File
@@ -1,5 +1,6 @@
import { promises as fs } from 'fs';
import path from 'path';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
interface ChangeInfo {
name: string;
@@ -33,35 +34,11 @@ export class ListCommand {
const changes: ChangeInfo[] = [];
for (const changeDir of changeDirs) {
const tasksPath = path.join(changesDir, changeDir, 'tasks.md');
let completedTasks = 0;
let incompleteTasks = 0;
try {
const content = await fs.readFile(tasksPath, 'utf-8');
const lines = content.split('\n');
for (const line of lines) {
if (line.includes('- [x]')) {
completedTasks++;
} else if (line.includes('- [ ]')) {
incompleteTasks++;
}
}
} catch {
// No tasks.md file
changes.push({
name: changeDir,
completedTasks: 0,
totalTasks: 0
});
continue;
}
const progress = await getTaskProgressForChange(changesDir, changeDir);
changes.push({
name: changeDir,
completedTasks,
totalTasks: completedTasks + incompleteTasks
completedTasks: progress.completed,
totalTasks: progress.total
});
}
@@ -75,14 +52,7 @@ export class ListCommand {
const nameWidth = Math.max(...changes.map(c => c.name.length));
const paddedName = change.name.padEnd(nameWidth);
let status: string;
if (change.totalTasks === 0) {
status = 'No tasks';
} else if (change.completedTasks === change.totalTasks) {
status = '✓ Complete';
} else {
status = `${change.completedTasks}/${change.totalTasks} tasks`;
}
const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
console.log(`${padding}${paddedName} ${status}`);
}
+233
View File
@@ -0,0 +1,233 @@
import { MarkdownParser, Section } from './markdown-parser.js';
import { Change, Delta, DeltaOperation, Requirement } from '../schemas/index.js';
import path from 'path';
import { promises as fs } from 'fs';
interface DeltaSection {
operation: DeltaOperation;
requirements: Requirement[];
renames?: Array<{ from: string; to: string }>;
}
export class ChangeParser extends MarkdownParser {
private changeDir: string;
constructor(content: string, changeDir: string) {
super(content);
this.changeDir = changeDir;
}
async parseChangeWithDeltas(name: string): Promise<Change> {
const sections = this.parseSections();
const why = this.findSection(sections, 'Why')?.content || '';
const whatChanges = this.findSection(sections, 'What Changes')?.content || '';
if (!why) {
throw new Error('Change must have a Why section');
}
if (!whatChanges) {
throw new Error('Change must have a What Changes section');
}
// Parse deltas from the What Changes section (simple format)
const simpleDeltas = this.parseDeltas(whatChanges);
// Check if there are spec files with delta format
const specsDir = path.join(this.changeDir, 'specs');
const deltaDeltas = await this.parseDeltaSpecs(specsDir);
// Combine both types of deltas, preferring delta format if available
const deltas = deltaDeltas.length > 0 ? deltaDeltas : simpleDeltas;
return {
name,
why: why.trim(),
whatChanges: whatChanges.trim(),
deltas,
metadata: {
version: '1.0.0',
format: 'openspec-change',
},
};
}
private async parseDeltaSpecs(specsDir: string): Promise<Delta[]> {
const deltas: Delta[] = [];
try {
const specDirs = await fs.readdir(specsDir, { withFileTypes: true });
for (const dir of specDirs) {
if (!dir.isDirectory()) continue;
const specName = dir.name;
const specFile = path.join(specsDir, specName, 'spec.md');
try {
const content = await fs.readFile(specFile, 'utf-8');
const specDeltas = this.parseSpecDeltas(specName, content);
deltas.push(...specDeltas);
} catch (error) {
// Spec file might not exist, which is okay
continue;
}
}
} catch (error) {
// Specs directory might not exist, which is okay
return [];
}
return deltas;
}
private parseSpecDeltas(specName: string, content: string): Delta[] {
const deltas: Delta[] = [];
const sections = this.parseSectionsFromContent(content);
// Parse ADDED requirements
const addedSection = this.findSection(sections, 'ADDED Requirements');
if (addedSection) {
const requirements = this.parseRequirements(addedSection);
requirements.forEach(req => {
deltas.push({
spec: specName,
operation: 'ADDED' as DeltaOperation,
description: `Add requirement: ${req.text}`,
// Provide both single and plural forms for compatibility
requirement: req,
requirements: [req],
});
});
}
// Parse MODIFIED requirements
const modifiedSection = this.findSection(sections, 'MODIFIED Requirements');
if (modifiedSection) {
const requirements = this.parseRequirements(modifiedSection);
requirements.forEach(req => {
deltas.push({
spec: specName,
operation: 'MODIFIED' as DeltaOperation,
description: `Modify requirement: ${req.text}`,
requirement: req,
requirements: [req],
});
});
}
// Parse REMOVED requirements
const removedSection = this.findSection(sections, 'REMOVED Requirements');
if (removedSection) {
const requirements = this.parseRequirements(removedSection);
requirements.forEach(req => {
deltas.push({
spec: specName,
operation: 'REMOVED' as DeltaOperation,
description: `Remove requirement: ${req.text}`,
requirement: req,
requirements: [req],
});
});
}
// Parse RENAMED requirements
const renamedSection = this.findSection(sections, 'RENAMED Requirements');
if (renamedSection) {
const renames = this.parseRenames(renamedSection.content);
renames.forEach(rename => {
deltas.push({
spec: specName,
operation: 'RENAMED' as DeltaOperation,
description: `Rename requirement from "${rename.from}" to "${rename.to}"`,
rename,
});
});
}
return deltas;
}
private parseRenames(content: string): Array<{ from: string; to: string }> {
const renames: Array<{ from: string; to: string }> = [];
const lines = content.split('\n');
let currentRename: { 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) {
currentRename.from = fromMatch[1].trim();
} else if (toMatch) {
currentRename.to = toMatch[1].trim();
if (currentRename.from && currentRename.to) {
renames.push({
from: currentRename.from,
to: currentRename.to,
});
currentRename = {};
}
}
}
return renames;
}
private parseSectionsFromContent(content: string): Section[] {
const lines = content.split('\n');
const sections: Section[] = [];
const stack: Section[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const title = headerMatch[2].trim();
const contentLines = this.getContentUntilNextHeaderFromLines(lines, i + 1, level);
const section = {
level,
title,
content: contentLines.join('\n').trim(),
children: [],
};
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
if (stack.length === 0) {
sections.push(section);
} else {
stack[stack.length - 1].children.push(section);
}
stack.push(section);
}
}
return sections;
}
private getContentUntilNextHeaderFromLines(lines: string[], startLine: number, currentLevel: number): string[] {
const contentLines: string[] = [];
for (let i = startLine; i < lines.length; i++) {
const line = lines[i];
const headerMatch = line.match(/^(#{1,6})\s+/);
if (headerMatch && headerMatch[1].length <= currentLevel) {
break;
}
contentLines.push(line);
}
return contentLines;
}
}
+13 -9
View File
@@ -1,6 +1,6 @@
import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation } from '../schemas/index.js';
interface Section {
export interface Section {
level: number;
title: string;
content: string;
@@ -70,7 +70,7 @@ export class MarkdownParser {
};
}
private parseSections(): Section[] {
protected parseSections(): Section[] {
const sections: Section[] = [];
const stack: Section[] = [];
@@ -107,7 +107,7 @@ export class MarkdownParser {
return sections;
}
private getContentUntilNextHeader(startLine: number, currentLevel: number): string {
protected getContentUntilNextHeader(startLine: number, currentLevel: number): string {
const contentLines: string[] = [];
for (let i = startLine; i < this.lines.length; i++) {
@@ -124,7 +124,7 @@ export class MarkdownParser {
return contentLines.join('\n').trim();
}
private findSection(sections: Section[], title: string): Section | undefined {
protected findSection(sections: Section[], title: string): Section | undefined {
for (const section of sections) {
if (section.title.toLowerCase() === title.toLowerCase()) {
return section;
@@ -137,7 +137,7 @@ export class MarkdownParser {
return undefined;
}
private parseRequirements(section: Section): Requirement[] {
protected parseRequirements(section: Section): Requirement[] {
const requirements: Requirement[] = [];
for (const child of section.children) {
@@ -179,7 +179,7 @@ export class MarkdownParser {
return requirements;
}
private parseScenarios(requirementSection: Section): Scenario[] {
protected parseScenarios(requirementSection: Section): Scenario[] {
const scenarios: Scenario[] = [];
for (const scenarioSection of requirementSection.children) {
@@ -195,12 +195,13 @@ export class MarkdownParser {
}
private parseDeltas(content: string): Delta[] {
protected parseDeltas(content: string): Delta[] {
const deltas: Delta[] = [];
const lines = content.split('\n');
for (const line of lines) {
const deltaMatch = line.match(/^\s*-\s*\*\*([^:]+):\*\*\s*(.+)$/);
// Match both formats: **spec:** and **spec**:
const deltaMatch = line.match(/^\s*-\s*\*\*([^*:]+)(?::\*\*|\*\*:)\s*(.+)$/);
if (deltaMatch) {
const specName = deltaMatch[1].trim();
const description = deltaMatch[2].trim();
@@ -209,7 +210,10 @@ export class MarkdownParser {
const lowerDesc = description.toLowerCase();
// Use word boundaries to avoid false matches (e.g., "address" matching "add")
if (/\badd(s|ed|ing)?\b/.test(lowerDesc) || /\bcreate(s|d|ing)?\b/.test(lowerDesc) || /\bnew\b/.test(lowerDesc)) {
// Check RENAMED first since it's more specific than patterns containing "new"
if (/\brename(s|d|ing)?\b/.test(lowerDesc) || /\brenamed\s+(to|from)\b/.test(lowerDesc)) {
operation = 'RENAMED';
} else if (/\badd(s|ed|ing)?\b/.test(lowerDesc) || /\bcreate(s|d|ing)?\b/.test(lowerDesc) || /\bnew\b/.test(lowerDesc)) {
operation = 'ADDED';
} else if (/\bremove(s|d|ing)?\b/.test(lowerDesc) || /\bdelete(s|d|ing)?\b/.test(lowerDesc)) {
operation = 'REMOVED';
+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;
}
+6 -1
View File
@@ -7,13 +7,18 @@ import {
VALIDATION_MESSAGES
} from '../validation/constants.js';
export const DeltaOperationType = z.enum(['ADDED', 'MODIFIED', 'REMOVED']);
export const DeltaOperationType = z.enum(['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED']);
export const DeltaSchema = z.object({
spec: z.string().min(1, VALIDATION_MESSAGES.DELTA_SPEC_EMPTY),
operation: DeltaOperationType,
description: z.string().min(1, VALIDATION_MESSAGES.DELTA_DESCRIPTION_EMPTY),
requirement: RequirementSchema.optional(),
requirements: z.array(RequirementSchema).optional(),
rename: z.object({
from: z.string(),
to: z.string(),
}).optional(),
});
export const ChangeSchema = z.object({
+5 -2
View File
@@ -1,7 +1,9 @@
import { z, ZodError } from 'zod';
import { readFileSync } from 'fs';
import path from 'path';
import { SpecSchema, ChangeSchema, Spec, Change } from '../schemas/index.js';
import { MarkdownParser } from '../parsers/markdown-parser.js';
import { ChangeParser } from '../parsers/change-parser.js';
import { ValidationReport, ValidationIssue, ValidationLevel } from './types.js';
import {
MIN_PURPOSE_LENGTH,
@@ -50,10 +52,11 @@ export class Validator {
try {
const content = readFileSync(filePath, 'utf-8');
const parser = new MarkdownParser(content);
const changeName = this.extractNameFromPath(filePath);
const changeDir = path.dirname(filePath);
const parser = new ChangeParser(content, changeDir);
const change = parser.parseChange(changeName);
const change = await parser.parseChangeWithDeltas(changeName);
const result = ChangeSchema.safeParse(change);
+7
View File
@@ -0,0 +1,7 @@
export function isInteractive(noInteractiveFlag?: boolean): boolean {
if (noInteractiveFlag) return false;
if (process.env.OPEN_SPEC_INTERACTIVE === '0') return false;
return !!process.stdin.isTTY;
}
+38
View File
@@ -0,0 +1,38 @@
import { promises as fs } from 'fs';
import path from 'path';
export async function getActiveChangeIds(root: string = process.cwd()): Promise<string[]> {
const changesPath = path.join(root, 'openspec', 'changes');
try {
const entries = await fs.readdir(changesPath, { withFileTypes: true });
return entries
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'archive')
.map(entry => entry.name)
.sort();
} catch {
return [];
}
}
export async function getSpecIds(root: string = process.cwd()): Promise<string[]> {
const specsPath = path.join(root, 'openspec', 'specs');
const result: string[] = [];
try {
const entries = await fs.readdir(specsPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const specFile = path.join(specsPath, entry.name, 'spec.md');
try {
await fs.access(specFile);
result.push(entry.name);
} catch {
// ignore
}
}
} catch {
// ignore
}
return result.sort();
}
+43
View File
@@ -0,0 +1,43 @@
import { promises as fs } from 'fs';
import path from 'path';
const TASK_PATTERN = /^[-*]\s+\[[\sx]\]/i;
const COMPLETED_TASK_PATTERN = /^[-*]\s+\[x\]/i;
export interface TaskProgress {
total: number;
completed: number;
}
export function countTasksFromContent(content: string): TaskProgress {
const lines = content.split('\n');
let total = 0;
let completed = 0;
for (const line of lines) {
if (line.match(TASK_PATTERN)) {
total++;
if (line.match(COMPLETED_TASK_PATTERN)) {
completed++;
}
}
}
return { total, completed };
}
export async function getTaskProgressForChange(changesDir: string, changeName: string): Promise<TaskProgress> {
const tasksPath = path.join(changesDir, changeName, 'tasks.md');
try {
const content = await fs.readFile(tasksPath, 'utf-8');
return countTasksFromContent(content);
} catch {
return { total: 0, completed: 0 };
}
}
export function formatTaskStatus(progress: TaskProgress): string {
if (progress.total === 0) return 'No tasks';
if (progress.completed === progress.total) return '✓ Complete';
return `${progress.completed}/${progress.total} tasks`;
}
@@ -0,0 +1,51 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
// Note: We cannot truly simulate TTY prompts in this test runner easily.
// Instead, we verify non-interactive fallback behavior and basic invocation.
describe('change validate (interactive behavior)', () => {
const projectRoot = process.cwd();
const testDir = path.join(projectRoot, 'test-change-validate-tmp');
const changesDir = path.join(testDir, 'openspec', 'changes');
const bin = path.join(projectRoot, 'bin', 'openspec.js');
beforeAll(() => {
execSync('pnpm -s build', { stdio: 'pipe' });
});
beforeEach(async () => {
await fs.mkdir(changesDir, { recursive: true });
const content = `# Change: Demo\n\n## Why\nBecause reasons that are sufficiently long.\n\n## What Changes\n- **spec-x:** Add something`;
await fs.mkdir(path.join(changesDir, 'demo'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'demo', 'proposal.md'), content, 'utf-8');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('prints list hint and exits non-zero when no arg and non-interactive', () => {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
try {
process.chdir(testDir);
process.env.OPEN_SPEC_INTERACTIVE = '0';
let err: any;
try {
execSync(`node ${bin} change validate`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
expect(err.stderr.toString()).toContain('Available IDs:');
expect(err.stderr.toString()).toContain('openspec change list');
} finally {
process.chdir(originalCwd);
process.env = originalEnv;
}
});
});
@@ -0,0 +1,47 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
describe('spec validate (interactive behavior)', () => {
const projectRoot = process.cwd();
const testDir = path.join(projectRoot, 'test-spec-validate-tmp');
const specsDir = path.join(testDir, 'openspec', 'specs');
const bin = path.join(projectRoot, 'bin', 'openspec.js');
beforeAll(() => {
execSync('pnpm -s build', { stdio: 'pipe' });
});
beforeEach(async () => {
await fs.mkdir(specsDir, { recursive: true });
const content = `## Purpose\nValid spec for interactive test.\n\n## Requirements\n\n### Requirement: X\nText`;
await fs.mkdir(path.join(specsDir, 's1'), { recursive: true });
await fs.writeFile(path.join(specsDir, 's1', 'spec.md'), content, 'utf-8');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('errors when no arg and non-interactive', () => {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
try {
process.chdir(testDir);
process.env.OPEN_SPEC_INTERACTIVE = '0';
let err: any;
try {
execSync(`node ${bin} spec validate`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
expect(err.stderr.toString()).toContain('Missing required argument <spec-id>');
} finally {
process.chdir(originalCwd);
process.env = originalEnv;
}
});
});
+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);
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
describe('top-level validate command', () => {
const projectRoot = process.cwd();
const testDir = path.join(projectRoot, 'test-validate-command-tmp');
const changesDir = path.join(testDir, 'openspec', 'changes');
const specsDir = path.join(testDir, 'openspec', 'specs');
const bin = path.join(projectRoot, 'bin', 'openspec.js');
beforeAll(() => {
execSync('pnpm -s build', { stdio: 'pipe' });
});
beforeEach(async () => {
await fs.mkdir(changesDir, { recursive: true });
await fs.mkdir(specsDir, { recursive: true });
// Create a valid spec
const specContent = `## Purpose
Valid spec for testing.
## Requirements
### Requirement: Foo
Text
#### Scenario: Bar
Given A\nWhen B\nThen C`;
await fs.mkdir(path.join(specsDir, 'alpha'), { recursive: true });
await fs.writeFile(path.join(specsDir, 'alpha', 'spec.md'), specContent, 'utf-8');
// Create a simple change with bullets (parser supports this)
const changeContent = `# Test Change\n\n## Why\nBecause reasons that are sufficiently long for validation.\n\n## What Changes\n- **alpha:** Add something`;
await fs.mkdir(path.join(changesDir, 'c1'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'c1', 'proposal.md'), changeContent, 'utf-8');
// Duplicate name for ambiguity test
await fs.mkdir(path.join(changesDir, 'dup'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'dup', 'proposal.md'), changeContent, 'utf-8');
await fs.mkdir(path.join(specsDir, 'dup'), { recursive: true });
await fs.writeFile(path.join(specsDir, 'dup', 'spec.md'), specContent, 'utf-8');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('prints a helpful hint when no args in non-interactive mode', () => {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
try {
process.chdir(testDir);
process.env.OPEN_SPEC_INTERACTIVE = '0';
let err: any;
try {
execSync(`node ${bin} validate`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
expect(err.stderr.toString()).toContain('Nothing to validate. Try one of:');
} finally {
process.chdir(originalCwd);
process.env = originalEnv;
}
});
it('validates all with --all and outputs JSON summary', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let outStr = '';
try {
outStr = execSync(`node ${bin} validate --all --json`, { encoding: 'utf-8' });
} catch (e: any) {
// If exit code is non-zero (e.g., on failures), still parse stdout JSON
outStr = e.stdout?.toString?.() ?? '';
}
const json = JSON.parse(outStr);
expect(Array.isArray(json.items)).toBe(true);
expect(json.summary?.totals?.items).toBeDefined();
expect(json.version).toBe('1.0');
} finally {
process.chdir(originalCwd);
}
});
it('validates only specs with --specs and respects --concurrency', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let outStr = '';
try {
outStr = execSync(`node ${bin} validate --specs --json --concurrency 1`, { encoding: 'utf-8' });
} catch (e: any) {
outStr = e.stdout?.toString?.() ?? '';
}
const json = JSON.parse(outStr);
// All items should be specs
expect(json.items.every((i: any) => i.type === 'spec')).toBe(true);
} finally {
process.chdir(originalCwd);
}
});
it('errors on ambiguous item names and suggests type override', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let err: any;
try {
execSync(`node ${bin} validate dup`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.stderr.toString()).toContain('Ambiguous item');
expect(err.status).not.toBe(0);
} finally {
process.chdir(originalCwd);
}
});
});
+292 -18
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', () => {
@@ -295,14 +569,14 @@ Then expected result happens`;
// Execute without change name
await archiveCommand.execute(undefined, { yes: true });
// Verify select was called with correct options
expect(mockSelect).toHaveBeenCalledWith({
// Verify select was called with correct options (values matter, names may include progress)
expect(mockSelect).toHaveBeenCalledWith(expect.objectContaining({
message: 'Select a change to archive',
choices: [
{ name: change1, value: change1 },
{ name: change2, value: change2 }
]
});
choices: expect.arrayContaining([
expect.objectContaining({ value: change1 }),
expect.objectContaining({ value: change2 })
])
}));
// Verify the selected change was archived
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { ChangeCommand } from '../../../src/commands/change.js';
// These tests assume the repository's own openspec/changes directory exists
// and contains at least one active change (e.g., add-change-commands)
describe('ChangeCommand.list', () => {
let cmd: ChangeCommand;
beforeAll(() => {
cmd = new ChangeCommand();
});
it('returns JSON with expected shape', async () => {
// Capture console output
const logs: string[] = [];
const origLog = console.log;
try {
console.log = (msg?: any, ...args: any[]) => {
logs.push([msg, ...args].filter(Boolean).join(' '));
};
await cmd.list({ json: true });
const output = logs.join('\n');
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
if (parsed.length > 0) {
const item = parsed[0];
expect(item).toHaveProperty('id');
expect(item).toHaveProperty('title');
expect(item).toHaveProperty('deltaCount');
expect(item).toHaveProperty('taskStatus');
expect(item.taskStatus).toHaveProperty('total');
expect(item.taskStatus).toHaveProperty('completed');
}
} finally {
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;
}
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { ChangeCommand } from '../../../src/commands/change.js';
import path from 'path';
import { promises as fs } from 'fs';
async function findSingleActiveChange(root: string): Promise<string | undefined> {
const changesDir = path.join(root, 'openspec', 'changes');
try {
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const names = entries
.filter((e) => e.isDirectory() && e.name !== 'archive')
.map((e) => e.name);
if (names.length === 1) return names[0];
return names.includes('add-change-commands') ? 'add-change-commands' : names[0];
} catch {
return undefined;
}
}
describe('ChangeCommand.show/validate', () => {
let cmd: ChangeCommand;
let changeName: string | undefined;
beforeAll(async () => {
cmd = new ChangeCommand();
changeName = await findSingleActiveChange(process.cwd());
});
it('show --json prints JSON including deltas', async () => {
if (!changeName) return; // skip if no changes present
const logs: string[] = [];
const origLog = console.log;
try {
console.log = (msg?: any, ...args: any[]) => {
logs.push([msg, ...args].filter(Boolean).join(' '));
};
await cmd.show(changeName, { json: true });
const output = logs.join('\n');
const parsed = JSON.parse(output);
expect(parsed).toHaveProperty('deltas');
expect(Array.isArray(parsed.deltas)).toBe(true);
} finally {
console.log = origLog;
}
});
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[] = [];
const origLog = console.log;
try {
console.log = (msg?: any, ...args: any[]) => {
logs.push([msg, ...args].filter(Boolean).join(' '));
};
await cmd.show(changeName, { json: true, requirementsOnly: true });
const output = logs.join('\n');
const parsed = JSON.parse(output);
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;
}
});
it('validate --strict --json returns a report with valid boolean', async () => {
if (!changeName) return; // skip if no changes present
const logs: string[] = [];
const origLog = console.log;
try {
console.log = (msg?: any, ...args: any[]) => {
logs.push([msg, ...args].filter(Boolean).join(' '));
};
await cmd.validate(changeName, { strict: true, json: true });
const output = logs.join('\n');
const parsed = JSON.parse(output);
expect(parsed).toHaveProperty('valid');
expect(parsed).toHaveProperty('issues');
expect(Array.isArray(parsed.issues)).toBe(true);
} finally {
console.log = origLog;
}
});
});
+2 -2
View File
@@ -89,7 +89,7 @@ We need to implement user authentication to secure the application and protect u
const changePath = path.join(testDir, 'change.md');
await fs.writeFile(changePath, changeContent);
const json = converter.convertChangeToJson(changePath);
const json = await converter.convertChangeToJson(changePath);
const parsed = JSON.parse(json);
expect(parsed.name).toBe('change');
@@ -117,7 +117,7 @@ We need authentication for security reasons and to protect user data properly.
const changePath = path.join(changesDir, 'proposal.md');
await fs.writeFile(changePath, changeContent);
const json = converter.convertChangeToJson(changePath);
const json = await converter.convertChangeToJson(changePath);
const parsed = JSON.parse(json);
expect(parsed.name).toBe('add-auth');
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
import { promises as fs } from 'fs';
import os from 'os';
import { ChangeParser } from '../../../src/core/parsers/change-parser.js';
async function withTempDir(run: (dir: string) => Promise<void>) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-change-parser-'));
try {
await run(dir);
} finally {
// Best-effort cleanup
try { await fs.rm(dir, { recursive: true, force: true }); } catch {}
}
}
describe('ChangeParser', () => {
it('parses simple What Changes bullet list', async () => {
const content = `# Test Change\n\n## Why\nWe need it because reasons that are sufficiently long.\n\n## What Changes\n- **spec-a:** Add a new requirement to A\n- **spec-b:** Rename requirement X to Y\n- **spec-c:** Remove obsolete requirement`;
const parser = new ChangeParser(content, process.cwd());
const change = await parser.parseChangeWithDeltas('test-change');
expect(change.name).toBe('test-change');
expect(change.deltas.length).toBe(3);
expect(change.deltas[0].spec).toBe('spec-a');
expect(['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED']).toContain(change.deltas[1].operation);
});
it('prefers delta-format specs over simple bullets when both exist', async () => {
await withTempDir(async (dir) => {
const changeDir = dir;
const specsDir = path.join(changeDir, 'specs', 'foo');
await fs.mkdir(specsDir, { recursive: true });
const content = `# Test Change\n\n## Why\nWe need it because reasons that are sufficiently long.\n\n## What Changes\n- **foo:** Add something via bullets (should be overridden)`;
const deltaSpec = `# Delta for Foo\n\n## ADDED Requirements\n\n### Requirement: New thing\n\n#### Scenario: basic\nGiven X\nWhen Y\nThen Z`;
await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec, 'utf8');
const parser = new ChangeParser(content, changeDir);
const change = await parser.parseChangeWithDeltas('test-change');
expect(change.deltas.length).toBeGreaterThan(0);
// Since delta spec exists, the description should reflect delta-derived entries
expect(change.deltas[0].spec).toBe('foo');
expect(change.deltas[0].description).toContain('Add requirement:');
expect(change.deltas[0].operation).toBe('ADDED');
expect(change.deltas[0].requirement).toBeDefined();
});
});
});