mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac50289f0 | ||
|
|
1f295cec52 | ||
|
|
ad8e213cf9 | ||
|
|
a6c1a90165 | ||
|
|
21b5a3e680 | ||
|
|
1bda5be96c | ||
|
|
0faf44807e | ||
|
|
f9c1d07edb | ||
|
|
2bd1a4417c | ||
|
|
1db19ac3d8 | ||
|
|
25018786e4 | ||
|
|
5fd9173ad9 | ||
|
|
0a611747bc | ||
|
|
49e422724f | ||
|
|
c22d6bce1c | ||
|
|
1c0dc09dc9 | ||
|
|
f0b1e00c65 | ||
|
|
5fe72ddc5d | ||
|
|
c18f3b2b2e | ||
|
|
33344727a8 | ||
|
|
828e5ba316 | ||
|
|
31c57f5c0f | ||
|
|
767a0053e8 | ||
|
|
fd65b99c91 | ||
|
|
e170df9f41 | ||
|
|
b91040e8c2 | ||
|
|
8824bd2a42 | ||
|
|
1d3c292d94 | ||
|
|
cfe6da96ac | ||
|
|
c3c78551d0 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user