mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-03 22:13:19 +08:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
828e5ba316 | ||
|
|
31c57f5c0f | ||
|
|
767a0053e8 | ||
|
|
fd65b99c91 | ||
|
|
e170df9f41 | ||
|
|
b91040e8c2 | ||
|
|
8824bd2a42 | ||
|
|
1d3c292d94 | ||
|
|
cfe6da96ac | ||
|
|
c3c78551d0 | ||
|
|
f1fabc5f18 | ||
|
|
166b960428 | ||
|
|
ef1a6c0f0b | ||
|
|
9b3944bd09 | ||
|
|
7917d08a50 | ||
|
|
4a8e5986f0 | ||
|
|
103838f371 | ||
|
|
0a26c686f9 | ||
|
|
efcf766193 | ||
|
|
151eddb759 | ||
|
|
cb0d6f3189 | ||
|
|
a897c697a5 | ||
|
|
3bedf6b23e | ||
|
|
9ff0e85693 | ||
|
|
1ca407fa2f | ||
|
|
46c927af06 | ||
|
|
87cb206e88 | ||
|
|
6806a2fc5a | ||
|
|
4ab65d75dd | ||
|
|
2a3294dbfb | ||
|
|
8334006f2b | ||
|
|
8a559e0d00 | ||
|
|
a3924f17b2 | ||
|
|
b11e862b0f | ||
|
|
099585afcb | ||
|
|
f023fc317e | ||
|
|
38a1463af0 | ||
|
|
f2399d3280 | ||
|
|
f699e10778 | ||
|
|
c824d8927f | ||
|
|
5821b24ab3 | ||
|
|
e812eb9e78 | ||
|
|
abfe13c5a7 | ||
|
|
0d5a75d3a0 | ||
|
|
b30c0ad27e | ||
|
|
1cada18186 | ||
|
|
fa50b07938 | ||
|
|
b6cad1631c | ||
|
|
2497e81e4d | ||
|
|
d8cba03840 | ||
|
|
0b1be19302 | ||
|
|
d7ebee4555 | ||
|
|
8f45a6f6ee | ||
|
|
6da77f01ce | ||
|
|
d90eccf959 | ||
|
|
f192a97aeb | ||
|
|
7781bbadd3 | ||
|
|
aeaa1d50cc | ||
|
|
fa5df9a329 | ||
|
|
f94f396c99 | ||
|
|
1f670f71d4 | ||
|
|
32b2901d13 | ||
|
|
2ad0b1d306 | ||
|
|
279d327899 | ||
|
|
5d848cf005 | ||
|
|
5607fd3ccb | ||
|
|
6a0d862258 | ||
|
|
1fe5f84fbc | ||
|
|
80e78ecd1e | ||
|
|
564135a530 | ||
|
|
b9e80641a0 |
@@ -0,0 +1,76 @@
|
||||
## openspec change vs spec: behavior differences and recommendations
|
||||
|
||||
This document compares how `openspec change` and `openspec spec` behave today (focused on `show` and `list`) and recommends a raw-first, minimal standard to keep behavior simple and predictable before adding smarter formatting later.
|
||||
|
||||
## Summary of key differences and recommendations
|
||||
|
||||
| Area | Current: change | Current: spec | Recommendation |
|
||||
|---|---|---|---|
|
||||
| Invocation (show) | `openspec change show [change-name]` auto-picks when only one active change | `openspec spec show <spec-id>` requires id | Require explicit IDs for both. If `change-name` is omitted, print available IDs and a short hint (e.g., use `openspec change list`) and exit non-zero. No auto-pick. No interactive picker. |
|
||||
| Default text output (show) | Raw `proposal.md` content | Formatted summary | Default both to RAW: print the underlying Markdown file as-is. Provide a future `--pretty` flag (non-default) for formatted output. |
|
||||
| Filtering flags (show) | `--requirements-only` affects text and JSON | `--requirements`, `--no-scenarios`, `-r/--requirement` | Raw-first: in TEXT mode, no filtering. All filtering applies only to JSON output. Deprecate text-mode filters. Keep minimal JSON filters only. |
|
||||
| JSON shape (show) | Full change object; `--requirements-only` can return an array | Filtered object | Always return an OBJECT. Minimal, stable shape. Change: `{ id, title, deltaCount, deltas, taskStatus? }`. Spec: `{ id, title, overview, requirementCount, requirements, metadata }`. No top-level arrays. |
|
||||
| Text output (list) | "Active Changes" with progress | "Available Specifications" with teaser | Default both to RAW/minimal: print IDs only by default. Add `--long` to show `id + title` and minimal details (counts). No teasers. |
|
||||
| JSON shape (list) | `[{ name, title, deltas, taskStatus }]` | `[{ id, title, overview, requirementCount }]` | Unify minimal keys: `id`, `title`, counts only. Change: `deltaCount`, `taskStatus`. Spec: `requirementCount`. Drop `overview` from list JSON. Sort by `id`. |
|
||||
| Error/exit policy | Spinner + `process.exit(1)` in some paths | `exitCode` in others | Raw-first: no spinners in errors. Use `console.error` + `process.exitCode = 1` consistently. |
|
||||
| Empty states (list) | Graceful | Errors if specs missing | Raw-first: graceful empty state everywhere. Print "No items found" and exit 0. |
|
||||
| Multi-selection (change show) | Auto-pick single; error when multiple | N/A | Keep auto-pick single. If multiple, print IDs inline and exit non-zero. No interactive prompts. |
|
||||
| Colors/TTY | Mixed | Chalk only | Raw-first: minimal color; honor `NO_COLOR` and add `--no-color`. |
|
||||
|
||||
## Detailed guidance
|
||||
|
||||
### 1) Unify flags and semantics (raw-first)
|
||||
- Text mode: no filters; just raw file content.
|
||||
- JSON mode: allow minimal filters only.
|
||||
- Specs: `--json` returns the structured spec. Optional: `-r/--requirement <n>` and `--requirements-only` apply to JSON only.
|
||||
- Changes: `--json` returns the structured change. Optional: `--deltas-only` applies to JSON only.
|
||||
- Deprecate text-mode filtering flags across both commands.
|
||||
- Optional future: `--pretty` (text formatting) as a non-default enhancement.
|
||||
|
||||
### 2) Normalize JSON contracts (minimal and stable)
|
||||
- Show (change): `{ id, title, deltaCount, deltas: [...], taskStatus?: { total, completed } }`
|
||||
- Show (spec): `{ id, title, overview, requirementCount, requirements: [...], metadata: { format, version } }`
|
||||
- List (change): `[{ id, title, deltaCount, taskStatus }]`
|
||||
- List (spec): `[{ id, title, requirementCount }]`
|
||||
- Notes:
|
||||
- No top-level arrays for filtered show responses; always objects.
|
||||
- Avoid derived/pretty fields (e.g., teasers, percentages). Counts only.
|
||||
|
||||
### 3) Standardize text UI (minimal)
|
||||
- Show: print raw Markdown file contents.
|
||||
- List (default): print IDs only, one per line.
|
||||
- List (`--long`): print `id: title` plus minimal counts where relevant.
|
||||
- Keep colors minimal; support `--no-color` and respect `NO_COLOR`.
|
||||
|
||||
### 4) Consistent error handling and empty states
|
||||
- Use `console.error` + `process.exitCode = 1`. Avoid `process.exit(1)`.
|
||||
- No spinners (`ora`) in raw-first mode.
|
||||
- Empty states print a simple message and exit 0.
|
||||
|
||||
### 5) Discoverability and UX
|
||||
- No `change-name` provided: print IDs inline and a short hint; exit non-zero. No auto-pick. No interactive prompts.
|
||||
- Add `--no-color` for deterministic logs and pipelines.
|
||||
|
||||
### 6) Backwards compatibility and deprecation
|
||||
- Keep legacy flags as aliases for one minor release.
|
||||
- Print a clear deprecation warning when a legacy flag is used.
|
||||
- Update CLI docs/README/specs to reflect raw-first behavior.
|
||||
|
||||
### 7) Test coverage updates
|
||||
- Add tests asserting raw text outputs (file passthrough) for `show`.
|
||||
- Add tests for minimal list outputs (IDs by default, `--long` for details).
|
||||
- Add JSON contract tests asserting minimal, stable shapes and JSON-only filtering.
|
||||
|
||||
### 8) Library alignment with existing commands
|
||||
- No new dependencies.
|
||||
- Do not use `@inquirer/prompts` in `change`/`spec` show/list (keep non-interactive). Interaction remains limited to `init`, `diff`, and `archive` where already in use.
|
||||
- Do not use `ora` in `change`/`spec` (including validate). Use `console.error` and `process.exitCode`.
|
||||
- Use `chalk` minimally; support `--no-color` and respect `NO_COLOR`.
|
||||
- Keep using the shared `Validator` where applicable.
|
||||
- Do not introduce `jest-diff` into `change`/`spec` (remains specific to `diff`).
|
||||
|
||||
## Why this approach
|
||||
- Keeps the system raw and predictable; easy to compose in scripts.
|
||||
- Minimizes UI/formatting logic until real needs emerge.
|
||||
- Stabilizes JSON for tooling and avoids top-level arrays.
|
||||
- Simple to extend later with `--pretty` and richer filtering if needed.
|
||||
@@ -0,0 +1,119 @@
|
||||
# OpenSpec
|
||||
|
||||
A specification-driven development system for maintaining living documentation alongside your code.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install -g openspec
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Initialize OpenSpec in your project
|
||||
openspec init
|
||||
|
||||
# Update existing OpenSpec instructions (team-friendly)
|
||||
openspec update
|
||||
|
||||
# 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]
|
||||
|
||||
# Archive completed changes
|
||||
openspec archive [change-name]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `openspec init`
|
||||
|
||||
Initializes OpenSpec in your project by creating:
|
||||
- `openspec/` directory structure
|
||||
- `openspec/README.md` with OpenSpec instructions
|
||||
- AI tool configuration files (based on your selection)
|
||||
|
||||
### `openspec update`
|
||||
|
||||
Updates OpenSpec instructions to the latest version. This command is **team-friendly** and only updates files that already exist:
|
||||
|
||||
- Always updates `openspec/README.md` with the latest OpenSpec instructions
|
||||
- **Only updates existing AI tool configuration files** (e.g., CLAUDE.md, CURSOR.md)
|
||||
- **Never creates new AI tool configuration files**
|
||||
- Preserves content outside of OpenSpec markers in AI tool files
|
||||
|
||||
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 spec`
|
||||
|
||||
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]`
|
||||
|
||||
Shows the differences between current specs and proposed changes:
|
||||
- Displays a unified diff format
|
||||
- Helps review what will change before implementation
|
||||
- Useful for pull request reviews
|
||||
|
||||
### `openspec archive [change-name]`
|
||||
|
||||
Archives a completed change:
|
||||
- Moves change from `openspec/changes/` to `openspec/changes/archive/`
|
||||
- Adds a date prefix to the archived change
|
||||
- Updates specs to reflect the new state
|
||||
- Use `--skip-specs` to archive without updating specs (for abandoned changes)
|
||||
|
||||
## Team Collaboration
|
||||
|
||||
OpenSpec is designed for team collaboration:
|
||||
|
||||
1. **AI Tool Flexibility**: Each team member can use their preferred AI assistant (Claude, Cursor, etc.)
|
||||
2. **Non-Invasive Updates**: The `update` command only modifies existing files, never forcing tools on team members
|
||||
3. **Specification Sharing**: The `openspec/` directory contains shared specifications that all team members work from
|
||||
4. **Change Tracking**: Proposed changes are visible to all team members for review before implementation
|
||||
|
||||
## Contributing
|
||||
|
||||
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
|
||||
@@ -1,19 +0,0 @@
|
||||
# Add Status Command to OpenSpec CLI
|
||||
|
||||
## Why
|
||||
|
||||
Developers need to know which changes have all tasks completed and are ready to archive.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `openspec status` command that scans the changes/ directory
|
||||
- Parse each tasks.md file to count `[x]` (complete) and `[ ]` (incomplete) tasks
|
||||
- Display each change with its completion status (e.g., "auth-feature: 5/5" or "auth-feature: ✓")
|
||||
- Skip the archive/ subdirectory
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: New capability `cli-status` will be added
|
||||
- Affected code:
|
||||
- `src/cli/index.ts` - Add status command
|
||||
- `src/core/status.ts` - New file with simple scanning and parsing logic (~50 lines)
|
||||
@@ -1,58 +0,0 @@
|
||||
# CLI Status Command Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
The status command shows which OpenSpec changes are ready to archive by displaying task completion status for each change.
|
||||
|
||||
## Command Interface
|
||||
|
||||
```bash
|
||||
# Show status of all changes
|
||||
openspec status
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
WHEN the status command runs:
|
||||
1. Scan the `openspec/changes/` directory
|
||||
2. Skip the `archive/` subdirectory
|
||||
3. For each change directory with a `tasks.md` file:
|
||||
- Count tasks marked with `[x]` (case-insensitive)
|
||||
- Count tasks marked with `[ ]`
|
||||
- Display the change name and completion status
|
||||
|
||||
## Output Format
|
||||
|
||||
```
|
||||
add-auth-feature: 15/15
|
||||
fix-payment-bug: 8/8
|
||||
refactor-api: 3/10
|
||||
update-docs: 0/5
|
||||
```
|
||||
|
||||
Or with checkmark for fully complete:
|
||||
|
||||
```
|
||||
add-auth-feature: ✓
|
||||
fix-payment-bug: ✓
|
||||
refactor-api: 3/10
|
||||
update-docs: 0/5
|
||||
```
|
||||
|
||||
## Task Detection
|
||||
|
||||
The command recognizes these patterns as tasks:
|
||||
- `- [ ]` Incomplete task
|
||||
- `- [x]` Complete task (lowercase)
|
||||
- `- [X]` Complete task (uppercase)
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If no `tasks.md` exists, skip that change
|
||||
- If `tasks.md` is empty or has no tasks, skip that change
|
||||
- Continue scanning even if individual files have errors
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0`: Success - status displayed
|
||||
- `1`: Error - unable to scan changes directory
|
||||
@@ -1,8 +0,0 @@
|
||||
# Implementation Tasks for Status Command
|
||||
|
||||
## Core Implementation
|
||||
- [ ] Add status command to `src/cli/index.ts`
|
||||
- [ ] Create `src/core/status.ts` with directory scanning logic
|
||||
- [ ] Parse tasks.md files to count `[x]` and `[ ]` patterns
|
||||
- [ ] Display each change with completion status (name: complete/total)
|
||||
- [ ] Skip the archive/ subdirectory when scanning
|
||||
+60
-15
@@ -43,9 +43,9 @@ openspec/
|
||||
│ │ ├── proposal.md # Why, what, impact (consolidated)
|
||||
│ │ ├── tasks.md # Implementation checklist
|
||||
│ │ ├── design.md # Technical decisions (optional, for complex changes)
|
||||
│ │ └── specs/ # Future state of affected specs
|
||||
│ │ └── specs/ # Delta changes to specs
|
||||
│ │ └── [capability]/
|
||||
│ │ └── spec.md # Clean markdown (no diff syntax)
|
||||
│ │ └── spec.md # Delta format (ADDED/MODIFIED/REMOVED/RENAMED)
|
||||
│ └── archive/ # Completed changes (dated)
|
||||
```
|
||||
|
||||
@@ -94,7 +94,35 @@ Before any task:
|
||||
- If adding dependencies, frameworks, or architectural patterns, document why simpler alternatives won't work
|
||||
- Default to single-file implementations until proven insufficient
|
||||
|
||||
### 3. Creating a Change Proposal
|
||||
### 3. Delta-Based Change Format
|
||||
|
||||
Changes use a delta format with clear sections:
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: New Feature
|
||||
[Complete requirement content in structured format]
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Existing Feature
|
||||
[Complete modified requirement (header must match current spec)]
|
||||
|
||||
## REMOVED Requirements
|
||||
### Requirement: Old Feature
|
||||
**Reason for removal**: [Why removing]
|
||||
**Migration path**: [How to handle existing usage]
|
||||
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
Key rules:
|
||||
- Headers are matched using `normalize(header) = trim(header)`
|
||||
- Include complete requirements (not diffs)
|
||||
- Use standard symbols in CLI output: + (added), ~ (modified), - (removed), → (renamed)
|
||||
|
||||
### 4. Creating a Change Proposal
|
||||
|
||||
When a user requests a significant change:
|
||||
|
||||
@@ -113,13 +141,21 @@ openspec/changes/[descriptive-name]/
|
||||
- Affected specs: [list capabilities that will change]
|
||||
- Affected code: [list key files/systems]
|
||||
|
||||
# 3. Create future state specs for ALL affected capabilities
|
||||
# - Store complete spec files as they will exist after the change
|
||||
# - Use clean markdown without diff syntax (+/- prefixes)
|
||||
# - Include all formatting and structure of the final intended state
|
||||
# 3. Create delta specs for ALL affected capabilities
|
||||
# - Store only the changes (not complete future state)
|
||||
# - Use sections: ## ADDED, ## MODIFIED, ## REMOVED, ## RENAMED
|
||||
# - Include complete requirements in their final form
|
||||
# Example spec.md content:
|
||||
# ## ADDED Requirements
|
||||
# ### Requirement: Password Reset
|
||||
# Users SHALL be able to reset passwords via email...
|
||||
#
|
||||
# ## MODIFIED Requirements
|
||||
# ### Requirement: User Authentication
|
||||
# [Complete modified requirement with new password reset hook]
|
||||
specs/
|
||||
└── [capability]/
|
||||
└── spec.md
|
||||
└── spec.md # Contains delta sections
|
||||
|
||||
# 4. Create tasks.md with implementation steps
|
||||
## 1. [Task Group]
|
||||
@@ -130,16 +166,16 @@ specs/
|
||||
[Technical decisions and trade-offs]
|
||||
```
|
||||
|
||||
### 4. The Change Lifecycle
|
||||
### 5. The Change Lifecycle
|
||||
|
||||
1. **Propose** → Create change directory with all documentation
|
||||
1. **Propose** → Create change directory with delta-based documentation
|
||||
2. **Review** → User reviews and approves the proposal
|
||||
3. **Implement** → Follow the approved tasks.md (can be multiple PRs)
|
||||
4. **Deploy** → User confirms deployment
|
||||
5. **Update Specs** → Sync specs/ with new reality (IF the change affects system capabilities)
|
||||
5. **Update Specs** → Apply deltas to sync specs/ with new reality (IF the change affects system capabilities)
|
||||
6. **Archive** → Move to `changes/archive/YYYY-MM-DD-[name]/`
|
||||
|
||||
### 5. Implementing Changes
|
||||
### 6. Implementing Changes
|
||||
|
||||
When implementing an approved change:
|
||||
1. Follow the tasks.md checklist exactly
|
||||
@@ -154,7 +190,7 @@ When implementing an approved change:
|
||||
- Different developers can work on different task groups
|
||||
- Example: PR #1 completes tasks 1.1-1.3, PR #2 completes tasks 2.1-2.4
|
||||
|
||||
### 6. Updating Specs and Archiving After Deployment
|
||||
### 7. Updating Specs and Archiving After Deployment
|
||||
|
||||
**Create a separate PR after deployment** that:
|
||||
1. Moves change to `changes/archive/YYYY-MM-DD-[name]/`
|
||||
@@ -163,7 +199,7 @@ When implementing an approved change:
|
||||
|
||||
This ensures changes are only archived when truly complete and deployed.
|
||||
|
||||
### 7. Types of Changes That Don't Require Specs
|
||||
### 8. Types of Changes That Don't Require Specs
|
||||
|
||||
Some changes only affect development infrastructure and don't need specs:
|
||||
- Initial project setup (package.json, tsconfig.json, etc.)
|
||||
@@ -216,7 +252,16 @@ User: "Add password reset functionality"
|
||||
You should:
|
||||
1. Read specs/user-auth/spec.md
|
||||
2. Check changes/ for pending auth changes
|
||||
3. Create changes/add-password-reset/ with proposal
|
||||
3. Create changes/add-password-reset/ with:
|
||||
- proposal.md describing the change
|
||||
- specs/user-auth/spec.md with:
|
||||
## ADDED Requirements
|
||||
### Requirement: Password Reset
|
||||
[Complete requirement for password reset]
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: User Authentication
|
||||
[Updated to integrate with password reset]
|
||||
4. Wait for approval before implementing
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Implementation Order and Dependencies
|
||||
|
||||
## Required Implementation Sequence
|
||||
|
||||
The following changes must be implemented in this specific order due to dependencies:
|
||||
|
||||
### Phase 1: Foundation
|
||||
**1. add-zod-validation** (No dependencies)
|
||||
- Creates all core schemas (RequirementSchema, ScenarioSchema, SpecSchema, ChangeSchema, DeltaSchema)
|
||||
- Implements markdown parser utilities
|
||||
- Implements validation infrastructure and rules
|
||||
- Establishes validation patterns used by all commands
|
||||
- Must be completed first
|
||||
|
||||
### Phase 2: Change Commands
|
||||
**2. add-change-commands** (Depends on: add-zod-validation)
|
||||
- Imports ChangeSchema and DeltaSchema from zod validation
|
||||
- Reuses markdown parsing utilities
|
||||
- Implements change command with built-in validation
|
||||
- Uses validation infrastructure for change validate subcommand
|
||||
- Cannot start until schemas and validation exist
|
||||
|
||||
### Phase 3: Spec Commands
|
||||
**3. add-spec-commands** (Depends on: add-zod-validation, add-change-commands)
|
||||
- Imports RequirementSchema, ScenarioSchema, SpecSchema from zod validation
|
||||
- Reuses markdown parsing utilities
|
||||
- Implements spec command with built-in validation
|
||||
- Uses validation infrastructure for spec validate subcommand
|
||||
- Builds on patterns established by change commands
|
||||
|
||||
## Dependency Graph
|
||||
```
|
||||
add-zod-validation
|
||||
↓
|
||||
add-change-commands
|
||||
↓
|
||||
add-spec-commands
|
||||
```
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
### Shared Code Dependencies
|
||||
1. **Schemas**: All schemas created in add-zod-validation, used by both command implementations
|
||||
2. **Validation**: Infrastructure created in add-zod-validation, integrated into both commands
|
||||
3. **Parsers**: Markdown parsing utilities created in add-zod-validation, used by both commands
|
||||
|
||||
### File Dependencies
|
||||
- `src/core/schemas/*.schema.ts` (created by add-zod-validation) → imported by both commands
|
||||
- `src/core/validation/validator.ts` (created by add-zod-validation) → used by both commands
|
||||
- `src/core/parsers/markdown-parser.ts` (created by add-zod-validation) → used by both commands
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### For Developers
|
||||
1. Complete each phase fully before moving to the next
|
||||
2. Run tests after each phase to ensure stability
|
||||
3. The legacy `list` command remains functional throughout
|
||||
|
||||
### For CI/CD
|
||||
1. Each change can be validated independently
|
||||
2. Integration tests should run after each phase
|
||||
3. Full system tests required after Phase 3
|
||||
|
||||
### Parallel Work Opportunities
|
||||
Within each phase, the following can be done in parallel:
|
||||
- **Phase 1**: Schema design, validation rules, and parser implementation
|
||||
- **Phase 2**: Change command features and legacy compatibility work
|
||||
- **Phase 3**: Spec command features and final integration
|
||||
@@ -0,0 +1,56 @@
|
||||
# Design: Change Commands
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Command Structure
|
||||
Similar to spec commands, we use subcommands (`change show`, `change list`, `change validate`) for:
|
||||
- Consistency with spec command pattern
|
||||
- Clear separation of concerns
|
||||
- Future extensibility for change management features
|
||||
|
||||
### JSON Schema for Changes
|
||||
```typescript
|
||||
{
|
||||
version: string, // Schema version
|
||||
format: "change", // Identifies as change document
|
||||
sourcePath: string, // Original markdown file path
|
||||
id: string, // Change identifier
|
||||
title: string, // Change title
|
||||
why: string, // Motivation section
|
||||
whatChanges: Array<{
|
||||
type: "ADDED" | "MODIFIED" | "REMOVED" | "RENAMED",
|
||||
deltas: Array<{
|
||||
specId: string,
|
||||
description: string,
|
||||
requirements?: Array<Requirement> // Only for ADDED/MODIFIED
|
||||
}>
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Group deltas by operation type for clearer organization
|
||||
- Optional requirements field (only relevant for ADDED/MODIFIED)
|
||||
- Reuse RequirementSchema from spec commands for consistency
|
||||
|
||||
### Delta Operations
|
||||
**Four operation types:**
|
||||
1. **ADDED**: New requirements added to specs
|
||||
2. **MODIFIED**: Changes to existing requirements
|
||||
3. **REMOVED**: Requirements being deleted
|
||||
4. **RENAMED**: Spec identifier changes
|
||||
|
||||
**Design choice:** Explicit operation types rather than diff-based approach for:
|
||||
- Human readability in markdown
|
||||
- Clear intent communication
|
||||
- Easier validation and tooling
|
||||
|
||||
### Dependency on Spec Commands
|
||||
- **Shared schemas**: RequirementSchema and ScenarioSchema reused
|
||||
- **Implementation order**: spec commands must be implemented first
|
||||
- **Common parser utilities**: Share markdown parsing logic
|
||||
|
||||
### Legacy Compatibility
|
||||
- Keep existing `list` command functional with deprecation warning
|
||||
- Migration path: `list` → `change list` with same functionality
|
||||
- Gradual transition to avoid breaking existing workflows
|
||||
@@ -0,0 +1,17 @@
|
||||
# Change: Add Change Commands with JSON Output
|
||||
|
||||
## Why
|
||||
|
||||
OpenSpec change proposals currently can only be viewed as markdown files, creating the same programmatic access limitations as specs. Additionally, the current `openspec list` command only lists changes, which is inconsistent with the new resource-based command structure.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **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
|
||||
|
||||
- **Affected specs**: cli-list (modify to add deprecation notice)
|
||||
- **Affected code**:
|
||||
- src/cli/index.ts (register new command)
|
||||
- src/core/list.ts (add deprecation notice)
|
||||
@@ -0,0 +1,48 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Change Command
|
||||
|
||||
The system SHALL provide a `change` command with subcommands for displaying, listing, and validating change proposals.
|
||||
|
||||
#### Scenario: Show change as JSON
|
||||
|
||||
- **WHEN** executing `openspec change show update-error --json`
|
||||
- **THEN** parse the markdown change file
|
||||
- **AND** extract change structure and deltas
|
||||
- **AND** output valid JSON to stdout
|
||||
|
||||
#### Scenario: List all changes
|
||||
|
||||
- **WHEN** executing `openspec change list`
|
||||
- **THEN** scan the openspec/changes directory
|
||||
- **AND** return list of all pending changes
|
||||
- **AND** support JSON output with `--json` flag
|
||||
|
||||
#### Scenario: Show only requirement changes
|
||||
|
||||
- **WHEN** executing `openspec change show update-error --requirements-only`
|
||||
- **THEN** display only the requirement changes (ADDED/MODIFIED/REMOVED/RENAMED)
|
||||
- **AND** exclude why and what changes sections
|
||||
|
||||
#### Scenario: Validate change structure
|
||||
|
||||
- **WHEN** executing `openspec change validate update-error`
|
||||
- **THEN** parse the change file
|
||||
- **AND** validate against Zod schema
|
||||
- **AND** ensure deltas are well-formed
|
||||
|
||||
### Requirement: Legacy Compatibility
|
||||
|
||||
The system SHALL maintain backward compatibility with the existing `list` command while showing deprecation notices.
|
||||
|
||||
#### Scenario: Legacy list command
|
||||
|
||||
- **WHEN** executing `openspec list`
|
||||
- **THEN** display current list of changes (existing behavior)
|
||||
- **AND** show deprecation notice: "Note: 'openspec list' is deprecated. Use 'openspec change list' instead."
|
||||
|
||||
#### Scenario: Legacy list with --all flag
|
||||
|
||||
- **WHEN** executing `openspec list --all`
|
||||
- **THEN** display all changes (existing behavior)
|
||||
- **AND** show same deprecation notice
|
||||
@@ -0,0 +1,12 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: List Command Behavior
|
||||
|
||||
The current `list` command behavior SHALL be preserved but marked as deprecated.
|
||||
|
||||
#### Scenario: Deprecation notice
|
||||
|
||||
- **WHEN** using the legacy `list` command
|
||||
- **THEN** continue to work as before
|
||||
- **AND** display deprecation notice
|
||||
- **AND** suggest using `openspec change list` instead
|
||||
@@ -0,0 +1,34 @@
|
||||
# Implementation Tasks (Phase 2: Builds on add-zod-validation)
|
||||
|
||||
## 1. Command Implementation
|
||||
- [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
|
||||
- [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
|
||||
- [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
|
||||
- [x] 4.1 Register change command in src/cli/index.ts
|
||||
- [ ] 4.2 Add integration tests for all subcommands
|
||||
- [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)
|
||||
@@ -1,15 +0,0 @@
|
||||
# Add @requirement Markers for Requirement Identification
|
||||
|
||||
## Why
|
||||
Specs contain WHEN/THEN patterns that define system requirements, but extracting these programmatically requires brittle regex parsing that may miss edge cases or break with formatting changes.
|
||||
|
||||
## What Changes
|
||||
- Define @requirement marker convention for identifying key requirements in specs
|
||||
- Each marker includes a brief identifier (e.g., @requirement user-register)
|
||||
- Markers appear directly before their WHEN/THEN blocks
|
||||
- Document convention in openspec-conventions spec
|
||||
|
||||
## Impact
|
||||
- Affected specs: openspec-conventions (new)
|
||||
- Affected code: None initially - enables future tooling
|
||||
- Breaking changes: None - additive convention only
|
||||
@@ -1,24 +0,0 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## 1. Define Convention
|
||||
- [ ] 1.1 Document @requirement marker syntax
|
||||
- [ ] 1.2 Define identifier naming guidelines
|
||||
- [ ] 1.3 Specify marker placement rules
|
||||
- [ ] 1.4 Add examples of proper usage
|
||||
|
||||
## 2. Create Specification
|
||||
- [ ] 2.1 Write openspec-conventions spec
|
||||
- [ ] 2.2 Include requirement marker section
|
||||
- [ ] 2.3 Add good and bad examples
|
||||
- [ ] 2.4 Document edge cases
|
||||
|
||||
## 3. Update Existing Specs
|
||||
- [ ] 3.1 Add @requirement markers to cli-init spec
|
||||
- [ ] 3.2 Add @requirement markers to cli-update spec
|
||||
- [ ] 3.3 Add @requirement markers to cli-view spec
|
||||
- [ ] 3.4 Review and update any other existing specs
|
||||
|
||||
## 4. Documentation
|
||||
- [ ] 4.1 Update README with marker convention
|
||||
- [ ] 4.2 Add marker usage to CLAUDE.md
|
||||
- [ ] 4.3 Create examples for AI assistants
|
||||
@@ -0,0 +1,13 @@
|
||||
## Why
|
||||
The archive command currently forces users to either accept spec updates or cancel the entire archive operation. Users need flexibility to archive changes without updating specs, either through explicit flags or by declining the confirmation prompt. This is especially important for changes that don't modify specs (like tooling, documentation, or infrastructure updates).
|
||||
|
||||
## What Changes
|
||||
- Add new `--skip-specs` flag to the archive command that bypasses all spec update operations
|
||||
- Fix confirmation behavior: when users decline spec updates interactively, proceed with archiving instead of cancelling the entire operation
|
||||
- When `--skip-specs` flag is used, skip both the spec discovery and update confirmation steps entirely
|
||||
- Display clear message when specs are skipped (either via flag or user choice)
|
||||
- Flag can be combined with existing `--yes` flag for fully automated archiving without spec updates
|
||||
|
||||
## Impact
|
||||
- Affected specs: cli-archive
|
||||
- Affected code: src/core/archive.ts, src/cli/index.ts
|
||||
@@ -0,0 +1,167 @@
|
||||
# CLI Archive Command Specification
|
||||
|
||||
## Purpose
|
||||
The archive command moves completed changes from the active changes directory to the archive folder with date-based naming, following OpenSpec conventions.
|
||||
|
||||
## Command Syntax
|
||||
```bash
|
||||
openspec archive [change-name] [--yes|-y] [--skip-specs]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--yes`, `-y`: Skip confirmation prompts (for automation)
|
||||
- `--skip-specs`: Skip spec update operations entirely (for changes without spec modifications)
|
||||
|
||||
## Behavior
|
||||
|
||||
### Requirement: Change Selection
|
||||
|
||||
The command SHALL support both interactive and direct change selection methods.
|
||||
|
||||
#### Scenario: Interactive selection
|
||||
|
||||
- **WHEN** no change-name is provided
|
||||
- **THEN** display interactive list of available changes (excluding archive/)
|
||||
- **AND** allow user to select one
|
||||
|
||||
#### Scenario: Direct selection
|
||||
|
||||
- **WHEN** change-name is provided
|
||||
- **THEN** use that change directly
|
||||
- **AND** validate it exists
|
||||
|
||||
### Requirement: Task Completion Check
|
||||
|
||||
The command SHALL verify task completion status before archiving to prevent premature archival.
|
||||
|
||||
#### Scenario: Incomplete tasks found
|
||||
|
||||
- **WHEN** incomplete tasks are found (marked with `- [ ]`)
|
||||
- **THEN** display all incomplete tasks to the user
|
||||
- **AND** prompt for confirmation to continue
|
||||
- **AND** default to "No" for safety
|
||||
|
||||
#### Scenario: All tasks complete
|
||||
|
||||
- **WHEN** all tasks are complete OR no tasks.md exists
|
||||
- **THEN** proceed with archiving without prompting
|
||||
|
||||
### Requirement: Archive Process
|
||||
|
||||
The archive operation SHALL follow a structured process to safely move changes to the archive.
|
||||
|
||||
#### Scenario: Performing archive
|
||||
|
||||
- **WHEN** archiving a change
|
||||
- **THEN** execute these steps:
|
||||
1. Create archive/ directory if it doesn't exist
|
||||
2. Generate target name as `YYYY-MM-DD-[change-name]` using current date
|
||||
3. Check if target directory already exists
|
||||
4. Update main specs from the change's future state specs unless `--skip-specs` is provided (see Spec Update Process below)
|
||||
5. Move the entire change directory to the archive location
|
||||
|
||||
#### Scenario: Archive already exists
|
||||
|
||||
- **WHEN** target archive already exists
|
||||
- **THEN** fail with error message
|
||||
- **AND** do not overwrite existing archive
|
||||
|
||||
#### Scenario: Successful archive
|
||||
|
||||
- **WHEN** move succeeds
|
||||
- **THEN** display success message with archived name and list of updated specs (if any)
|
||||
|
||||
### Requirement: Spec Update Process
|
||||
|
||||
Before moving the change to archive, the command SHALL update main specs to reflect the deployed reality unless the `--skip-specs` flag is provided.
|
||||
|
||||
#### Scenario: Skipping spec updates
|
||||
|
||||
- **WHEN** the `--skip-specs` flag is provided
|
||||
- **THEN** skip all spec discovery and update operations
|
||||
- **AND** proceed directly to moving the change to archive
|
||||
- **AND** display message indicating specs were skipped
|
||||
|
||||
#### Scenario: Updating specs from change
|
||||
|
||||
- **WHEN** the change contains specs in `changes/[name]/specs/` AND `--skip-specs` is NOT provided
|
||||
- **THEN** execute these steps:
|
||||
1. Analyze which specs will be affected by comparing with existing specs
|
||||
2. Display a summary of spec updates to the user (see Confirmation Behavior below)
|
||||
3. Prompt for confirmation unless `--yes` flag is provided
|
||||
4. If confirmed, for each capability spec in the change directory:
|
||||
- Copy the spec from `changes/[name]/specs/[capability]/spec.md` to `openspec/specs/[capability]/spec.md`
|
||||
- Create the target directory structure if it doesn't exist
|
||||
- Overwrite existing spec files (specs represent current reality, change specs are the new reality)
|
||||
- Track which specs were updated for the success message
|
||||
|
||||
#### Scenario: No specs in change
|
||||
|
||||
- **WHEN** no specs exist in the change AND `--skip-specs` is NOT provided
|
||||
- **THEN** skip the spec update step
|
||||
- **AND** proceed with archiving
|
||||
|
||||
### Requirement: Confirmation Behavior
|
||||
|
||||
The spec update confirmation SHALL provide clear visibility into changes before they are applied.
|
||||
|
||||
#### Scenario: Displaying confirmation
|
||||
|
||||
- **WHEN** prompting for confirmation AND `--skip-specs` is NOT provided
|
||||
- **THEN** display a clear summary showing:
|
||||
- Which specs will be created (new capabilities)
|
||||
- Which specs will be updated (existing capabilities)
|
||||
- The source path for each spec
|
||||
- **AND** format the confirmation prompt as:
|
||||
```
|
||||
The following specs will be updated:
|
||||
|
||||
NEW specs to be created:
|
||||
- cli-archive (from changes/add-archive-command/specs/cli-archive/spec.md)
|
||||
|
||||
EXISTING specs to be updated:
|
||||
- cli-init (from changes/update-init-command/specs/cli-init/spec.md)
|
||||
|
||||
Update 2 specs and archive 'add-archive-command'? [y/N]:
|
||||
```
|
||||
#### Scenario: Handling confirmation response
|
||||
|
||||
- **WHEN** waiting for user confirmation
|
||||
- **THEN** default to "No" for safety (require explicit "y" or "yes")
|
||||
- **AND** skip confirmation when `--yes` or `-y` flag is provided
|
||||
- **AND** skip entire spec confirmation when `--skip-specs` flag is provided
|
||||
|
||||
#### Scenario: User declines spec update confirmation
|
||||
|
||||
- **WHEN** user declines the spec update confirmation
|
||||
- **THEN** skip the spec update operations
|
||||
- **AND** display message: "Skipping spec updates. Proceeding with archive."
|
||||
- **AND** continue with the archive operation
|
||||
- **AND** display success message indicating specs were not updated
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Requirement: Error Conditions
|
||||
|
||||
The command SHALL handle various error conditions gracefully.
|
||||
|
||||
#### Scenario: Handling errors
|
||||
|
||||
- **WHEN** errors occur
|
||||
- **THEN** handle the following conditions:
|
||||
- Missing openspec/changes/ directory
|
||||
- Change not found
|
||||
- Archive target already exists
|
||||
- File system permissions issues
|
||||
|
||||
## Why These Decisions
|
||||
|
||||
**Interactive selection**: Reduces typing and helps users see available changes
|
||||
**Task checking**: Prevents accidental archiving of incomplete work
|
||||
**Date prefixing**: Maintains chronological order and prevents naming conflicts
|
||||
**No overwrite**: Preserves historical archives and prevents data loss
|
||||
**Spec updates before archiving**: Specs in the main directory represent current reality; when a change is deployed and archived, its future state specs become the new reality and must replace the main specs
|
||||
**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
|
||||
@@ -0,0 +1,57 @@
|
||||
## 1. Update Archive Command Implementation
|
||||
- [x] 1.1 Add `skipSpecs` option to the archive command options interface
|
||||
- [x] 1.2 Modify the execute method to skip spec operations when flag is set
|
||||
- [x] 1.3 Fix confirmation behavior: when user declines spec updates, proceed with archiving instead of cancelling
|
||||
- [x] 1.4 Update console output to indicate when specs are being skipped (via flag or user choice)
|
||||
- [x] 1.5 Ensure archive continues after declining spec updates
|
||||
|
||||
## 2. Update CLI Interface
|
||||
- [x] 2.1 Add `--skip-specs` flag to the archive command definition
|
||||
- [x] 2.2 Pass the flag value to the archive command execute method
|
||||
|
||||
## 3. Update Tests
|
||||
- [x] 3.1 Add test case for archiving with --skip-specs flag
|
||||
- [x] 3.2 Add test case for declining spec updates but continuing with archive
|
||||
- [x] 3.3 Verify that spec updates are skipped when flag is used
|
||||
- [x] 3.4 Verify that archive proceeds when user declines spec updates
|
||||
- [x] 3.5 Ensure existing behavior remains unchanged when flag is not used
|
||||
|
||||
## 4. Update Documentation
|
||||
- [x] 4.1 Update the cli-archive spec to document the new --skip-specs flag
|
||||
- [x] 4.2 Document the new behavior when declining spec updates interactively
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
1. **Non-blocking Confirmation Behavior**: When users decline spec updates interactively, the archive operation continues rather than cancelling entirely. This was a critical UX improvement because:
|
||||
- Users may want to review specs separately before updating them
|
||||
- Archiving work shouldn't be blocked by spec review decisions
|
||||
- Maintains flexibility in the deployment workflow
|
||||
|
||||
2. **Flag Naming Convention**: Chose `--skip-specs` for clarity and consistency:
|
||||
- Clearly indicates the action (skipping) and target (specs)
|
||||
- Follows kebab-case convention for CLI flags
|
||||
- Converts naturally to `skipSpecs` camelCase in code
|
||||
|
||||
3. **Console Messaging Strategy**: Added explicit messages for all spec-skipping scenarios:
|
||||
- When flag is used: "Skipping spec updates (--skip-specs flag provided)."
|
||||
- When user declines: "Skipping spec updates. Proceeding with archive."
|
||||
- Ensures users always understand what's happening with their specs
|
||||
|
||||
4. **Test Coverage Approach**: Created separate test cases for:
|
||||
- Flag-based skipping (explicit user choice via CLI)
|
||||
- Interactive declining (runtime user decision)
|
||||
- Both verify the same outcome but test different code paths
|
||||
|
||||
### Use Cases Addressed
|
||||
|
||||
- **Infrastructure Changes**: Changes to build tools, CI/CD, dependencies
|
||||
- **Documentation Updates**: README updates, comment improvements
|
||||
- **Tooling Modifications**: Developer tools, scripts, configuration files
|
||||
- **Refactoring**: Code improvements that don't change functionality/specs
|
||||
|
||||
### Future Considerations
|
||||
|
||||
- Could potentially auto-detect when changes don't include specs and suggest using the flag
|
||||
- May want to track which archives skipped spec updates for audit purposes
|
||||
@@ -0,0 +1,45 @@
|
||||
# Design: Spec Commands
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Command Hierarchy
|
||||
We chose a subcommand pattern (`spec show`, `spec list`, `spec validate`) to:
|
||||
- Group related functionality under a common namespace
|
||||
- Enable future extensibility without polluting the top-level CLI
|
||||
- Maintain consistency with the planned `change` command structure
|
||||
|
||||
### JSON Schema Structure
|
||||
The spec JSON schema follows this structure:
|
||||
```typescript
|
||||
{
|
||||
version: string, // Schema version for compatibility
|
||||
format: "spec", // Identifies this as a spec document
|
||||
sourcePath: string, // Original markdown file path
|
||||
id: string, // Spec identifier from filename
|
||||
title: string, // Human-readable title
|
||||
overview?: string, // Optional overview section
|
||||
requirements: Array<{
|
||||
id: string,
|
||||
text: string,
|
||||
scenarios: Array<{
|
||||
id: string,
|
||||
text: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Flat structure for requirements array (vs nested objects) for easier iteration
|
||||
- Scenarios nested within requirements to maintain relationship
|
||||
- Metadata fields (version, format, sourcePath) for tooling integration
|
||||
|
||||
### Parser Architecture
|
||||
- **Markdown-first approach**: Parse markdown headings rather than custom syntax
|
||||
- **Streaming parser**: Process line-by-line to handle large files efficiently
|
||||
- **Strict heading hierarchy**: Enforce ##/###/#### structure for consistency
|
||||
|
||||
### Validation Strategy
|
||||
- **Parse-time validation**: Catch structural issues during parsing
|
||||
- **Schema validation**: Use Zod for runtime type checking of parsed data
|
||||
- **Separate validation command**: Allow validation without full parsing/conversion
|
||||
@@ -0,0 +1,19 @@
|
||||
# Change: Add Spec Commands with JSON Output
|
||||
|
||||
## Why
|
||||
|
||||
Currently, OpenSpec specs can only be viewed as markdown files. This makes programmatic access difficult and prevents integration with CI/CD pipelines, external tools, and automated processing.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add new `openspec spec` command with three subcommands: `show`, `list`, and `validate`
|
||||
- Implement JSON output capability for specs using heading-based parsing
|
||||
- Add Zod schemas for spec structure validation
|
||||
- Enable content filtering options (requirements only, no scenarios, specific requirement)
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected specs**: None (new capability)
|
||||
- **Affected code**:
|
||||
- src/cli/index.ts (register new command)
|
||||
- package.json (add zod dependency)
|
||||
@@ -0,0 +1,43 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Spec Command
|
||||
|
||||
The system SHALL provide a `spec` command with subcommands for displaying, listing, and validating specifications.
|
||||
|
||||
#### Scenario: Show spec as JSON
|
||||
|
||||
- **WHEN** executing `openspec spec show init --json`
|
||||
- **THEN** parse the markdown spec file
|
||||
- **AND** extract headings and content hierarchically
|
||||
- **AND** output valid JSON to stdout
|
||||
|
||||
#### Scenario: List all specs
|
||||
|
||||
- **WHEN** executing `openspec spec list`
|
||||
- **THEN** scan the openspec/specs directory
|
||||
- **AND** return list of all available capabilities
|
||||
- **AND** support JSON output with `--json` flag
|
||||
|
||||
#### Scenario: Filter spec content
|
||||
|
||||
- **WHEN** executing `openspec spec show init --requirements`
|
||||
- **THEN** display only requirement names and SHALL statements
|
||||
- **AND** exclude scenario content
|
||||
|
||||
#### Scenario: Validate spec structure
|
||||
|
||||
- **WHEN** executing `openspec spec validate init`
|
||||
- **THEN** parse the spec file
|
||||
- **AND** validate against Zod schema
|
||||
- **AND** report any structural issues
|
||||
|
||||
### Requirement: JSON Schema Definition
|
||||
|
||||
The system SHALL define Zod schemas that accurately represent the spec structure for runtime validation.
|
||||
|
||||
#### Scenario: Schema validation
|
||||
|
||||
- **WHEN** parsing a spec into JSON
|
||||
- **THEN** validate the structure using Zod schemas
|
||||
- **AND** ensure all required fields are present
|
||||
- **AND** provide clear error messages for validation failures
|
||||
@@ -0,0 +1,22 @@
|
||||
# Implementation Tasks (Phase 3: Builds on add-zod-validation and add-change-commands)
|
||||
|
||||
## 1. Command Implementation
|
||||
- [x] 1.1 Create src/commands/spec.ts
|
||||
- [x] 1.2 Import RequirementSchema, ScenarioSchema, SpecSchema from src/core/schemas/
|
||||
- [x] 1.3 Import markdown parser from src/core/parsers/markdown-parser.ts
|
||||
- [x] 1.4 Import SpecValidator 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 SpecValidator
|
||||
- [x] 1.9 Add filtering options (--requirements, --no-scenarios, -r)
|
||||
- [x] 1.10 Add --strict mode support (leveraging existing validation infrastructure)
|
||||
- [x] 1.11 Add --json flag for validation reports
|
||||
|
||||
## 2. Integration
|
||||
- [x] 2.1 Register spec command in src/cli/index.ts
|
||||
- [x] 2.2 Add integration tests for all subcommands
|
||||
- [x] 2.3 Test JSON output validation
|
||||
- [x] 2.4 Test filtering options
|
||||
- [x] 2.5 Test validation with strict mode
|
||||
- [x] 2.6 Update CLI help documentation (add 'spec' command to main help, document subcommands: show, list, validate)
|
||||
@@ -0,0 +1,104 @@
|
||||
# Design: Zod Validation Framework
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Validation Levels
|
||||
Three-tier validation system:
|
||||
1. **ERROR**: Structural issues that prevent parsing (must fix)
|
||||
2. **WARNING**: Quality issues that should be addressed (recommended fix)
|
||||
3. **INFO**: Suggestions for improvement (optional)
|
||||
|
||||
**Rationale:**
|
||||
- Gradual enforcement allows teams to adopt validation incrementally
|
||||
- CI/CD can fail on errors but allow warnings initially
|
||||
- Info level provides guidance without blocking
|
||||
|
||||
### Validation Rules Hierarchy
|
||||
|
||||
#### Spec Validation Rules
|
||||
```
|
||||
ERROR level:
|
||||
- Missing ## Overview or ## Requirements sections
|
||||
- Invalid heading hierarchy
|
||||
- Malformed requirement/scenario structure
|
||||
|
||||
WARNING level:
|
||||
- Requirements without scenarios
|
||||
- Requirements missing SHALL keyword
|
||||
- Empty overview section
|
||||
|
||||
INFO level:
|
||||
- Very long requirement text (>500 chars)
|
||||
- Scenarios without Given/When/Then structure
|
||||
```
|
||||
|
||||
#### Change Validation Rules
|
||||
```
|
||||
ERROR level:
|
||||
- Missing ## Why or ## What Changes sections
|
||||
- Invalid delta operation types
|
||||
- Malformed delta structure
|
||||
|
||||
WARNING level:
|
||||
- Why section too brief (<50 chars)
|
||||
- Deltas without clear descriptions
|
||||
- Missing requirements in ADDED/MODIFIED
|
||||
|
||||
INFO level:
|
||||
- Very long why section (>1000 chars)
|
||||
- Too many deltas in single change (>10)
|
||||
```
|
||||
|
||||
### Strict Mode
|
||||
- **Default**: Show all levels, fail on ERROR only
|
||||
- **--strict flag**: Fail on both ERROR and WARNING
|
||||
- **Use case**: Gradual quality improvement in CI/CD pipelines
|
||||
|
||||
### Archive Command Safety
|
||||
**Problem:** Invalid specs could be archived, polluting the archive.
|
||||
|
||||
**Solution:**
|
||||
1. Pre-archive validation (default behavior)
|
||||
2. --no-validate flag with safeguards:
|
||||
- Interactive confirmation prompt
|
||||
- Prominent warning message
|
||||
- Console logging with timestamp
|
||||
- Not recommended for CI/CD usage
|
||||
|
||||
**Rationale:**
|
||||
- Protect archive integrity by default
|
||||
- Allow emergency overrides with accountability
|
||||
- Clear audit trail for validation bypasses
|
||||
|
||||
### Validation Report Format
|
||||
```json
|
||||
{
|
||||
"valid": boolean,
|
||||
"issues": [
|
||||
{
|
||||
"level": "ERROR" | "WARNING" | "INFO",
|
||||
"path": "requirements[0].scenarios",
|
||||
"message": "Requirement must have at least one scenario",
|
||||
"line": 15,
|
||||
"column": 0
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"errors": 2,
|
||||
"warnings": 5,
|
||||
"info": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Machine-readable for tooling integration
|
||||
- Human-friendly messages
|
||||
- Line/column info for IDE integration
|
||||
- Summary for quick assessment
|
||||
|
||||
### Implementation Strategy
|
||||
1. **Zod schemas with refinements**: Built-in validation in type definitions
|
||||
2. **Custom validators**: Additional business logic validation
|
||||
3. **Composable rules**: Mix and match for different contexts
|
||||
4. **Extensible framework**: Easy to add new rules without refactoring
|
||||
@@ -0,0 +1,22 @@
|
||||
# Change: Add Zod Runtime Validation
|
||||
|
||||
## Why
|
||||
|
||||
While the spec and change commands can output JSON, they currently don't perform strict runtime validation beyond basic structure checking. This can lead to invalid specs or changes being processed, silent failures when required fields are missing, and poor error messages.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Enhance existing `spec validate` and `change validate` commands with strict Zod validation
|
||||
- Add validation to the archive command to ensure changes are valid before applying
|
||||
- Add validation to the diff command to ensure changes are well-formed
|
||||
- Provide detailed validation reports in JSON format
|
||||
- Add `--strict` mode that fails on warnings
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected specs**: cli-spec, cli-change, cli-archive, cli-diff
|
||||
- **Affected code**:
|
||||
- src/commands/spec.ts (enhance validate subcommand)
|
||||
- src/commands/change.ts (enhance validate subcommand)
|
||||
- src/core/archive.ts (add pre-archive validation)
|
||||
- src/core/diff.ts (add validation check)
|
||||
@@ -0,0 +1,18 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Archive Validation
|
||||
|
||||
The archive command SHALL validate changes before applying them to ensure data integrity.
|
||||
|
||||
#### Scenario: Pre-archive validation
|
||||
|
||||
- **WHEN** executing `openspec archive change-name`
|
||||
- **THEN** validate the change structure first
|
||||
- **AND** only proceed if validation passes
|
||||
- **AND** show validation errors if it fails
|
||||
|
||||
#### Scenario: Force archive without validation
|
||||
|
||||
- **WHEN** executing `openspec archive change-name --no-validate`
|
||||
- **THEN** skip validation (unsafe mode)
|
||||
- **AND** show warning about skipping validation
|
||||
@@ -0,0 +1,12 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Diff Command Enhancement
|
||||
|
||||
The diff command SHALL validate change structure before displaying differences.
|
||||
|
||||
#### Scenario: Validate before diff
|
||||
|
||||
- **WHEN** executing `openspec diff change-name`
|
||||
- **THEN** validate change structure
|
||||
- **AND** show validation warnings if present
|
||||
- **AND** continue with diff display
|
||||
@@ -0,0 +1,59 @@
|
||||
# Implementation Tasks (Foundation Phase)
|
||||
|
||||
## 1. Core Schemas
|
||||
- [x] 1.1 Add zod dependency to package.json
|
||||
- [x] 1.2 Create src/core/schemas/base.schema.ts with ScenarioSchema and RequirementSchema
|
||||
- [x] 1.3 Create src/core/schemas/spec.schema.ts with SpecSchema
|
||||
- [x] 1.4 Create src/core/schemas/change.schema.ts with DeltaSchema and ChangeSchema
|
||||
- [x] 1.5 Create src/core/schemas/index.ts to export all schemas
|
||||
|
||||
## 2. Parser Implementation
|
||||
- [x] 2.1 Create src/core/parsers/markdown-parser.ts
|
||||
- [x] 2.2 Implement heading extraction (##, ###, ####)
|
||||
- [x] 2.3 Implement content capture between headings
|
||||
- [x] 2.4 Add tests for parser edge cases
|
||||
|
||||
## 3. Validation Infrastructure
|
||||
- [x] 3.1 Create src/core/validation/types.ts with ValidationLevel, ValidationIssue, ValidationReport types
|
||||
- [x] 3.2 Create src/core/validation/constants.ts with validation rules and thresholds
|
||||
- [x] 3.3 Create src/core/validation/validator.ts with SpecValidator and ChangeValidator classes
|
||||
|
||||
## 4. Enhanced Validation Rules
|
||||
- [x] 4.1 Add RequirementValidation refinements (must have scenarios, must contain SHALL)
|
||||
- [x] 4.2 Add SpecValidation refinements (must have requirements)
|
||||
- [x] 4.3 Add ChangeValidation refinements (must have deltas, why section length)
|
||||
- [x] 4.4 Implement custom error messages for each rule
|
||||
|
||||
## 5. JSON Converter
|
||||
- [x] 5.1 Create src/core/converters/json-converter.ts
|
||||
- [x] 5.2 Implement spec-to-JSON conversion
|
||||
- [x] 5.3 Implement change-to-JSON conversion
|
||||
- [x] 5.4 Add metadata fields (version, format, sourcePath)
|
||||
|
||||
## 6. Archive Command Enhancement
|
||||
- [x] 6.1 Add pre-archive validation check using new validators
|
||||
- [x] 6.2 Add --no-validate flag with required confirmation prompt and warning message: "⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)"
|
||||
- [x] 6.3 Display validation errors before aborting
|
||||
- [x] 6.4 Log all --no-validate usages to console with timestamp and affected files
|
||||
- [x] 6.5 Add tests for validation scenarios including --no-validate confirmation flow
|
||||
|
||||
## 7. Diff Command Enhancement
|
||||
- [x] 7.1 Add validation check before diff using new validators
|
||||
- [x] 7.2 Show validation warnings (non-blocking)
|
||||
- [x] 7.3 Continue with diff even if warnings present
|
||||
|
||||
## 8. Testing
|
||||
- [x] 8.1 Unit tests for all schemas
|
||||
- [x] 8.2 Unit tests for parser
|
||||
- [x] 8.3 Unit tests for validation rules
|
||||
- [x] 8.4 Integration tests for validation reports
|
||||
- [x] 8.5 Test various invalid spec/change formats
|
||||
- [x] 8.6 Test strict mode behavior
|
||||
- [x] 8.7 Test pre-archive validation
|
||||
- [x] 8.8 Test validation report JSON output
|
||||
|
||||
## 9. Documentation
|
||||
- [x] 9.1 Document schema structure and validation rules (openspec/VALIDATION.md)
|
||||
- [x] 9.2 Update CLI help for archive (document --no-validate flag and its warnings)
|
||||
- [x] 9.3 Update CLI help for diff (document validation warnings behavior)
|
||||
- [x] 9.4 Create migration guide for future command integration (openspec/MIGRATION.md)
|
||||
@@ -0,0 +1,66 @@
|
||||
# Adopt Delta-Based Changes for Specifications
|
||||
|
||||
## Why
|
||||
|
||||
The current approach of storing complete future states in change proposals creates a poor review experience. When reviewing changes on GitHub, reviewers see entire spec files (often 100+ lines) as "added" in green, making it impossible to identify what actually changed. With the recent structured format adoption, we now have clear section boundaries that enable a better approach: storing only additions and modifications.
|
||||
|
||||
## What Changes
|
||||
|
||||
Store only the requirements that actually change, not complete future states:
|
||||
|
||||
- **ADDED Requirements**: New capabilities being introduced
|
||||
- **MODIFIED Requirements**: Existing requirements being changed (must match current header)
|
||||
- **REMOVED Requirements**: Deprecated capabilities
|
||||
- **RENAMED Requirements**: Explicit header changes (e.g., `FROM: Old Name` → `TO: New Name`)
|
||||
|
||||
The archive command will programmatically apply these deltas using normalized header matching (trim leading/trailing whitespace) instead of manually copying entire files.
|
||||
|
||||
## Impact
|
||||
|
||||
**Affected specs**: openspec-conventions, cli-archive, cli-diff
|
||||
|
||||
**Benefits**:
|
||||
- GitHub diffs show only actual changes (25 lines instead of 150+)
|
||||
- Reviewers immediately see what's being added, modified, or removed
|
||||
- Conflicts are more apparent when two changes modify the same requirement
|
||||
- Archive command can programmatically apply changes
|
||||
|
||||
**Format**: Delta format only - all changes must use ADDED/MODIFIED/REMOVED sections.
|
||||
|
||||
## Example
|
||||
|
||||
Instead of storing a 150-line complete future spec, store only:
|
||||
|
||||
```markdown
|
||||
# User Authentication - Changes
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: OAuth Support
|
||||
Users SHALL authenticate via OAuth providers including Google and GitHub.
|
||||
|
||||
#### Scenario: OAuth login flow
|
||||
- **WHEN** user selects OAuth provider
|
||||
- **THEN** redirect to provider authorization
|
||||
- **AND** exchange authorization code for tokens
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Session Management
|
||||
Sessions SHALL expire after 30 minutes of inactivity.
|
||||
|
||||
#### Scenario: Inactive session timeout
|
||||
- **WHEN** no activity for 30 minutes ← (was 60 minutes)
|
||||
- **THEN** invalidate session token
|
||||
- **AND** require re-authentication
|
||||
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Basic Authentication`
|
||||
- TO: `### Requirement: Email Authentication`
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,46 @@
|
||||
# CLI Archive Command - Changes
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Spec Update Process
|
||||
|
||||
Before moving the change to archive, the command SHALL apply delta changes to main specs to reflect the deployed reality.
|
||||
|
||||
#### Scenario: Applying delta changes
|
||||
|
||||
- **WHEN** archiving a change with delta-based specs
|
||||
- **THEN** parse and apply delta changes as defined in openspec-conventions
|
||||
- **AND** validate all operations before applying
|
||||
|
||||
#### Scenario: Validating delta changes
|
||||
|
||||
- **WHEN** processing delta changes
|
||||
- **THEN** perform validations as specified in openspec-conventions
|
||||
- **AND** if validation fails, show specific errors and abort
|
||||
|
||||
#### Scenario: Conflict detection
|
||||
|
||||
- **WHEN** applying deltas would create duplicate requirement headers
|
||||
- **THEN** abort with error message showing the conflict
|
||||
- **AND** suggest manual resolution
|
||||
|
||||
### Requirement: Display Output
|
||||
|
||||
The command SHALL provide clear feedback about delta operations.
|
||||
|
||||
#### Scenario: Showing delta application
|
||||
|
||||
- **WHEN** applying delta changes
|
||||
- **THEN** display for each spec:
|
||||
- Number of requirements added
|
||||
- Number of requirements modified
|
||||
- Number of requirements removed
|
||||
- Number of requirements renamed
|
||||
- **AND** use standard output symbols (+ ~ - →) as defined in openspec-conventions:
|
||||
```
|
||||
Applying changes to specs/user-auth/spec.md:
|
||||
+ 2 added
|
||||
~ 3 modified
|
||||
- 1 removed
|
||||
→ 1 renamed
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# CLI Diff Command - Changes
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Display 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.
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Diff Output
|
||||
|
||||
The command SHALL show a requirement-level comparison displaying only changed requirements.
|
||||
|
||||
#### Scenario: Side-by-side comparison of changes
|
||||
|
||||
- **WHEN** running `openspec diff <change>`
|
||||
- **THEN** display only requirements that have changed
|
||||
- **AND** show them in a side-by-side format that:
|
||||
- Clearly shows the current version on the left
|
||||
- Shows the future version on the right
|
||||
- Indicates new requirements (not in current)
|
||||
- Indicates removed requirements (not in future)
|
||||
- Aligns modified requirements for easy comparison
|
||||
|
||||
### Requirement: Validation
|
||||
|
||||
The command SHALL validate that changes can be applied successfully.
|
||||
|
||||
#### Scenario: Invalid delta references
|
||||
|
||||
- **WHEN** delta references non-existent requirement
|
||||
- **THEN** show error message with specific requirement
|
||||
- **AND** continue showing other valid changes
|
||||
- **AND** clearly mark failed changes in the output
|
||||
@@ -0,0 +1,109 @@
|
||||
# OpenSpec Conventions - Changes
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Header-Based Requirement Identification
|
||||
|
||||
Requirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes.
|
||||
|
||||
#### Scenario: Matching requirements programmatically
|
||||
|
||||
- **WHEN** processing delta changes
|
||||
- **THEN** use the `### Requirement: [Name]` header as the unique identifier
|
||||
- **AND** match using normalized headers: `normalize(header) = trim(header)`
|
||||
- **AND** compare headers with case-sensitive equality after normalization
|
||||
|
||||
#### Scenario: Handling requirement renames
|
||||
|
||||
- **WHEN** renaming a requirement
|
||||
- **THEN** use a special `## RENAMED Requirements` section
|
||||
- **AND** specify both old and new names explicitly:
|
||||
```markdown
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
- **AND** if content also changes, include under MODIFIED using the NEW header
|
||||
|
||||
#### Scenario: Validating header uniqueness
|
||||
|
||||
- **WHEN** creating or modifying requirements
|
||||
- **THEN** ensure no duplicate headers exist within a spec
|
||||
- **AND** validation tools SHALL flag duplicate headers as errors
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Change Storage Convention
|
||||
|
||||
Change proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states.
|
||||
|
||||
#### Scenario: Creating change proposals with additions
|
||||
|
||||
- **WHEN** creating a change proposal that adds new requirements
|
||||
- **THEN** include only the new requirements under `## ADDED Requirements`
|
||||
- **AND** each requirement SHALL include its complete content
|
||||
- **AND** use the standard structured format for requirements and scenarios
|
||||
|
||||
#### Scenario: Creating change proposals with modifications
|
||||
|
||||
- **WHEN** creating a change proposal that modifies existing requirements
|
||||
- **THEN** include the modified requirements under `## MODIFIED Requirements`
|
||||
- **AND** use the same header text as in the current spec (normalized)
|
||||
- **AND** include the complete modified requirement (not a diff)
|
||||
- **AND** optionally annotate what changed with inline comments like `← (was X)`
|
||||
|
||||
#### Scenario: Creating change proposals with removals
|
||||
|
||||
- **WHEN** creating a change proposal that removes requirements
|
||||
- **THEN** list them under `## REMOVED Requirements`
|
||||
- **AND** use the normalized header text for identification
|
||||
- **AND** include reason for removal
|
||||
- **AND** document any migration path if applicable
|
||||
|
||||
|
||||
The `changes/[name]/specs/` directory SHALL contain:
|
||||
- Delta files showing only what changes
|
||||
- Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements
|
||||
- Normalized header matching for requirement identification
|
||||
- Complete requirements using the structured format
|
||||
- Clear indication of change type for each requirement
|
||||
|
||||
#### Scenario: Using standard output symbols
|
||||
|
||||
- **WHEN** displaying delta operations in CLI output
|
||||
- **THEN** use these standard symbols:
|
||||
- `+` for ADDED (green)
|
||||
- `~` for MODIFIED (yellow)
|
||||
- `-` for REMOVED (red)
|
||||
- `→` for RENAMED (cyan)
|
||||
|
||||
### Requirement: Archive Process Enhancement
|
||||
|
||||
The archive process SHALL programmatically apply delta changes to current specifications using header-based matching.
|
||||
|
||||
#### Scenario: Archiving changes with deltas
|
||||
|
||||
- **WHEN** archiving a completed change
|
||||
- **THEN** the archive command SHALL:
|
||||
1. Parse RENAMED sections first and apply renames
|
||||
2. Parse REMOVED sections and remove by normalized header match
|
||||
3. Parse MODIFIED sections and replace by normalized header match (using new names if renamed)
|
||||
4. Parse ADDED sections and append new requirements
|
||||
- **AND** validate that all MODIFIED/REMOVED headers exist in current spec
|
||||
- **AND** validate that ADDED headers don't already exist
|
||||
- **AND** generate the updated spec in the main specs/ directory
|
||||
|
||||
#### Scenario: Handling conflicts during archive
|
||||
|
||||
- **WHEN** delta changes conflict with current spec state
|
||||
- **THEN** the archive command SHALL report specific conflicts
|
||||
- **AND** require manual resolution before proceeding
|
||||
- **AND** provide clear guidance on resolving conflicts
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Future State Storage
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## 1. Update Conventions
|
||||
- [x] 1.1 Update openspec-conventions spec with delta-based approach
|
||||
- [x] 1.2 Add Header-Based Requirement Identification
|
||||
- [x] 1.3 Define ADDED/MODIFIED/REMOVED/RENAMED sections
|
||||
- [x] 1.4 Document standard output symbols (+ ~ - →)
|
||||
- [x] 1.5 Update openspec/README.md with delta-based conventions
|
||||
- [x] 1.6 Update examples to use delta format
|
||||
|
||||
## 2. Update Diff Command
|
||||
- [ ] 2.1 Update cli-diff spec with requirement-level comparison
|
||||
- [ ] 2.2 Parse specs into requirement-level structures
|
||||
- [ ] 2.3 Apply deltas to generate future state
|
||||
- [ ] 2.4 Implement side-by-side comparison view (changes only)
|
||||
- [ ] 2.5 Add tests for requirement-level comparison
|
||||
- [ ] 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
|
||||
|
||||
## Notes
|
||||
- Archive command is critical path - must work reliably
|
||||
- All new changes must use delta format
|
||||
- Header normalization: normalize(header) = trim(header)
|
||||
- Diff command shows only changed requirements in side-by-side comparison
|
||||
@@ -0,0 +1,28 @@
|
||||
# Fix Update Command Tool Selection
|
||||
|
||||
## Problem
|
||||
|
||||
The `openspec update` command currently forces the creation/update of CLAUDE.md regardless of which AI tool was selected during initialization. This violates the tool-agnostic design principle and creates confusion for users who selected different AI assistants.
|
||||
|
||||
Additionally, different team members may use different AI tools, so we cannot rely on a shared configuration file.
|
||||
|
||||
## Solution
|
||||
|
||||
Modify the update command to:
|
||||
1. Only update AI tool configuration files that already exist
|
||||
2. Never create new AI tool configuration files
|
||||
3. Always update the core OpenSpec files (README.md, etc.)
|
||||
|
||||
## Implementation
|
||||
|
||||
- Remove hardcoded CLAUDE.md update from update command
|
||||
- Implement file existence check before updating any AI tool config
|
||||
- Update each existing AI tool config file with its appropriate markers
|
||||
- No configuration file needed (avoids team conflicts)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- 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)
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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)
|
||||
|
||||
### Requirement: Tool-Agnostic Updates
|
||||
|
||||
The update command SHALL work for any team member regardless of their AI tool choice.
|
||||
|
||||
#### Scenario: Team member using Claude
|
||||
|
||||
- **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
|
||||
- **AND** preserve user content outside OpenSpec markers
|
||||
|
||||
#### Scenario: Mixed team environment
|
||||
|
||||
- **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
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### 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)
|
||||
@@ -0,0 +1,21 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## 1. Update Update Command
|
||||
- [x] Remove hardcoded CLAUDE.md update from `src/core/update.ts`
|
||||
- [x] Add logic to check for existing AI tool configuration files
|
||||
- [x] Update only existing files using their appropriate configurators
|
||||
- [x] Iterate through all registered configurators to check for existing files
|
||||
|
||||
## 2. Update Configurator Registry
|
||||
- [x] Add method to get all configurators for update command
|
||||
- [x] Ensure each configurator can check if its file exists
|
||||
|
||||
## 3. Add Tests
|
||||
- [x] Test update command with only CLAUDE.md present
|
||||
- [x] Test update command with no AI tool files present
|
||||
- [x] Test update command with multiple AI tool files present
|
||||
- [x] Test that update never creates new AI tool files
|
||||
|
||||
## 4. Update Documentation
|
||||
- [x] Update README to clarify team-friendly behavior
|
||||
- [x] Document that update only modifies existing files
|
||||
@@ -0,0 +1,36 @@
|
||||
## Why
|
||||
|
||||
OpenSpec specifications lack a consistent structure that makes sections visually identifiable and programmatically parseable across different specs. This makes it harder to maintain consistency and build tooling.
|
||||
|
||||
## What Changes
|
||||
|
||||
**Specification Format Section**
|
||||
- From: No formal structure requirements for specifications
|
||||
- To: Structured format with `### Requirement:` and `#### Scenario:` headers
|
||||
- Reason: Visual consistency and parseability across all specs
|
||||
- Impact: Non-breaking - existing specs can migrate gradually
|
||||
|
||||
**Keyword Formatting**
|
||||
- From: Inconsistent use of WHEN/THEN/AND keywords
|
||||
- To: Bold keywords (**WHEN**, **THEN**, **AND**) in scenario bullets
|
||||
- Reason: Improved readability and consistent visual hierarchy
|
||||
- Impact: Non-breaking - formatting enhancement only
|
||||
|
||||
**Format Flexibility**
|
||||
- From: Implicit understanding that different content needs different formats
|
||||
- To: Explicit allowance for alternative formats (OpenAPI, JSON Schema, etc.)
|
||||
- Reason: Address concern that not all specs fit requirement/scenario pattern
|
||||
- Impact: Non-breaking - clarifies existing practice
|
||||
|
||||
**Migration Guidelines**
|
||||
- From: No migration guidance
|
||||
- To: Documented gradual migration approach
|
||||
- Reason: Allows incremental adoption without disrupting existing specs
|
||||
- Impact: Non-breaking - opt-in migration as specs are modified
|
||||
|
||||
## Impact
|
||||
|
||||
- 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
|
||||
+61
-100
@@ -36,6 +36,60 @@ openspec/
|
||||
└── YYYY-MM-DD-[name]/
|
||||
```
|
||||
|
||||
## Specification Format
|
||||
|
||||
### Requirement: Structured Format for Behavioral Specs
|
||||
|
||||
Behavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability.
|
||||
|
||||
#### Scenario: Writing requirement sections
|
||||
|
||||
- **WHEN** documenting a requirement in a behavioral specification
|
||||
- **THEN** use a level-3 heading with format `### Requirement: [Name]`
|
||||
- **AND** immediately follow with a SHALL statement describing core behavior
|
||||
- **AND** keep requirement names descriptive and under 50 characters
|
||||
|
||||
#### Scenario: Documenting scenarios
|
||||
|
||||
- **WHEN** documenting specific behaviors or use cases
|
||||
- **THEN** use level-4 headings with format `#### Scenario: [Description]`
|
||||
- **AND** use bullet points with bold keywords for steps:
|
||||
- **GIVEN** for initial state (optional)
|
||||
- **WHEN** for conditions or triggers
|
||||
- **THEN** for expected outcomes
|
||||
- **AND** for additional outcomes or conditions
|
||||
|
||||
#### Scenario: Adding implementation details
|
||||
|
||||
- **WHEN** a step requires additional detail
|
||||
- **THEN** use sub-bullets under the main step
|
||||
- **AND** maintain consistent indentation
|
||||
- Sub-bullets provide examples or specifics
|
||||
- Keep sub-bullets concise
|
||||
|
||||
### Requirement: Format Flexibility
|
||||
|
||||
The structured format SHALL be the default for behavioral specifications, but alternative formats MAY be used when more appropriate for the content type.
|
||||
|
||||
#### Scenario: Documenting API specifications
|
||||
|
||||
- **WHEN** documenting REST API endpoints or GraphQL schemas
|
||||
- **THEN** OpenAPI, GraphQL SDL, or similar formats MAY be used
|
||||
- **AND** the spec SHALL clearly indicate the format being used
|
||||
- **AND** behavioral aspects SHALL still follow the structured format
|
||||
|
||||
#### Scenario: Documenting data schemas
|
||||
|
||||
- **WHEN** documenting data structures, database schemas, or configurations
|
||||
- **THEN** JSON Schema, SQL DDL, or similar formats MAY be used
|
||||
- **AND** include the structured format for behavioral rules and constraints
|
||||
|
||||
#### Scenario: Using simplified format
|
||||
|
||||
- **WHEN** documenting simple capabilities without complex scenarios
|
||||
- **THEN** a simplified WHEN/THEN format without full structure MAY be used
|
||||
- **AND** this should be consistent within the capability
|
||||
|
||||
## Change Storage Convention
|
||||
|
||||
### Future State Storage
|
||||
@@ -110,105 +164,6 @@ A proposal is NOT required for:
|
||||
- Adding tests for existing behavior
|
||||
- Documentation clarifications
|
||||
|
||||
## Requirement Markers
|
||||
|
||||
### Marker Syntax
|
||||
|
||||
@requirement marker-syntax
|
||||
WHEN writing a requirement in a spec
|
||||
THEN prefix it with @requirement followed by a brief kebab-case identifier
|
||||
AND place the marker on the line immediately before the WHEN statement
|
||||
|
||||
@requirement marker-identifier
|
||||
WHEN choosing an identifier for @requirement
|
||||
THEN use kebab-case (lowercase with hyphens)
|
||||
AND keep it brief but descriptive (2-4 words)
|
||||
AND ensure it's unique within the spec
|
||||
|
||||
@requirement marker-placement
|
||||
WHEN adding @requirement markers to a spec
|
||||
THEN place them in the ## Behavior or ## Behaviors section
|
||||
AND ensure each WHEN/THEN block has exactly one marker
|
||||
AND maintain a blank line after each THEN block for readability
|
||||
|
||||
### Examples
|
||||
|
||||
@requirement valid-marker-example
|
||||
WHEN a spec includes properly formatted markers
|
||||
THEN tools can extract and identify requirements programmatically
|
||||
AND the spec remains human-readable
|
||||
|
||||
Example of correct usage:
|
||||
```markdown
|
||||
## Behavior
|
||||
|
||||
@requirement user-register
|
||||
WHEN user registers with valid email
|
||||
THEN create account and send confirmation
|
||||
|
||||
@requirement user-login
|
||||
WHEN user logs in with correct credentials
|
||||
THEN return JWT token with user data
|
||||
|
||||
@requirement invalid-credentials
|
||||
WHEN user provides invalid credentials
|
||||
THEN return 401 unauthorized error
|
||||
```
|
||||
|
||||
@requirement invalid-marker-detection
|
||||
WHEN a requirement lacks an @requirement marker
|
||||
THEN tools should gracefully skip it
|
||||
AND optionally warn about unmarked requirements
|
||||
|
||||
### Edge Cases
|
||||
|
||||
@requirement multiline-when-then
|
||||
WHEN a WHEN or THEN clause spans multiple lines
|
||||
THEN the @requirement marker still goes on the line before WHEN
|
||||
AND the entire block is considered part of that requirement
|
||||
|
||||
@requirement multiple-then-clauses
|
||||
WHEN a requirement has multiple THEN clauses using AND
|
||||
THEN treat them as part of the same requirement
|
||||
AND use a single @requirement marker for the entire block
|
||||
|
||||
@requirement nested-conditions
|
||||
WHEN requirements have nested conditions or complex logic
|
||||
THEN keep the @requirement marker simple
|
||||
AND let the WHEN/THEN content contain the complexity
|
||||
|
||||
## Spec Structure
|
||||
|
||||
@requirement spec-file-location
|
||||
WHEN creating a spec file
|
||||
THEN place it in openspec/specs/[capability-name]/spec.md
|
||||
AND use kebab-case for the capability name
|
||||
|
||||
@requirement spec-sections
|
||||
WHEN structuring a spec
|
||||
THEN include these sections in order:
|
||||
- # [Capability Name] Specification
|
||||
- ## Purpose (brief description)
|
||||
- ## Behavior or ## Behaviors (with @requirement markers)
|
||||
- ## Examples (optional, for complex requirements)
|
||||
|
||||
## Benefits of Requirement Markers
|
||||
|
||||
@requirement tooling-extraction
|
||||
WHEN tools need to extract requirements from specs
|
||||
THEN they can parse @requirement markers reliably
|
||||
AND avoid complex regex patterns for WHEN/THEN extraction
|
||||
|
||||
@requirement requirement-counting
|
||||
WHEN displaying change summaries
|
||||
THEN tools can count requirements by counting @requirement markers
|
||||
AND show accurate requirement counts per spec
|
||||
|
||||
@requirement requirement-referencing
|
||||
WHEN documenting or discussing specific requirements
|
||||
THEN use the @requirement identifier for clear reference
|
||||
AND maintain consistency across documentation
|
||||
|
||||
## Why This Approach
|
||||
|
||||
Clean future state storage provides:
|
||||
@@ -216,4 +171,10 @@ Clean future state storage provides:
|
||||
- **AI-compatibility**: Standard markdown that AI tools understand
|
||||
- **Simplicity**: No special parsing or processing needed
|
||||
- **Tool-agnostic**: Any diff tool can show changes
|
||||
- **Clear intent**: Explicit proposals document reasoning
|
||||
- **Clear intent**: Explicit proposals document reasoning
|
||||
|
||||
The structured format adds:
|
||||
- **Visual Consistency**: Requirement and Scenario prefixes make sections instantly recognizable
|
||||
- **Parseability**: Consistent structure enables tooling and automation
|
||||
- **Flexibility**: Alternative formats supported where appropriate
|
||||
- **Gradual Adoption**: Existing specs can migrate incrementally
|
||||
@@ -0,0 +1,19 @@
|
||||
## 1. Update OpenSpec Conventions Spec
|
||||
|
||||
- [x] 1.1 Add "Specification Format" section to openspec-conventions
|
||||
- [x] 1.2 Document structured format with Requirement/Scenario headers
|
||||
- [x] 1.3 Define bold keyword usage (WHEN/THEN/AND) for scenarios
|
||||
- [x] 1.4 Include examples demonstrating the format within the spec itself
|
||||
|
||||
## 2. Update Documentation
|
||||
|
||||
- [x] 2.1 Update the "Why This Approach" section with structured format benefits
|
||||
- [x] 2.2 Ensure spec follows its own format as a demonstration
|
||||
|
||||
## 3. Update Existing Specs
|
||||
|
||||
- [x] 3.1 Update cli-init spec to use structured format in Behavior section
|
||||
- [x] 3.2 Update cli-list spec to use structured format in Behavior section
|
||||
- [x] 3.3 Update cli-update spec to use structured format in Behavior section
|
||||
- [x] 3.4 Update cli-diff spec to use structured format in Behavior section
|
||||
- [x] 3.5 Update cli-archive spec to use structured format in Behavior section
|
||||
@@ -0,0 +1,155 @@
|
||||
# CLI Archive Command Specification
|
||||
|
||||
## Purpose
|
||||
The archive command moves completed changes from the active changes directory to the archive folder with date-based naming, following OpenSpec conventions.
|
||||
|
||||
## Command Syntax
|
||||
```bash
|
||||
openspec archive [change-name] [--yes|-y]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--yes`, `-y`: Skip confirmation prompts (for automation)
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Change Selection
|
||||
|
||||
The command SHALL support both interactive and direct change selection methods.
|
||||
|
||||
#### Scenario: Interactive selection
|
||||
|
||||
- **WHEN** no change-name is provided
|
||||
- **THEN** display interactive list of available changes (excluding archive/)
|
||||
- **AND** allow user to select one
|
||||
|
||||
#### Scenario: Direct selection
|
||||
|
||||
- **WHEN** change-name is provided
|
||||
- **THEN** use that change directly
|
||||
- **AND** validate it exists
|
||||
|
||||
### Requirement: Task Completion Check
|
||||
|
||||
The command SHALL verify task completion status before archiving to prevent premature archival.
|
||||
|
||||
#### Scenario: Incomplete tasks found
|
||||
|
||||
- **WHEN** incomplete tasks are found (marked with `- [ ]`)
|
||||
- **THEN** display all incomplete tasks to the user
|
||||
- **AND** prompt for confirmation to continue
|
||||
- **AND** default to "No" for safety
|
||||
|
||||
#### Scenario: All tasks complete
|
||||
|
||||
- **WHEN** all tasks are complete OR no tasks.md exists
|
||||
- **THEN** proceed with archiving without prompting
|
||||
|
||||
### Requirement: Archive Process
|
||||
|
||||
The archive operation SHALL follow a structured process to safely move changes to the archive.
|
||||
|
||||
#### Scenario: Performing archive
|
||||
|
||||
- **WHEN** archiving a change
|
||||
- **THEN** execute these steps:
|
||||
1. Create archive/ directory if it doesn't exist
|
||||
2. Generate target name as `YYYY-MM-DD-[change-name]` using current date
|
||||
3. Check if target directory already exists
|
||||
4. Update main specs from the change's future state specs (see Spec Update Process below)
|
||||
5. Move the entire change directory to the archive location
|
||||
|
||||
#### Scenario: Archive already exists
|
||||
|
||||
- **WHEN** target archive already exists
|
||||
- **THEN** fail with error message
|
||||
- **AND** do not overwrite existing archive
|
||||
|
||||
#### Scenario: Successful archive
|
||||
|
||||
- **WHEN** move succeeds
|
||||
- **THEN** display success message with archived name and list of updated specs
|
||||
|
||||
### Requirement: Spec Update Process
|
||||
|
||||
Before moving the change to archive, the command SHALL update main specs to reflect the deployed reality.
|
||||
|
||||
#### Scenario: Updating specs from change
|
||||
|
||||
- **WHEN** the change contains specs in `changes/[name]/specs/`
|
||||
- **THEN** execute these steps:
|
||||
1. Analyze which specs will be affected by comparing with existing specs
|
||||
2. Display a summary of spec updates to the user (see Confirmation Behavior below)
|
||||
3. Prompt for confirmation unless `--yes` flag is provided
|
||||
4. If confirmed, for each capability spec in the change directory:
|
||||
- Copy the spec from `changes/[name]/specs/[capability]/spec.md` to `openspec/specs/[capability]/spec.md`
|
||||
- Create the target directory structure if it doesn't exist
|
||||
- Overwrite existing spec files (specs represent current reality, change specs are the new reality)
|
||||
- Track which specs were updated for the success message
|
||||
|
||||
#### Scenario: No specs in change
|
||||
|
||||
- **WHEN** no specs exist in the change
|
||||
- **THEN** skip the spec update step
|
||||
- **AND** proceed with archiving
|
||||
|
||||
### Requirement: Confirmation Behavior
|
||||
|
||||
The spec update confirmation SHALL provide clear visibility into changes before they are applied.
|
||||
|
||||
#### Scenario: Displaying confirmation
|
||||
|
||||
- **WHEN** prompting for confirmation
|
||||
- **THEN** display a clear summary showing:
|
||||
- Which specs will be created (new capabilities)
|
||||
- Which specs will be updated (existing capabilities)
|
||||
- The source path for each spec
|
||||
- **AND** format the confirmation prompt as:
|
||||
```
|
||||
The following specs will be updated:
|
||||
|
||||
NEW specs to be created:
|
||||
- cli-archive (from changes/add-archive-command/specs/cli-archive/spec.md)
|
||||
|
||||
EXISTING specs to be updated:
|
||||
- cli-init (from changes/update-init-command/specs/cli-init/spec.md)
|
||||
|
||||
Update 2 specs and archive 'add-archive-command'? [y/N]:
|
||||
```
|
||||
#### Scenario: Handling confirmation response
|
||||
|
||||
- **WHEN** waiting for user confirmation
|
||||
- **THEN** default to "No" for safety (require explicit "y" or "yes")
|
||||
- **AND** skip confirmation when `--yes` or `-y` flag is provided
|
||||
|
||||
#### Scenario: User declines confirmation
|
||||
|
||||
- **WHEN** user declines the confirmation
|
||||
- **THEN** abort the entire archive operation
|
||||
- **AND** display message: "Archive cancelled. No changes were made."
|
||||
- **AND** exit with non-zero status code
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Requirement: Error Conditions
|
||||
|
||||
The command SHALL handle various error conditions gracefully.
|
||||
|
||||
#### Scenario: Handling errors
|
||||
|
||||
- **WHEN** errors occur
|
||||
- **THEN** handle the following conditions:
|
||||
- Missing openspec/changes/ directory
|
||||
- Change not found
|
||||
- Archive target already exists
|
||||
- File system permissions issues
|
||||
|
||||
## Why These Decisions
|
||||
|
||||
**Interactive selection**: Reduces typing and helps users see available changes
|
||||
**Task checking**: Prevents accidental archiving of incomplete work
|
||||
**Date prefixing**: Maintains chronological order and prevents naming conflicts
|
||||
**No overwrite**: Preserves historical archives and prevents data loss
|
||||
**Spec updates before archiving**: Specs in the main directory represent current reality; when a change is deployed and archived, its future state specs become the new reality and must replace the main specs
|
||||
**Confirmation for spec updates**: Provides visibility into what will change, prevents accidental overwrites, and ensures users understand the impact before specs are modified
|
||||
**--yes flag for automation**: Allows CI/CD pipelines to archive without interactive prompts while maintaining safety by default for manual use
|
||||
@@ -0,0 +1,120 @@
|
||||
# CLI Diff Command Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
The `openspec diff` command provides developers with a visual comparison between proposed spec changes and the current deployed specs.
|
||||
|
||||
## Command Syntax
|
||||
|
||||
```bash
|
||||
openspec diff [change-name]
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Without Arguments
|
||||
|
||||
The command SHALL provide an interactive selection when no change is specified.
|
||||
|
||||
#### Scenario: Running without arguments
|
||||
|
||||
- **WHEN** running `openspec diff` without arguments
|
||||
- **THEN** list all available changes in the `changes/` directory (excluding archive)
|
||||
- **AND** prompt user to select a change
|
||||
|
||||
### Requirement: With Change Name
|
||||
|
||||
The command SHALL compare specs when a specific change is provided.
|
||||
|
||||
#### Scenario: Running with change name
|
||||
|
||||
- **WHEN** running `openspec diff <change-name>`
|
||||
- **THEN** compare all spec files in `changes/<change-name>/specs/` with corresponding files in `specs/`
|
||||
|
||||
### Requirement: Diff Output
|
||||
|
||||
The command SHALL generate appropriate diff output for all spec changes.
|
||||
|
||||
#### Scenario: Comparing existing files
|
||||
|
||||
- **WHEN** file exists in both locations
|
||||
- **THEN** show unified diff
|
||||
|
||||
#### Scenario: New files
|
||||
|
||||
- **WHEN** file only exists in change
|
||||
- **THEN** show as new file (all lines with +)
|
||||
|
||||
#### Scenario: Deleted files
|
||||
|
||||
- **WHEN** file only exists in current specs
|
||||
- **THEN** show as deleted (all lines with -)
|
||||
|
||||
### Requirement: Display Format
|
||||
|
||||
The command SHALL use standard unified diff format for consistency with existing tools.
|
||||
|
||||
#### Scenario: Formatting diff output
|
||||
|
||||
- **WHEN** displaying diff output
|
||||
- **THEN** use standard unified diff format:
|
||||
- Lines prefixed with `-` for removed content
|
||||
- Lines prefixed with `+` for added content
|
||||
- Lines without prefix for unchanged context
|
||||
- File headers showing the paths being compared
|
||||
|
||||
### Requirement: Color Support
|
||||
|
||||
The command SHALL enhance readability with colors when supported.
|
||||
|
||||
#### Scenario: Terminal with color support
|
||||
|
||||
- **WHEN** terminal supports colors
|
||||
- **THEN** display:
|
||||
- Removed lines in red
|
||||
- Added lines in green
|
||||
- File headers in bold
|
||||
- Context lines in default color
|
||||
|
||||
### Requirement: Error Handling
|
||||
|
||||
The command SHALL provide clear error messages for various failure conditions.
|
||||
|
||||
#### Scenario: Change not found
|
||||
|
||||
- **WHEN** specified change doesn't exist
|
||||
- **THEN** display error "Change '<name>' not found"
|
||||
|
||||
#### Scenario: No specs in change
|
||||
|
||||
- **WHEN** no specs directory in change
|
||||
- **THEN** display "No spec changes found for '<name>'"
|
||||
|
||||
#### Scenario: Missing changes directory
|
||||
|
||||
- **WHEN** changes directory doesn't exist
|
||||
- **THEN** display "No OpenSpec changes directory found"
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# View diff for specific change
|
||||
$ openspec diff add-auth-feature
|
||||
|
||||
--- specs/user-auth/spec.md
|
||||
+++ changes/add-auth-feature/specs/user-auth/spec.md
|
||||
@@ -10,6 +10,8 @@
|
||||
Users SHALL authenticate with email and password.
|
||||
|
||||
+Users MAY authenticate with OAuth providers.
|
||||
+
|
||||
WHEN credentials are valid THEN issue JWT token.
|
||||
|
||||
# List all changes and select
|
||||
$ openspec diff
|
||||
Available changes:
|
||||
1. add-auth-feature
|
||||
2. update-payment-flow
|
||||
3. add-status-command
|
||||
Select a change (1-3):
|
||||
```
|
||||
+111
-63
@@ -4,22 +4,30 @@
|
||||
|
||||
The `openspec init` command SHALL create a complete OpenSpec directory structure in any project, enabling immediate adoption of OpenSpec conventions with support for multiple AI coding assistants.
|
||||
|
||||
## Behavior
|
||||
## Requirements
|
||||
|
||||
### Progress Indicators
|
||||
### Requirement: Progress Indicators
|
||||
|
||||
WHEN executing initialization steps
|
||||
THEN validate environment silently in background (no output unless error)
|
||||
AND display progress with ora spinners:
|
||||
- Show spinner: "⠋ Creating OpenSpec structure..."
|
||||
- Then success: "✔ OpenSpec structure created"
|
||||
- Show spinner: "⠋ Configuring AI tools..."
|
||||
- Then success: "✔ AI tools configured"
|
||||
The command SHALL display progress indicators during initialization to provide clear feedback about each step.
|
||||
|
||||
### Directory Creation
|
||||
#### Scenario: Displaying initialization progress
|
||||
|
||||
WHEN `openspec init` is executed
|
||||
THEN create the following directory structure:
|
||||
- **WHEN** executing initialization steps
|
||||
- **THEN** validate environment silently in background (no output unless error)
|
||||
- **AND** display progress with ora spinners:
|
||||
- Show spinner: "⠋ Creating OpenSpec structure..."
|
||||
- Then success: "✔ OpenSpec structure created"
|
||||
- Show spinner: "⠋ Configuring AI tools..."
|
||||
- Then success: "✔ AI tools configured"
|
||||
|
||||
### Requirement: Directory Creation
|
||||
|
||||
The command SHALL create the complete OpenSpec directory structure with all required directories and files.
|
||||
|
||||
#### Scenario: Creating OpenSpec structure
|
||||
|
||||
- **WHEN** `openspec init` is executed
|
||||
- **THEN** create the following directory structure:
|
||||
```
|
||||
openspec/
|
||||
├── project.md
|
||||
@@ -29,27 +37,41 @@ openspec/
|
||||
└── archive/
|
||||
```
|
||||
|
||||
### File Generation
|
||||
### Requirement: File Generation
|
||||
|
||||
The command SHALL generate:
|
||||
- `README.md` containing complete OpenSpec instructions for AI assistants
|
||||
- `project.md` with project context template
|
||||
The command SHALL generate required template files with appropriate content for immediate use.
|
||||
|
||||
### AI Tool Configuration
|
||||
#### Scenario: Generating template files
|
||||
|
||||
WHEN run interactively
|
||||
THEN prompt user to select AI tools to configure:
|
||||
- Claude Code (updates/creates CLAUDE.md with OpenSpec markers)
|
||||
- Cursor (future)
|
||||
- Aider (future)
|
||||
- **WHEN** initializing OpenSpec
|
||||
- **THEN** generate `README.md` containing complete OpenSpec instructions for AI assistants
|
||||
- **AND** generate `project.md` with project context template
|
||||
|
||||
### AI Tool Configuration Details
|
||||
### Requirement: AI Tool Configuration
|
||||
|
||||
WHEN Claude Code is selected
|
||||
THEN create or update `CLAUDE.md` in the project root directory (not inside openspec/)
|
||||
The command SHALL configure AI coding assistants with OpenSpec instructions based on user selection.
|
||||
|
||||
WHEN CLAUDE.md does not exist
|
||||
THEN create new file with OpenSpec content wrapped in markers:
|
||||
#### Scenario: Prompting for AI tool selection
|
||||
|
||||
- **WHEN** run interactively
|
||||
- **THEN** prompt user to select AI tools to configure:
|
||||
- Claude Code (updates/creates CLAUDE.md with OpenSpec markers)
|
||||
- Cursor (future)
|
||||
- Aider (future)
|
||||
|
||||
### Requirement: AI Tool Configuration Details
|
||||
|
||||
The command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.
|
||||
|
||||
#### Scenario: Configuring Claude Code
|
||||
|
||||
- **WHEN** Claude Code is selected
|
||||
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
- **THEN** create new file with OpenSpec content wrapped in markers:
|
||||
```markdown
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Project
|
||||
@@ -62,51 +84,71 @@ See @openspec/README.md for detailed conventions and guidelines.
|
||||
<!-- OPENSPEC:END -->
|
||||
```
|
||||
|
||||
WHEN CLAUDE.md already exists
|
||||
THEN preserve all existing content
|
||||
AND insert OpenSpec content at the beginning of the file using markers
|
||||
AND ensure markers don't duplicate if they already exist
|
||||
#### Scenario: Updating existing CLAUDE.md
|
||||
|
||||
The marker system SHALL:
|
||||
- Use `<!-- OPENSPEC:START -->` to mark the beginning of managed content
|
||||
- Use `<!-- OPENSPEC:END -->` to mark the end of managed content
|
||||
- Allow OpenSpec to update its content without affecting user customizations
|
||||
- Preserve all content outside the markers intact
|
||||
- **WHEN** CLAUDE.md already exists
|
||||
- **THEN** preserve all existing content
|
||||
- **AND** insert OpenSpec content at the beginning of the file using markers
|
||||
- **AND** ensure markers don't duplicate if they already exist
|
||||
|
||||
#### Scenario: Managing content with markers
|
||||
|
||||
- **WHEN** using the marker system
|
||||
- **THEN** use `<!-- OPENSPEC:START -->` to mark the beginning of managed content
|
||||
- **AND** use `<!-- OPENSPEC:END -->` to mark the end of managed content
|
||||
- **AND** allow OpenSpec to update its content without affecting user customizations
|
||||
- **AND** preserve all content outside the markers intact
|
||||
|
||||
WHY use markers:
|
||||
- Users may have existing CLAUDE.md instructions they want to keep
|
||||
- OpenSpec can update its instructions in future versions
|
||||
- Clear boundary between OpenSpec-managed and user-managed content
|
||||
|
||||
### Interactive Mode
|
||||
### Requirement: Interactive Mode
|
||||
|
||||
WHEN run
|
||||
THEN prompt user with: "Which AI tool do you use?"
|
||||
AND show single-select menu with available tools:
|
||||
- Claude Code
|
||||
AND show disabled options as "coming soon" (not selectable):
|
||||
- Cursor (coming soon)
|
||||
- Aider (coming soon)
|
||||
- Continue (coming soon)
|
||||
The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.
|
||||
|
||||
User navigation:
|
||||
- Use arrow keys to move between options
|
||||
- Press Enter to select the highlighted option
|
||||
#### Scenario: Displaying interactive menu
|
||||
|
||||
### Safety Checks
|
||||
- **WHEN** run
|
||||
- **THEN** prompt user with: "Which AI tool do you use?"
|
||||
- **AND** show single-select menu with available tools:
|
||||
- Claude Code
|
||||
- **AND** show disabled options as "coming soon" (not selectable):
|
||||
- Cursor (coming soon)
|
||||
- Aider (coming soon)
|
||||
- Continue (coming soon)
|
||||
|
||||
WHEN `openspec/` directory already exists
|
||||
THEN display error with ora fail indicator:
|
||||
"✖ Error: OpenSpec seems to already be initialized. Use 'openspec update' to update the structure."
|
||||
#### Scenario: Navigating the menu
|
||||
|
||||
WHEN checking initialization feasibility
|
||||
THEN verify write permissions in the target directory silently
|
||||
AND only display error if permissions are insufficient
|
||||
- **WHEN** user is in the menu
|
||||
- **THEN** allow arrow keys to move between options
|
||||
- **AND** allow Enter key to select the highlighted option
|
||||
|
||||
### Success Output
|
||||
### Requirement: Safety Checks
|
||||
|
||||
WHEN initialization completes successfully
|
||||
THEN display actionable prompts for AI-driven workflow:
|
||||
The command SHALL perform safety checks to prevent overwriting existing structures and ensure proper permissions.
|
||||
|
||||
#### Scenario: Detecting existing initialization
|
||||
|
||||
- **WHEN** `openspec/` directory already exists
|
||||
- **THEN** display error with ora fail indicator:
|
||||
- "✖ Error: OpenSpec seems to already be initialized. Use 'openspec update' to update the structure."
|
||||
|
||||
#### Scenario: Checking write permissions
|
||||
|
||||
- **WHEN** checking initialization feasibility
|
||||
- **THEN** verify write permissions in the target directory silently
|
||||
- **AND** only display error if permissions are insufficient
|
||||
|
||||
### Requirement: Success Output
|
||||
|
||||
The command SHALL provide clear, actionable next steps upon successful initialization.
|
||||
|
||||
#### Scenario: Displaying success message
|
||||
|
||||
- **WHEN** initialization completes successfully
|
||||
- **THEN** display actionable prompts for AI-driven workflow:
|
||||
```
|
||||
✔ OpenSpec initialized successfully!
|
||||
|
||||
@@ -132,12 +174,18 @@ The prompts SHALL:
|
||||
- Guide users through the AI-driven workflow
|
||||
- Replace placeholder text ([YOUR FEATURE HERE]) with actual features
|
||||
|
||||
### Exit Codes
|
||||
### Requirement: Exit Codes
|
||||
|
||||
- 0: Success
|
||||
- 1: General error (including when OpenSpec directory already exists)
|
||||
- 2: Insufficient permissions (reserved for future use)
|
||||
- 3: User cancelled operation (reserved for future use)
|
||||
The command SHALL use consistent exit codes to indicate different failure modes.
|
||||
|
||||
#### Scenario: Returning exit codes
|
||||
|
||||
- **WHEN** the command completes
|
||||
- **THEN** return appropriate exit code:
|
||||
- 0: Success
|
||||
- 1: General error (including when OpenSpec directory already exists)
|
||||
- 2: Insufficient permissions (reserved for future use)
|
||||
- 3: User cancelled operation (reserved for future use)
|
||||
|
||||
## Why
|
||||
|
||||
|
||||
@@ -4,30 +4,42 @@
|
||||
|
||||
The `openspec list` command SHALL provide developers with a quick overview of all active changes in the project, showing their names and task completion status.
|
||||
|
||||
## Behavior
|
||||
## Requirements
|
||||
|
||||
### Command Execution
|
||||
### Requirement: Command Execution
|
||||
|
||||
WHEN `openspec list` is executed
|
||||
THEN scan the `openspec/changes/` directory for change directories
|
||||
AND exclude the `archive/` subdirectory from results
|
||||
AND parse each change's `tasks.md` file to count task completion
|
||||
The command SHALL scan and analyze all active changes to provide a comprehensive overview.
|
||||
|
||||
### Task Counting
|
||||
#### Scenario: Scanning for changes
|
||||
|
||||
WHEN parsing a `tasks.md` file
|
||||
THEN count tasks matching these patterns:
|
||||
- Completed: Lines containing `- [x]`
|
||||
- Incomplete: Lines containing `- [ ]`
|
||||
AND calculate total tasks as the sum of completed and incomplete
|
||||
- **WHEN** `openspec list` is executed
|
||||
- **THEN** scan the `openspec/changes/` directory for change directories
|
||||
- **AND** exclude the `archive/` subdirectory from results
|
||||
- **AND** parse each change's `tasks.md` file to count task completion
|
||||
|
||||
### Output Format
|
||||
### Requirement: Task Counting
|
||||
|
||||
WHEN displaying the list
|
||||
THEN show a table with columns:
|
||||
- Change name (directory name)
|
||||
- Task progress (e.g., "3/5 tasks" or "✓ Complete")
|
||||
- Status indicator:
|
||||
The command SHALL accurately count task completion status using standard markdown checkbox patterns.
|
||||
|
||||
#### Scenario: Counting tasks in tasks.md
|
||||
|
||||
- **WHEN** parsing a `tasks.md` file
|
||||
- **THEN** count tasks matching these patterns:
|
||||
- Completed: Lines containing `- [x]`
|
||||
- Incomplete: Lines containing `- [ ]`
|
||||
- **AND** calculate total tasks as the sum of completed and incomplete
|
||||
|
||||
### Requirement: Output Format
|
||||
|
||||
The command SHALL display changes in a clear, readable table format with progress indicators.
|
||||
|
||||
#### Scenario: Displaying change list
|
||||
|
||||
- **WHEN** displaying the list
|
||||
- **THEN** show a table with columns:
|
||||
- Change name (directory name)
|
||||
- Task progress (e.g., "3/5 tasks" or "✓ Complete")
|
||||
- **AND** use status indicators:
|
||||
- `✓` for fully completed changes (all tasks done)
|
||||
- Progress fraction for partial completion
|
||||
|
||||
@@ -40,23 +52,38 @@ Changes:
|
||||
add-list-command 1/4 tasks
|
||||
```
|
||||
|
||||
### Empty State
|
||||
### Requirement: Empty State
|
||||
|
||||
WHEN no active changes exist (only archive/ or empty changes/)
|
||||
THEN display: "No active changes found."
|
||||
The command SHALL provide clear feedback when no active changes are present.
|
||||
|
||||
### Error Handling
|
||||
#### Scenario: Handling empty state
|
||||
|
||||
IF a change directory has no `tasks.md` file
|
||||
THEN display the change with "No tasks" status
|
||||
- **WHEN** no active changes exist (only archive/ or empty changes/)
|
||||
- **THEN** display: "No active changes found."
|
||||
|
||||
IF `openspec/changes/` directory doesn't exist
|
||||
THEN display error: "No OpenSpec changes directory found. Run 'openspec init' first."
|
||||
AND exit with code 1
|
||||
### Requirement: Error Handling
|
||||
|
||||
### Sorting
|
||||
The command SHALL gracefully handle missing files and directories with appropriate messages.
|
||||
|
||||
Changes SHALL be displayed in alphabetical order by change name for consistency.
|
||||
#### Scenario: Missing tasks.md file
|
||||
|
||||
- **WHEN** a change directory has no `tasks.md` file
|
||||
- **THEN** display the change with "No tasks" status
|
||||
|
||||
#### Scenario: Missing changes directory
|
||||
|
||||
- **WHEN** `openspec/changes/` directory doesn't exist
|
||||
- **THEN** display error: "No OpenSpec changes directory found. Run 'openspec init' first."
|
||||
- **AND** exit with code 1
|
||||
|
||||
### Requirement: Sorting
|
||||
|
||||
The command SHALL maintain consistent ordering of changes for predictable output.
|
||||
|
||||
#### Scenario: Ordering changes
|
||||
|
||||
- **WHEN** displaying multiple changes
|
||||
- **THEN** sort them in alphabetical order by change name
|
||||
|
||||
## Why
|
||||
|
||||
|
||||
@@ -6,45 +6,70 @@ As a developer using OpenSpec, I want to update the OpenSpec instructions in my
|
||||
|
||||
## Core Requirements
|
||||
|
||||
### Update Behavior
|
||||
### Requirement: Update Behavior
|
||||
|
||||
The update command SHALL update OpenSpec instruction files to the latest templates.
|
||||
The update command SHALL update OpenSpec instruction files to the latest templates in a team-friendly manner.
|
||||
|
||||
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)
|
||||
- Update the OpenSpec-managed block in `CLAUDE.md` using markers
|
||||
- Preserve user content outside markers
|
||||
- Create `CLAUDE.md` if missing
|
||||
- Display ASCII-safe success message: "Updated OpenSpec instructions"
|
||||
#### Scenario: Running update command
|
||||
|
||||
### Prerequisites
|
||||
- **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)
|
||||
- Update **only existing** AI tool configuration files (e.g., CLAUDE.md)
|
||||
- Check each registered AI tool configurator
|
||||
- For each configurator, check if its file exists
|
||||
- Update only files that already exist using their markers
|
||||
- Preserve user content outside markers
|
||||
- **Never create new AI tool configuration files**
|
||||
- Display success message listing updated files
|
||||
|
||||
The command SHALL require:
|
||||
- An existing `openspec` directory (created by `openspec init`)
|
||||
### Requirement: Prerequisites
|
||||
|
||||
IF the `openspec` directory does not exist THEN:
|
||||
- Display error: "No OpenSpec directory found. Run 'openspec init' first."
|
||||
- Exit with code 1
|
||||
The command SHALL require an existing OpenSpec structure before allowing updates.
|
||||
|
||||
### File Handling
|
||||
#### Scenario: Checking prerequisites
|
||||
|
||||
The update command SHALL:
|
||||
- Completely replace `openspec/README.md` with the latest template
|
||||
- Update only the OpenSpec-managed block in `CLAUDE.md` using markers
|
||||
- Use the default directory name `openspec`
|
||||
- Be idempotent (repeated runs have no additional effect)
|
||||
- **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 OpenSpec-managed blocks in **existing** AI tool files using markers
|
||||
- **AND** use the default directory name `openspec`
|
||||
- **AND** be idempotent (repeated runs have no additional effect)
|
||||
- **AND** respect team members' AI tool choices by not creating unwanted files
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### File Permissions
|
||||
IF file write fails THEN let the error bubble up naturally with file path.
|
||||
### Requirement: Error Handling
|
||||
|
||||
### Missing CLAUDE.md
|
||||
IF CLAUDE.md doesn't exist THEN create it with the template content.
|
||||
The command SHALL handle edge cases gracefully.
|
||||
|
||||
### Custom Directory Name
|
||||
Not supported in this change. The default directory name `openspec` SHALL be used.
|
||||
#### Scenario: File permission errors
|
||||
|
||||
- **WHEN** file write fails
|
||||
- **THEN** let the error bubble up naturally with file path
|
||||
|
||||
#### Scenario: Missing AI tool files
|
||||
|
||||
- **WHEN** an AI tool configuration file doesn't exist
|
||||
- **THEN** skip updating that file
|
||||
- **AND** do not create it
|
||||
|
||||
#### 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
|
||||
|
||||
|
||||
@@ -14,8 +14,14 @@ The system SHALL follow these principles:
|
||||
|
||||
## Directory Structure
|
||||
|
||||
WHEN an OpenSpec project is initialized
|
||||
THEN it SHALL have this structure:
|
||||
### Requirement: Project Structure
|
||||
|
||||
An OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.
|
||||
|
||||
#### Scenario: Initializing project structure
|
||||
|
||||
- **WHEN** an OpenSpec project is initialized
|
||||
- **THEN** it SHALL have this structure:
|
||||
```
|
||||
openspec/
|
||||
├── project.md # Project-specific context
|
||||
@@ -36,23 +42,144 @@ openspec/
|
||||
└── YYYY-MM-DD-[name]/
|
||||
```
|
||||
|
||||
## Specification Format
|
||||
|
||||
### Requirement: Structured Format for Behavioral Specs
|
||||
|
||||
Behavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability.
|
||||
|
||||
#### Scenario: Writing requirement sections
|
||||
|
||||
- **WHEN** documenting a requirement in a behavioral specification
|
||||
- **THEN** use a level-3 heading with format `### Requirement: [Name]`
|
||||
- **AND** immediately follow with a SHALL statement describing core behavior
|
||||
- **AND** keep requirement names descriptive and under 50 characters
|
||||
|
||||
#### Scenario: Documenting scenarios
|
||||
|
||||
- **WHEN** documenting specific behaviors or use cases
|
||||
- **THEN** use level-4 headings with format `#### Scenario: [Description]`
|
||||
- **AND** use bullet points with bold keywords for steps:
|
||||
- **GIVEN** for initial state (optional)
|
||||
- **WHEN** for conditions or triggers
|
||||
- **THEN** for expected outcomes
|
||||
- **AND** for additional outcomes or conditions
|
||||
|
||||
#### Scenario: Adding implementation details
|
||||
|
||||
- **WHEN** a step requires additional detail
|
||||
- **THEN** use sub-bullets under the main step
|
||||
- **AND** maintain consistent indentation
|
||||
- Sub-bullets provide examples or specifics
|
||||
- Keep sub-bullets concise
|
||||
|
||||
|
||||
## Change Storage Convention
|
||||
|
||||
### Future State Storage
|
||||
### Requirement: Header-Based Requirement Identification
|
||||
|
||||
Requirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes.
|
||||
|
||||
#### Scenario: Matching requirements programmatically
|
||||
|
||||
- **WHEN** processing delta changes
|
||||
- **THEN** use the `### Requirement: [Name]` header as the unique identifier
|
||||
- **AND** match using normalized headers: `normalize(header) = trim(header)`
|
||||
- **AND** compare headers with case-sensitive equality after normalization
|
||||
|
||||
#### Scenario: Handling requirement renames
|
||||
|
||||
- **WHEN** renaming a requirement
|
||||
- **THEN** use a special `## RENAMED Requirements` section
|
||||
- **AND** specify both old and new names explicitly:
|
||||
```markdown
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
- **AND** if content also changes, include under MODIFIED using the NEW header
|
||||
|
||||
#### Scenario: Validating header uniqueness
|
||||
|
||||
- **WHEN** creating or modifying requirements
|
||||
- **THEN** ensure no duplicate headers exist within a spec
|
||||
- **AND** validation tools SHALL flag duplicate headers as errors
|
||||
|
||||
### Requirement: Change Storage Convention
|
||||
|
||||
Change proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states.
|
||||
|
||||
#### Scenario: Creating change proposals with additions
|
||||
|
||||
- **WHEN** creating a change proposal that adds new requirements
|
||||
- **THEN** include only the new requirements under `## ADDED Requirements`
|
||||
- **AND** each requirement SHALL include its complete content
|
||||
- **AND** use the standard structured format for requirements and scenarios
|
||||
|
||||
#### Scenario: Creating change proposals with modifications
|
||||
|
||||
- **WHEN** creating a change proposal that modifies existing requirements
|
||||
- **THEN** include the modified requirements under `## MODIFIED Requirements`
|
||||
- **AND** use the same header text as in the current spec (normalized)
|
||||
- **AND** include the complete modified requirement (not a diff)
|
||||
- **AND** optionally annotate what changed with inline comments like `← (was X)`
|
||||
|
||||
#### Scenario: Creating change proposals with removals
|
||||
|
||||
- **WHEN** creating a change proposal that removes requirements
|
||||
- **THEN** list them under `## REMOVED Requirements`
|
||||
- **AND** use the normalized header text for identification
|
||||
- **AND** include reason for removal
|
||||
- **AND** document any migration path if applicable
|
||||
|
||||
WHEN creating a change proposal
|
||||
THEN store the complete future state of affected specs
|
||||
AND use clean markdown without diff syntax
|
||||
|
||||
The `changes/[name]/specs/` directory SHALL contain:
|
||||
- Complete spec files as they will exist after the change
|
||||
- Clean markdown without `+` or `-` prefixes
|
||||
- All formatting and structure of the final intended state
|
||||
- Delta files showing only what changes
|
||||
- Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements
|
||||
- Normalized header matching for requirement identification
|
||||
- Complete requirements using the structured format
|
||||
- Clear indication of change type for each requirement
|
||||
|
||||
### Proposal Format
|
||||
#### Scenario: Using standard output symbols
|
||||
|
||||
WHEN documenting what changes
|
||||
THEN the proposal SHALL explicitly describe each change:
|
||||
- **WHEN** displaying delta operations in CLI output
|
||||
- **THEN** use these standard symbols:
|
||||
- `+` for ADDED (green)
|
||||
- `~` for MODIFIED (yellow)
|
||||
- `-` for REMOVED (red)
|
||||
- `→` for RENAMED (cyan)
|
||||
|
||||
### Requirement: Archive Process Enhancement
|
||||
|
||||
The archive process SHALL programmatically apply delta changes to current specifications using header-based matching.
|
||||
|
||||
#### Scenario: Archiving changes with deltas
|
||||
|
||||
- **WHEN** archiving a completed change
|
||||
- **THEN** the archive command SHALL:
|
||||
1. Parse RENAMED sections first and apply renames
|
||||
2. Parse REMOVED sections and remove by normalized header match
|
||||
3. Parse MODIFIED sections and replace by normalized header match (using new names if renamed)
|
||||
4. Parse ADDED sections and append new requirements
|
||||
- **AND** validate that all MODIFIED/REMOVED headers exist in current spec
|
||||
- **AND** validate that ADDED headers don't already exist
|
||||
- **AND** generate the updated spec in the main specs/ directory
|
||||
|
||||
#### Scenario: Handling conflicts during archive
|
||||
|
||||
- **WHEN** delta changes conflict with current spec state
|
||||
- **THEN** the archive command SHALL report specific conflicts
|
||||
- **AND** require manual resolution before proceeding
|
||||
- **AND** provide clear guidance on resolving conflicts
|
||||
|
||||
### Requirement: Proposal Format
|
||||
|
||||
Proposals SHALL explicitly document all changes with clear from/to comparisons.
|
||||
|
||||
#### Scenario: Documenting changes
|
||||
|
||||
- **WHEN** documenting what changes
|
||||
- **THEN** the proposal SHALL explicitly describe each change:
|
||||
|
||||
```markdown
|
||||
**[Section or Behavior Name]**
|
||||
@@ -78,8 +205,14 @@ The change process SHALL follow these states:
|
||||
|
||||
## Viewing Changes
|
||||
|
||||
WHEN reviewing proposed changes
|
||||
THEN reviewers can compare using:
|
||||
### Requirement: Change Review
|
||||
|
||||
The system SHALL support multiple methods for reviewing proposed changes.
|
||||
|
||||
#### Scenario: Reviewing changes
|
||||
|
||||
- **WHEN** reviewing proposed changes
|
||||
- **THEN** reviewers can compare using:
|
||||
- GitHub PR diff view when changes are committed
|
||||
- Command line: `diff -u specs/[capability]/spec.md changes/[name]/specs/[capability]/spec.md`
|
||||
- Any visual diff tool comparing current vs future state
|
||||
@@ -117,4 +250,9 @@ Clean future state storage provides:
|
||||
- **AI-compatibility**: Standard markdown that AI tools understand
|
||||
- **Simplicity**: No special parsing or processing needed
|
||||
- **Tool-agnostic**: Any diff tool can show changes
|
||||
- **Clear intent**: Explicit proposals document reasoning
|
||||
- **Clear intent**: Explicit proposals document reasoning
|
||||
|
||||
The structured format adds:
|
||||
- **Visual Consistency**: Requirement and Scenario prefixes make sections instantly recognizable
|
||||
- **Parseability**: Consistent structure enables tooling and automation
|
||||
- **Gradual Adoption**: Existing specs can migrate incrementally
|
||||
+2
-1
@@ -56,6 +56,7 @@
|
||||
"chalk": "^5.5.0",
|
||||
"commander": "^14.0.0",
|
||||
"jest-diff": "^30.0.5",
|
||||
"ora": "^8.2.0"
|
||||
"ora": "^8.2.0",
|
||||
"zod": "^4.0.17"
|
||||
}
|
||||
}
|
||||
Generated
+8
@@ -23,6 +23,9 @@ importers:
|
||||
ora:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
zod:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.2.0
|
||||
@@ -900,6 +903,9 @@ packages:
|
||||
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
zod@4.0.17:
|
||||
resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.8':
|
||||
@@ -1631,3 +1637,5 @@ snapshots:
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
yoctocolors-cjs@2.1.2: {}
|
||||
|
||||
zod@4.0.17: {}
|
||||
|
||||
+72
-3
@@ -7,6 +7,8 @@ import { UpdateCommand } from '../core/update.js';
|
||||
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';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
@@ -15,6 +17,17 @@ program
|
||||
.description('AI-native system for spec-driven development')
|
||||
.version('0.0.1');
|
||||
|
||||
// Global options
|
||||
program.option('--no-color', 'Disable color output');
|
||||
|
||||
// Apply global flags before any command runs
|
||||
program.hook('preAction', (thisCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.noColor) {
|
||||
process.env.NO_COLOR = '1';
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('init [path]')
|
||||
.description('Initialize OpenSpec in your project')
|
||||
@@ -65,7 +78,7 @@ program
|
||||
|
||||
program
|
||||
.command('diff [change-name]')
|
||||
.description('Show differences between proposed spec changes and current specs')
|
||||
.description('Show differences between proposed spec changes and current specs (includes validation warnings)')
|
||||
.action(async (changeName?: string) => {
|
||||
try {
|
||||
const diffCommand = new DiffCommand();
|
||||
@@ -79,9 +92,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) {
|
||||
@@ -91,11 +105,64 @@ 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')
|
||||
.action(async (changeName?: string, options?: { strict?: boolean; json?: 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')
|
||||
.option('-y, --yes', 'Skip confirmation prompts')
|
||||
.action(async (changeName?: string, options?: { yes?: boolean }) => {
|
||||
.option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)')
|
||||
.option('--no-validate', 'Skip validation (not recommended, requires confirmation)')
|
||||
.action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean }) => {
|
||||
try {
|
||||
const archiveCommand = new ArchiveCommand();
|
||||
await archiveCommand.execute(changeName, options);
|
||||
@@ -106,4 +173,6 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
registerSpecCommand(program);
|
||||
|
||||
program.parse();
|
||||
@@ -0,0 +1,248 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
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';
|
||||
|
||||
// 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 }): 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}`);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { program } from 'commander';
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
|
||||
import { Validator } from '../core/validation/validator.js';
|
||||
import type { Spec } from '../core/schemas/index.js';
|
||||
|
||||
const SPECS_DIR = 'openspec/specs';
|
||||
|
||||
export function registerSpecCommand(rootProgram: typeof program) {
|
||||
const specCommand = rootProgram
|
||||
.command('spec')
|
||||
.description('Manage and view OpenSpec specifications');
|
||||
|
||||
interface ShowOptions {
|
||||
json?: boolean;
|
||||
// JSON-only filters (raw-first text has no filters)
|
||||
requirements?: boolean;
|
||||
scenarios?: boolean; // --no-scenarios sets this to false (JSON only)
|
||||
requirement?: string; // JSON only
|
||||
}
|
||||
|
||||
function parseSpecFromFile(specPath: string, specId: string): Spec {
|
||||
const content = readFileSync(specPath, 'utf-8');
|
||||
const parser = new MarkdownParser(content);
|
||||
return parser.parseSpec(specId);
|
||||
}
|
||||
|
||||
function validateRequirementIndex(spec: Spec, requirementOpt?: string): number | undefined {
|
||||
if (!requirementOpt) return undefined;
|
||||
const index = Number.parseInt(requirementOpt, 10);
|
||||
if (!Number.isInteger(index) || index < 1 || index > spec.requirements.length) {
|
||||
throw new Error(`Requirement ${requirementOpt} not found`);
|
||||
}
|
||||
return index - 1; // convert to 0-based
|
||||
}
|
||||
|
||||
function filterSpec(spec: Spec, options: ShowOptions): Spec {
|
||||
const requirementIndex = validateRequirementIndex(spec, options.requirement);
|
||||
const includeScenarios = options.scenarios !== false && !options.requirements;
|
||||
|
||||
const filteredRequirements = (requirementIndex !== undefined
|
||||
? [spec.requirements[requirementIndex]]
|
||||
: spec.requirements
|
||||
).map(req => ({
|
||||
text: req.text,
|
||||
scenarios: includeScenarios ? req.scenarios : [],
|
||||
}));
|
||||
|
||||
const metadata = spec.metadata ?? { version: '1.0.0', format: 'openspec' as const };
|
||||
|
||||
return {
|
||||
name: spec.name,
|
||||
overview: spec.overview,
|
||||
requirements: filteredRequirements,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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', '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');
|
||||
|
||||
if (!existsSync(specPath)) {
|
||||
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
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 {
|
||||
// raw-first text: print raw file
|
||||
printSpecTextRaw(specPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
specCommand
|
||||
.command('list')
|
||||
.description('List all available specifications')
|
||||
.option('--json', 'Output as JSON')
|
||||
.option('--long', 'Show id and title with counts')
|
||||
.action((options: { json?: boolean; long?: boolean }) => {
|
||||
try {
|
||||
if (!existsSync(SPECS_DIR)) {
|
||||
console.log('No items found');
|
||||
return;
|
||||
}
|
||||
|
||||
const specs = readdirSync(SPECS_DIR, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => {
|
||||
const specPath = join(SPECS_DIR, dirent.name, 'spec.md');
|
||||
if (existsSync(specPath)) {
|
||||
try {
|
||||
const spec = parseSpecFromFile(specPath, dirent.name);
|
||||
|
||||
return {
|
||||
id: dirent.name,
|
||||
title: spec.name,
|
||||
requirementCount: spec.requirements.length
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: dirent.name,
|
||||
title: dirent.name,
|
||||
requirementCount: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
return 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 {
|
||||
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(`${spec.id}: ${spec.title} [requirements ${spec.requirementCount}]`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
specCommand
|
||||
.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 }) => {
|
||||
try {
|
||||
const specPath = join(SPECS_DIR, specId, 'spec.md');
|
||||
|
||||
if (!existsSync(specPath)) {
|
||||
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
|
||||
}
|
||||
|
||||
const validator = new Validator(options.strict);
|
||||
const report = await validator.validateSpec(specPath);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
if (report.valid) {
|
||||
console.log(`Specification '${specId}' is valid`);
|
||||
} else {
|
||||
console.error(`Specification '${specId}' has issues`);
|
||||
report.issues.forEach(issue => {
|
||||
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(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return specCommand;
|
||||
}
|
||||
+119
-25
@@ -2,6 +2,8 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { select, confirm } from '@inquirer/prompts';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { Validator } from './validation/validator.js';
|
||||
import chalk from 'chalk';
|
||||
|
||||
interface SpecUpdate {
|
||||
source: string;
|
||||
@@ -10,7 +12,7 @@ interface SpecUpdate {
|
||||
}
|
||||
|
||||
export class ArchiveCommand {
|
||||
async execute(changeName?: string, options: { yes?: boolean } = {}): Promise<void> {
|
||||
async execute(changeName?: string, options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean } = {}): Promise<void> {
|
||||
const targetPath = '.';
|
||||
const changesDir = path.join(targetPath, 'openspec', 'changes');
|
||||
const archiveDir = path.join(changesDir, 'archive');
|
||||
@@ -45,6 +47,91 @@ export class ArchiveCommand {
|
||||
throw new Error(`Change '${changeName}' not found.`);
|
||||
}
|
||||
|
||||
// Validate specs and change before archiving
|
||||
if (!options.noValidate) {
|
||||
const validator = new Validator();
|
||||
let hasValidationErrors = false;
|
||||
|
||||
// Validate change.md file
|
||||
const changeFile = path.join(changeDir, 'change.md');
|
||||
try {
|
||||
await fs.access(changeFile);
|
||||
const changeReport = await validator.validateChange(changeFile);
|
||||
|
||||
if (!changeReport.valid) {
|
||||
hasValidationErrors = true;
|
||||
console.log(chalk.red(`\nValidation errors in change.md:`));
|
||||
for (const issue of changeReport.issues) {
|
||||
if (issue.level === 'ERROR') {
|
||||
console.log(chalk.red(` ✗ ${issue.message}`));
|
||||
} else if (issue.level === 'WARNING') {
|
||||
console.log(chalk.yellow(` ⚠ ${issue.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Change file doesn't exist, skip validation
|
||||
}
|
||||
|
||||
// Validate spec files
|
||||
const changeSpecsDir = path.join(changeDir, 'specs');
|
||||
try {
|
||||
const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
|
||||
|
||||
try {
|
||||
await fs.access(specFile);
|
||||
const report = await validator.validateSpec(specFile);
|
||||
|
||||
if (!report.valid) {
|
||||
hasValidationErrors = true;
|
||||
console.log(chalk.red(`\nValidation errors in ${entry.name}/spec.md:`));
|
||||
for (const issue of report.issues) {
|
||||
if (issue.level === 'ERROR') {
|
||||
console.log(chalk.red(` ✗ ${issue.message}`));
|
||||
} else if (issue.level === 'WARNING') {
|
||||
console.log(chalk.yellow(` ⚠ ${issue.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Spec file doesn't exist, skip validation
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No specs directory, skip validation
|
||||
}
|
||||
|
||||
if (hasValidationErrors) {
|
||||
console.log(chalk.red('\nValidation failed. Please fix the errors before archiving.'));
|
||||
console.log(chalk.yellow('To skip validation (not recommended), use --no-validate flag.'));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Log warning when validation is skipped
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
if (!options.yes) {
|
||||
const proceed = await confirm({
|
||||
message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),
|
||||
default: false
|
||||
});
|
||||
if (!proceed) {
|
||||
console.log('Archive cancelled.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.yellow(`\n⚠️ WARNING: Skipping validation may archive invalid specs.`));
|
||||
}
|
||||
|
||||
console.log(chalk.yellow(`[${timestamp}] Validation skipped for change: ${changeName}`));
|
||||
console.log(chalk.yellow(`Affected files: ${changeDir}`));
|
||||
}
|
||||
|
||||
// Check for incomplete tasks
|
||||
const tasksPath = path.join(changeDir, 'tasks.md');
|
||||
const incompleteTasks = await this.checkIncompleteTasks(tasksPath);
|
||||
@@ -64,33 +151,40 @@ export class ArchiveCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// Find specs to update
|
||||
const specUpdates = await this.findSpecUpdates(changeDir, mainSpecsDir);
|
||||
|
||||
if (specUpdates.length > 0) {
|
||||
console.log('\nSpecs to update:');
|
||||
for (const update of specUpdates) {
|
||||
const status = update.exists ? 'update' : 'create';
|
||||
const capability = path.basename(path.dirname(update.target));
|
||||
console.log(` ${capability}: ${status}`);
|
||||
}
|
||||
// Handle spec updates unless skipSpecs flag is set
|
||||
if (options.skipSpecs) {
|
||||
console.log('Skipping spec updates (--skip-specs flag provided).');
|
||||
} else {
|
||||
// Find specs to update
|
||||
const specUpdates = await this.findSpecUpdates(changeDir, mainSpecsDir);
|
||||
|
||||
if (specUpdates.length > 0) {
|
||||
console.log('\nSpecs to update:');
|
||||
for (const update of specUpdates) {
|
||||
const status = update.exists ? 'update' : 'create';
|
||||
const capability = path.basename(path.dirname(update.target));
|
||||
console.log(` ${capability}: ${status}`);
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const proceed = await confirm({
|
||||
message: 'Proceed with spec updates?',
|
||||
default: true
|
||||
});
|
||||
if (!proceed) {
|
||||
console.log('Archive cancelled.');
|
||||
return;
|
||||
let shouldUpdateSpecs = true;
|
||||
if (!options.yes) {
|
||||
shouldUpdateSpecs = await confirm({
|
||||
message: 'Proceed with spec updates?',
|
||||
default: true
|
||||
});
|
||||
if (!shouldUpdateSpecs) {
|
||||
console.log('Skipping spec updates. Proceeding with archive.');
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUpdateSpecs) {
|
||||
// Update specs
|
||||
for (const update of specUpdates) {
|
||||
await this.updateSpec(update);
|
||||
}
|
||||
console.log('Specs updated successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
// Update specs
|
||||
for (const update of specUpdates) {
|
||||
await this.updateSpec(update);
|
||||
}
|
||||
console.log('Specs updated successfully.');
|
||||
}
|
||||
|
||||
// Create archive directory with date prefix
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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 {
|
||||
convertSpecToJson(filePath: string): string {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const parser = new MarkdownParser(content);
|
||||
const specName = this.extractNameFromPath(filePath);
|
||||
|
||||
const spec = parser.parseSpec(specName);
|
||||
|
||||
const jsonSpec = {
|
||||
...spec,
|
||||
metadata: {
|
||||
...spec.metadata,
|
||||
sourcePath: filePath,
|
||||
},
|
||||
};
|
||||
|
||||
return JSON.stringify(jsonSpec, null, 2);
|
||||
}
|
||||
|
||||
async convertChangeToJson(filePath: string): Promise<string> {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const changeName = this.extractNameFromPath(filePath);
|
||||
const changeDir = path.dirname(filePath);
|
||||
const parser = new ChangeParser(content, changeDir);
|
||||
|
||||
const change = await parser.parseChangeWithDeltas(changeName);
|
||||
|
||||
const jsonChange = {
|
||||
...change,
|
||||
metadata: {
|
||||
...change.metadata,
|
||||
sourcePath: filePath,
|
||||
},
|
||||
};
|
||||
|
||||
return JSON.stringify(jsonChange, null, 2);
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const parts = filePath.split('/');
|
||||
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i] === 'specs' || parts[i] === 'changes') {
|
||||
if (i < parts.length - 1) {
|
||||
return parts[i + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fileName = parts[parts.length - 1];
|
||||
return fileName.replace('.md', '');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import { diffStringsUnified } from 'jest-diff';
|
||||
import { select } from '@inquirer/prompts';
|
||||
import { Validator } from './validation/validator.js';
|
||||
|
||||
// Constants
|
||||
const ARCHIVE_DIR = 'archive';
|
||||
@@ -47,6 +48,53 @@ export class DiffCommand {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate specs and show warnings (non-blocking)
|
||||
const validator = new Validator();
|
||||
let hasWarnings = false;
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
|
||||
|
||||
try {
|
||||
await fs.access(specFile);
|
||||
const report = await validator.validateSpec(specFile);
|
||||
|
||||
if (report.issues.length > 0) {
|
||||
const warnings = report.issues.filter(i => i.level === 'WARNING');
|
||||
const errors = report.issues.filter(i => i.level === 'ERROR');
|
||||
|
||||
if (errors.length > 0 || warnings.length > 0) {
|
||||
if (!hasWarnings) {
|
||||
console.log(chalk.yellow('\n⚠️ Validation warnings found:'));
|
||||
hasWarnings = true;
|
||||
}
|
||||
|
||||
console.log(chalk.yellow(`\n ${entry.name}/spec.md:`));
|
||||
for (const issue of errors) {
|
||||
console.log(chalk.red(` ✗ ${issue.message}`));
|
||||
}
|
||||
for (const issue of warnings) {
|
||||
console.log(chalk.yellow(` ⚠ ${issue.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Spec file doesn't exist, skip validation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasWarnings) {
|
||||
console.log(chalk.yellow('\nConsider fixing these issues before archiving.\n'));
|
||||
}
|
||||
} catch {
|
||||
// No specs directory, skip validation
|
||||
}
|
||||
|
||||
// Reset counters
|
||||
this.filesChanged = 0;
|
||||
this.linesAdded = 0;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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}`,
|
||||
requirement: 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Spec, Change, Requirement, Scenario, Delta, DeltaOperation } from '../schemas/index.js';
|
||||
|
||||
export interface Section {
|
||||
level: number;
|
||||
title: string;
|
||||
content: string;
|
||||
children: Section[];
|
||||
}
|
||||
|
||||
export class MarkdownParser {
|
||||
private lines: string[];
|
||||
private currentLine: number;
|
||||
|
||||
constructor(content: string) {
|
||||
this.lines = content.split('\n');
|
||||
this.currentLine = 0;
|
||||
}
|
||||
|
||||
parseSpec(name: string): Spec {
|
||||
const sections = this.parseSections();
|
||||
const purpose = this.findSection(sections, 'Purpose')?.content || '';
|
||||
|
||||
const requirementsSection = this.findSection(sections, 'Requirements');
|
||||
|
||||
if (!purpose) {
|
||||
throw new Error('Spec must have a Purpose section');
|
||||
}
|
||||
|
||||
if (!requirementsSection) {
|
||||
throw new Error('Spec must have a Requirements section');
|
||||
}
|
||||
|
||||
const requirements = this.parseRequirements(requirementsSection);
|
||||
|
||||
return {
|
||||
name,
|
||||
overview: purpose.trim(),
|
||||
requirements,
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
format: 'openspec',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
parseChange(name: string): 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');
|
||||
}
|
||||
|
||||
const deltas = this.parseDeltas(whatChanges);
|
||||
|
||||
return {
|
||||
name,
|
||||
why: why.trim(),
|
||||
whatChanges: whatChanges.trim(),
|
||||
deltas,
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
format: 'openspec-change',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected parseSections(): Section[] {
|
||||
const sections: Section[] = [];
|
||||
const stack: Section[] = [];
|
||||
|
||||
for (let i = 0; i < this.lines.length; i++) {
|
||||
const line = this.lines[i];
|
||||
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
const title = headerMatch[2].trim();
|
||||
const content = this.getContentUntilNextHeader(i + 1, level);
|
||||
|
||||
const section: Section = {
|
||||
level,
|
||||
title,
|
||||
content,
|
||||
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;
|
||||
}
|
||||
|
||||
protected getContentUntilNextHeader(startLine: number, currentLevel: number): string {
|
||||
const contentLines: string[] = [];
|
||||
|
||||
for (let i = startLine; i < this.lines.length; i++) {
|
||||
const line = this.lines[i];
|
||||
const headerMatch = line.match(/^(#{1,6})\s+/);
|
||||
|
||||
if (headerMatch && headerMatch[1].length <= currentLevel) {
|
||||
break;
|
||||
}
|
||||
|
||||
contentLines.push(line);
|
||||
}
|
||||
|
||||
return contentLines.join('\n').trim();
|
||||
}
|
||||
|
||||
protected findSection(sections: Section[], title: string): Section | undefined {
|
||||
for (const section of sections) {
|
||||
if (section.title.toLowerCase() === title.toLowerCase()) {
|
||||
return section;
|
||||
}
|
||||
const child = this.findSection(section.children, title);
|
||||
if (child) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected parseRequirements(section: Section): Requirement[] {
|
||||
const requirements: Requirement[] = [];
|
||||
|
||||
for (const child of section.children) {
|
||||
// Extract requirement text from first non-empty content line, fall back to heading
|
||||
let text = child.title;
|
||||
|
||||
// Get content before any child sections (scenarios)
|
||||
if (child.content.trim()) {
|
||||
// Split content into lines and find content before any child headers
|
||||
const lines = child.content.split('\n');
|
||||
const contentBeforeChildren: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Stop at child headers (scenarios start with ####)
|
||||
if (line.trim().startsWith('#')) {
|
||||
break;
|
||||
}
|
||||
contentBeforeChildren.push(line);
|
||||
}
|
||||
|
||||
// Find first non-empty line
|
||||
const directContent = contentBeforeChildren.join('\n').trim();
|
||||
if (directContent) {
|
||||
const firstLine = directContent.split('\n').find(l => l.trim());
|
||||
if (firstLine) {
|
||||
text = firstLine.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scenarios = this.parseScenarios(child);
|
||||
|
||||
requirements.push({
|
||||
text,
|
||||
scenarios,
|
||||
});
|
||||
}
|
||||
|
||||
return requirements;
|
||||
}
|
||||
|
||||
protected parseScenarios(requirementSection: Section): Scenario[] {
|
||||
const scenarios: Scenario[] = [];
|
||||
|
||||
for (const scenarioSection of requirementSection.children) {
|
||||
// Store the raw text content of the scenario section
|
||||
if (scenarioSection.content.trim()) {
|
||||
scenarios.push({
|
||||
rawText: scenarioSection.content
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return scenarios;
|
||||
}
|
||||
|
||||
|
||||
protected parseDeltas(content: string): Delta[] {
|
||||
const deltas: Delta[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// 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();
|
||||
|
||||
let operation: DeltaOperation = 'MODIFIED';
|
||||
const lowerDesc = description.toLowerCase();
|
||||
|
||||
// Use word boundaries to avoid false matches (e.g., "address" matching "add")
|
||||
// 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';
|
||||
}
|
||||
|
||||
deltas.push({
|
||||
spec: specName,
|
||||
operation,
|
||||
description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return deltas;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
import { VALIDATION_MESSAGES } from '../validation/constants.js';
|
||||
|
||||
export const ScenarioSchema = z.object({
|
||||
rawText: z.string().min(1, VALIDATION_MESSAGES.SCENARIO_EMPTY),
|
||||
});
|
||||
|
||||
export const RequirementSchema = z.object({
|
||||
text: z.string()
|
||||
.min(1, VALIDATION_MESSAGES.REQUIREMENT_EMPTY)
|
||||
.refine(
|
||||
(text) => text.includes('SHALL') || text.includes('MUST'),
|
||||
VALIDATION_MESSAGES.REQUIREMENT_NO_SHALL
|
||||
),
|
||||
scenarios: z.array(ScenarioSchema)
|
||||
.min(1, VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS),
|
||||
});
|
||||
|
||||
export type Scenario = z.infer<typeof ScenarioSchema>;
|
||||
export type Requirement = z.infer<typeof RequirementSchema>;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
import { RequirementSchema } from './base.schema.js';
|
||||
import {
|
||||
MIN_WHY_SECTION_LENGTH,
|
||||
MAX_WHY_SECTION_LENGTH,
|
||||
MAX_DELTAS_PER_CHANGE,
|
||||
VALIDATION_MESSAGES
|
||||
} from '../validation/constants.js';
|
||||
|
||||
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({
|
||||
name: z.string().min(1, VALIDATION_MESSAGES.CHANGE_NAME_EMPTY),
|
||||
why: z.string()
|
||||
.min(MIN_WHY_SECTION_LENGTH, VALIDATION_MESSAGES.CHANGE_WHY_TOO_SHORT)
|
||||
.max(MAX_WHY_SECTION_LENGTH, VALIDATION_MESSAGES.CHANGE_WHY_TOO_LONG),
|
||||
whatChanges: z.string().min(1, VALIDATION_MESSAGES.CHANGE_WHAT_EMPTY),
|
||||
deltas: z.array(DeltaSchema)
|
||||
.min(1, VALIDATION_MESSAGES.CHANGE_NO_DELTAS)
|
||||
.max(MAX_DELTAS_PER_CHANGE, VALIDATION_MESSAGES.CHANGE_TOO_MANY_DELTAS),
|
||||
metadata: z.object({
|
||||
version: z.string().default('1.0.0'),
|
||||
format: z.literal('openspec-change'),
|
||||
sourcePath: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type DeltaOperation = z.infer<typeof DeltaOperationType>;
|
||||
export type Delta = z.infer<typeof DeltaSchema>;
|
||||
export type Change = z.infer<typeof ChangeSchema>;
|
||||
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
ScenarioSchema,
|
||||
RequirementSchema,
|
||||
type Scenario,
|
||||
type Requirement,
|
||||
} from './base.schema.js';
|
||||
|
||||
export {
|
||||
SpecSchema,
|
||||
type Spec,
|
||||
} from './spec.schema.js';
|
||||
|
||||
export {
|
||||
DeltaOperationType,
|
||||
DeltaSchema,
|
||||
ChangeSchema,
|
||||
type DeltaOperation,
|
||||
type Delta,
|
||||
type Change,
|
||||
} from './change.schema.js';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
import { RequirementSchema } from './base.schema.js';
|
||||
import { VALIDATION_MESSAGES } from '../validation/constants.js';
|
||||
|
||||
export const SpecSchema = z.object({
|
||||
name: z.string().min(1, VALIDATION_MESSAGES.SPEC_NAME_EMPTY),
|
||||
overview: z.string().min(1, VALIDATION_MESSAGES.SPEC_PURPOSE_EMPTY),
|
||||
requirements: z.array(RequirementSchema)
|
||||
.min(1, VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS),
|
||||
metadata: z.object({
|
||||
version: z.string().default('1.0.0'),
|
||||
format: z.literal('openspec'),
|
||||
sourcePath: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type Spec = z.infer<typeof SpecSchema>;
|
||||
@@ -7,7 +7,7 @@ export interface ProjectContext {
|
||||
|
||||
export const projectTemplate = (context: ProjectContext = {}) => `# ${context.projectName || 'Project'} Context
|
||||
|
||||
## Overview
|
||||
## Purpose
|
||||
${context.description || '[Describe your project\'s purpose and goals]'}
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -43,9 +43,9 @@ openspec/
|
||||
│ │ ├── proposal.md # Why, what, impact (consolidated)
|
||||
│ │ ├── tasks.md # Implementation checklist
|
||||
│ │ ├── design.md # Technical decisions (optional, for complex changes)
|
||||
│ │ └── specs/ # Future state of affected specs
|
||||
│ │ └── specs/ # Delta changes to specs
|
||||
│ │ └── [capability]/
|
||||
│ │ └── spec.md # Clean markdown (no diff syntax)
|
||||
│ │ └── spec.md # Delta format (ADDED/MODIFIED/REMOVED/RENAMED)
|
||||
│ └── archive/ # Completed changes (dated)
|
||||
\`\`\`
|
||||
|
||||
@@ -94,7 +94,35 @@ Before any task:
|
||||
- If adding dependencies, frameworks, or architectural patterns, document why simpler alternatives won't work
|
||||
- Default to single-file implementations until proven insufficient
|
||||
|
||||
### 3. Creating a Change Proposal
|
||||
### 3. Delta-Based Change Format
|
||||
|
||||
Changes use a delta format with clear sections:
|
||||
|
||||
\`\`\`markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: New Feature
|
||||
[Complete requirement content in structured format]
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Existing Feature
|
||||
[Complete modified requirement (header must match current spec)]
|
||||
|
||||
## REMOVED Requirements
|
||||
### Requirement: Old Feature
|
||||
**Reason for removal**: [Why removing]
|
||||
**Migration path**: [How to handle existing usage]
|
||||
|
||||
## RENAMED Requirements
|
||||
- FROM: \`### Requirement: Old Name\`
|
||||
- TO: \`### Requirement: New Name\`
|
||||
\`\`\`
|
||||
|
||||
Key rules:
|
||||
- Headers are matched using \`normalize(header) = trim(header)\`
|
||||
- Include complete requirements (not diffs)
|
||||
- Use standard symbols in CLI output: + (added), ~ (modified), - (removed), → (renamed)
|
||||
|
||||
### 4. Creating a Change Proposal
|
||||
|
||||
When a user requests a significant change:
|
||||
|
||||
@@ -113,13 +141,21 @@ openspec/changes/[descriptive-name]/
|
||||
- Affected specs: [list capabilities that will change]
|
||||
- Affected code: [list key files/systems]
|
||||
|
||||
# 3. Create future state specs for ALL affected capabilities
|
||||
# - Store complete spec files as they will exist after the change
|
||||
# - Use clean markdown without diff syntax (+/- prefixes)
|
||||
# - Include all formatting and structure of the final intended state
|
||||
# 3. Create delta specs for ALL affected capabilities
|
||||
# - Store only the changes (not complete future state)
|
||||
# - Use sections: ## ADDED, ## MODIFIED, ## REMOVED, ## RENAMED
|
||||
# - Include complete requirements in their final form
|
||||
# Example spec.md content:
|
||||
# ## ADDED Requirements
|
||||
# ### Requirement: Password Reset
|
||||
# Users SHALL be able to reset passwords via email...
|
||||
#
|
||||
# ## MODIFIED Requirements
|
||||
# ### Requirement: User Authentication
|
||||
# [Complete modified requirement with new password reset hook]
|
||||
specs/
|
||||
└── [capability]/
|
||||
└── spec.md
|
||||
└── spec.md # Contains delta sections
|
||||
|
||||
# 4. Create tasks.md with implementation steps
|
||||
## 1. [Task Group]
|
||||
@@ -130,16 +166,16 @@ specs/
|
||||
[Technical decisions and trade-offs]
|
||||
\`\`\`
|
||||
|
||||
### 4. The Change Lifecycle
|
||||
### 5. The Change Lifecycle
|
||||
|
||||
1. **Propose** → Create change directory with all documentation
|
||||
1. **Propose** → Create change directory with delta-based documentation
|
||||
2. **Review** → User reviews and approves the proposal
|
||||
3. **Implement** → Follow the approved tasks.md (can be multiple PRs)
|
||||
4. **Deploy** → User confirms deployment
|
||||
5. **Update Specs** → Sync specs/ with new reality (IF the change affects system capabilities)
|
||||
5. **Update Specs** → Apply deltas to sync specs/ with new reality (IF the change affects system capabilities)
|
||||
6. **Archive** → Move to \`changes/archive/YYYY-MM-DD-[name]/\`
|
||||
|
||||
### 5. Implementing Changes
|
||||
### 6. Implementing Changes
|
||||
|
||||
When implementing an approved change:
|
||||
1. Follow the tasks.md checklist exactly
|
||||
@@ -154,7 +190,7 @@ When implementing an approved change:
|
||||
- Different developers can work on different task groups
|
||||
- Example: PR #1 completes tasks 1.1-1.3, PR #2 completes tasks 2.1-2.4
|
||||
|
||||
### 6. Updating Specs and Archiving After Deployment
|
||||
### 7. Updating Specs and Archiving After Deployment
|
||||
|
||||
**Create a separate PR after deployment** that:
|
||||
1. Moves change to \`changes/archive/YYYY-MM-DD-[name]/\`
|
||||
@@ -163,7 +199,7 @@ When implementing an approved change:
|
||||
|
||||
This ensures changes are only archived when truly complete and deployed.
|
||||
|
||||
### 7. Types of Changes That Don't Require Specs
|
||||
### 8. Types of Changes That Don't Require Specs
|
||||
|
||||
Some changes only affect development infrastructure and don't need specs:
|
||||
- Initial project setup (package.json, tsconfig.json, etc.)
|
||||
@@ -216,7 +252,16 @@ User: "Add password reset functionality"
|
||||
You should:
|
||||
1. Read specs/user-auth/spec.md
|
||||
2. Check changes/ for pending auth changes
|
||||
3. Create changes/add-password-reset/ with proposal
|
||||
3. Create changes/add-password-reset/ with:
|
||||
- proposal.md describing the change
|
||||
- specs/user-auth/spec.md with:
|
||||
## ADDED Requirements
|
||||
### Requirement: Password Reset
|
||||
[Complete requirement for password reset]
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: User Authentication
|
||||
[Updated to integrate with password reset]
|
||||
4. Wait for approval before implementing
|
||||
\`\`\`
|
||||
|
||||
|
||||
+32
-12
@@ -1,8 +1,8 @@
|
||||
import path from 'path';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { TemplateManager } from './templates/index.js';
|
||||
import { OPENSPEC_DIR_NAME, OPENSPEC_MARKERS } from './config.js';
|
||||
import { OPENSPEC_DIR_NAME } from './config.js';
|
||||
import { readmeTemplate } from './templates/readme-template.js';
|
||||
import { ToolRegistry } from './configurators/registry.js';
|
||||
|
||||
export class UpdateCommand {
|
||||
async execute(projectPath: string): Promise<void> {
|
||||
@@ -19,17 +19,37 @@ export class UpdateCommand {
|
||||
const readmePath = path.join(openspecPath, 'README.md');
|
||||
await FileSystemUtils.writeFile(readmePath, readmeTemplate);
|
||||
|
||||
// 3. Update CLAUDE.md (marker-based)
|
||||
const claudePath = path.join(resolvedProjectPath, 'CLAUDE.md');
|
||||
const claudeContent = TemplateManager.getClaudeTemplate();
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
claudePath,
|
||||
claudeContent,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
// 3. Update existing AI tool configuration files only
|
||||
const configurators = ToolRegistry.getAll();
|
||||
let updatedFiles: string[] = [];
|
||||
let failedFiles: string[] = [];
|
||||
|
||||
for (const configurator of configurators) {
|
||||
const configFilePath = path.join(resolvedProjectPath, configurator.configFileName);
|
||||
|
||||
// Only update if the file already exists
|
||||
if (await FileSystemUtils.fileExists(configFilePath)) {
|
||||
try {
|
||||
await configurator.configure(resolvedProjectPath, openspecPath);
|
||||
updatedFiles.push(configurator.configFileName);
|
||||
} catch (error) {
|
||||
failedFiles.push(configurator.configFileName);
|
||||
console.error(`Failed to update ${configurator.configFileName}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Success message (ASCII-safe)
|
||||
console.log('Updated OpenSpec instructions');
|
||||
const messages: string[] = ['Updated OpenSpec instructions (README.md)'];
|
||||
|
||||
if (updatedFiles.length > 0) {
|
||||
messages.push(`Updated AI tool files: ${updatedFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
messages.push(`Failed to update: ${failedFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(messages.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Validation threshold constants
|
||||
*/
|
||||
|
||||
// Minimum character lengths
|
||||
export const MIN_WHY_SECTION_LENGTH = 50;
|
||||
export const MIN_PURPOSE_LENGTH = 50;
|
||||
|
||||
// Maximum character/item limits
|
||||
export const MAX_WHY_SECTION_LENGTH = 1000;
|
||||
export const MAX_REQUIREMENT_TEXT_LENGTH = 500;
|
||||
export const MAX_DELTAS_PER_CHANGE = 10;
|
||||
|
||||
// Validation messages
|
||||
export const VALIDATION_MESSAGES = {
|
||||
// Required content
|
||||
SCENARIO_EMPTY: 'Scenario text cannot be empty',
|
||||
REQUIREMENT_EMPTY: 'Requirement text cannot be empty',
|
||||
REQUIREMENT_NO_SHALL: 'Requirement must contain SHALL or MUST keyword',
|
||||
REQUIREMENT_NO_SCENARIOS: 'Requirement must have at least one scenario',
|
||||
SPEC_NAME_EMPTY: 'Spec name cannot be empty',
|
||||
SPEC_PURPOSE_EMPTY: 'Purpose section cannot be empty',
|
||||
SPEC_NO_REQUIREMENTS: 'Spec must have at least one requirement',
|
||||
CHANGE_NAME_EMPTY: 'Change name cannot be empty',
|
||||
CHANGE_WHY_TOO_SHORT: `Why section must be at least ${MIN_WHY_SECTION_LENGTH} characters`,
|
||||
CHANGE_WHY_TOO_LONG: `Why section should not exceed ${MAX_WHY_SECTION_LENGTH} characters`,
|
||||
CHANGE_WHAT_EMPTY: 'What Changes section cannot be empty',
|
||||
CHANGE_NO_DELTAS: 'Change must have at least one delta',
|
||||
CHANGE_TOO_MANY_DELTAS: `Consider splitting changes with more than ${MAX_DELTAS_PER_CHANGE} deltas`,
|
||||
DELTA_SPEC_EMPTY: 'Spec name cannot be empty',
|
||||
DELTA_DESCRIPTION_EMPTY: 'Delta description cannot be empty',
|
||||
|
||||
// Warnings
|
||||
PURPOSE_TOO_BRIEF: `Purpose section is too brief (less than ${MIN_PURPOSE_LENGTH} characters)`,
|
||||
REQUIREMENT_TOO_LONG: `Requirement text is very long (>${MAX_REQUIREMENT_TEXT_LENGTH} characters). Consider breaking it down.`,
|
||||
DELTA_DESCRIPTION_TOO_BRIEF: 'Delta description is too brief',
|
||||
DELTA_MISSING_REQUIREMENTS: 'Delta should include requirements',
|
||||
} as const;
|
||||
@@ -0,0 +1,19 @@
|
||||
export type ValidationLevel = 'ERROR' | 'WARNING' | 'INFO';
|
||||
|
||||
export interface ValidationIssue {
|
||||
level: ValidationLevel;
|
||||
path: string;
|
||||
message: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
}
|
||||
|
||||
export interface ValidationReport {
|
||||
valid: boolean;
|
||||
issues: ValidationIssue[];
|
||||
summary: {
|
||||
errors: number;
|
||||
warnings: number;
|
||||
info: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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,
|
||||
MAX_REQUIREMENT_TEXT_LENGTH,
|
||||
VALIDATION_MESSAGES
|
||||
} from './constants.js';
|
||||
|
||||
export class Validator {
|
||||
private strictMode: boolean;
|
||||
|
||||
constructor(strictMode: boolean = false) {
|
||||
this.strictMode = strictMode;
|
||||
}
|
||||
|
||||
async validateSpec(filePath: string): Promise<ValidationReport> {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const parser = new MarkdownParser(content);
|
||||
const specName = this.extractNameFromPath(filePath);
|
||||
|
||||
const spec = parser.parseSpec(specName);
|
||||
|
||||
const result = SpecSchema.safeParse(spec);
|
||||
|
||||
if (!result.success) {
|
||||
issues.push(...this.convertZodErrors(result.error));
|
||||
}
|
||||
|
||||
issues.push(...this.applySpecRules(spec, content));
|
||||
|
||||
} catch (error) {
|
||||
issues.push({
|
||||
level: 'ERROR',
|
||||
path: 'file',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
|
||||
return this.createReport(issues);
|
||||
}
|
||||
|
||||
async validateChange(filePath: string): Promise<ValidationReport> {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const changeName = this.extractNameFromPath(filePath);
|
||||
const changeDir = path.dirname(filePath);
|
||||
const parser = new ChangeParser(content, changeDir);
|
||||
|
||||
const change = await parser.parseChangeWithDeltas(changeName);
|
||||
|
||||
const result = ChangeSchema.safeParse(change);
|
||||
|
||||
if (!result.success) {
|
||||
issues.push(...this.convertZodErrors(result.error));
|
||||
}
|
||||
|
||||
issues.push(...this.applyChangeRules(change, content));
|
||||
|
||||
} catch (error) {
|
||||
issues.push({
|
||||
level: 'ERROR',
|
||||
path: 'file',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
|
||||
return this.createReport(issues);
|
||||
}
|
||||
|
||||
private convertZodErrors(error: ZodError): ValidationIssue[] {
|
||||
return error.issues.map(err => ({
|
||||
level: 'ERROR' as ValidationLevel,
|
||||
path: err.path.join('.'),
|
||||
message: err.message,
|
||||
}));
|
||||
}
|
||||
|
||||
private applySpecRules(spec: Spec, content: string): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
if (spec.overview.length < MIN_PURPOSE_LENGTH) {
|
||||
issues.push({
|
||||
level: 'WARNING',
|
||||
path: 'overview',
|
||||
message: VALIDATION_MESSAGES.PURPOSE_TOO_BRIEF,
|
||||
});
|
||||
}
|
||||
|
||||
spec.requirements.forEach((req, index) => {
|
||||
if (req.text.length > MAX_REQUIREMENT_TEXT_LENGTH) {
|
||||
issues.push({
|
||||
level: 'INFO',
|
||||
path: `requirements[${index}]`,
|
||||
message: VALIDATION_MESSAGES.REQUIREMENT_TOO_LONG,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.scenarios.length === 0) {
|
||||
issues.push({
|
||||
level: 'WARNING',
|
||||
path: `requirements[${index}].scenarios`,
|
||||
message: 'Requirement has no scenarios',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private applyChangeRules(change: Change, content: string): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
const MIN_DELTA_DESCRIPTION_LENGTH = 10;
|
||||
|
||||
change.deltas.forEach((delta, index) => {
|
||||
if (!delta.description || delta.description.length < MIN_DELTA_DESCRIPTION_LENGTH) {
|
||||
issues.push({
|
||||
level: 'WARNING',
|
||||
path: `deltas[${index}].description`,
|
||||
message: VALIDATION_MESSAGES.DELTA_DESCRIPTION_TOO_BRIEF,
|
||||
});
|
||||
}
|
||||
|
||||
if ((delta.operation === 'ADDED' || delta.operation === 'MODIFIED') &&
|
||||
(!delta.requirements || delta.requirements.length === 0)) {
|
||||
issues.push({
|
||||
level: 'WARNING',
|
||||
path: `deltas[${index}].requirements`,
|
||||
message: `${delta.operation} ${VALIDATION_MESSAGES.DELTA_MISSING_REQUIREMENTS}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const parts = filePath.split('/');
|
||||
|
||||
// Look for the directory name after 'specs' or 'changes'
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i] === 'specs' || parts[i] === 'changes') {
|
||||
if (i < parts.length - 1) {
|
||||
return parts[i + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to filename without extension if not in expected structure
|
||||
const fileName = parts[parts.length - 1];
|
||||
return fileName.replace('.md', '');
|
||||
}
|
||||
|
||||
private createReport(issues: ValidationIssue[]): ValidationReport {
|
||||
const errors = issues.filter(i => i.level === 'ERROR').length;
|
||||
const warnings = issues.filter(i => i.level === 'WARNING').length;
|
||||
const info = issues.filter(i => i.level === 'INFO').length;
|
||||
|
||||
const valid = this.strictMode
|
||||
? errors === 0 && warnings === 0
|
||||
: errors === 0;
|
||||
|
||||
return {
|
||||
valid,
|
||||
issues,
|
||||
summary: {
|
||||
errors,
|
||||
warnings,
|
||||
info,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
isValid(report: ValidationReport): boolean {
|
||||
return report.valid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
describe('spec command', () => {
|
||||
const projectRoot = process.cwd();
|
||||
const testDir = path.join(projectRoot, 'test-spec-command-tmp');
|
||||
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 });
|
||||
|
||||
// Create test spec files
|
||||
const testSpec = `## Purpose
|
||||
This is a test specification for the authentication system.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: User Authentication
|
||||
The system SHALL provide secure user authentication
|
||||
|
||||
#### Scenario: Successful login
|
||||
- **GIVEN** a user with valid credentials
|
||||
- **WHEN** they submit the login form
|
||||
- **THEN** they are authenticated
|
||||
|
||||
### Requirement: Password Reset
|
||||
The system SHALL allow users to reset their password
|
||||
|
||||
#### Scenario: Reset via email
|
||||
- **GIVEN** a user with a registered email
|
||||
- **WHEN** they request a password reset
|
||||
- **THEN** they receive a reset link`;
|
||||
|
||||
await fs.mkdir(path.join(specsDir, 'auth'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'auth', 'spec.md'), testSpec);
|
||||
|
||||
const testSpec2 = `## Purpose
|
||||
This specification defines the payment processing system.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Process Payments
|
||||
The system SHALL process credit card payments securely`;
|
||||
|
||||
await fs.mkdir(path.join(specsDir, 'payment'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'payment', 'spec.md'), testSpec2);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('spec show', () => {
|
||||
it('should display spec in text format', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
const output = execSync(`node ${openspecBin} spec show auth`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
it('should output spec as JSON with --json flag', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
const output = execSync(`node ${openspecBin} spec show auth --json`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
const json = JSON.parse(output);
|
||||
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');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
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 --json --requirements`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
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 (JSON only)', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
const output = execSync(`node ${openspecBin} spec show auth --json --no-scenarios`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
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 (JSON only)', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
const output = execSync(`node ${openspecBin} spec show auth --json -r 1`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return JSON with filtered requirements', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
const output = execSync(`node ${openspecBin} spec show auth --json --no-scenarios`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
const json = JSON.parse(output);
|
||||
expect(json.requirements).toHaveLength(2);
|
||||
expect(json.requirements[0].scenarios).toHaveLength(0);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spec list', () => {
|
||||
it('should list all available specs (IDs only by default)', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
const output = execSync(`node ${openspecBin} spec list`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
expect(output).toContain('auth');
|
||||
expect(output).toContain('payment');
|
||||
// Default should not include counts or teasers
|
||||
expect(output).not.toMatch(/Requirements:\s*\d+/);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('should output spec list as JSON with --json flag', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
const output = execSync(`node ${openspecBin} spec list --json`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
const json = JSON.parse(output);
|
||||
expect(json).toHaveLength(2);
|
||||
expect(json.find((s: any) => s.id === 'auth')).toBeDefined();
|
||||
expect(json.find((s: any) => s.id === 'payment')).toBeDefined();
|
||||
expect(json[0].requirementCount).toBeDefined();
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spec validate', () => {
|
||||
it('should validate a valid spec', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
const output = execSync(`node ${openspecBin} spec validate auth`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
expect(output).toContain("Specification 'auth' is valid");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('should output validation report as JSON with --json flag', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
const output = execSync(`node ${openspecBin} spec validate auth --json`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
const json = JSON.parse(output);
|
||||
expect(json.valid).toBeDefined();
|
||||
expect(json.issues).toBeDefined();
|
||||
expect(json.summary).toBeDefined();
|
||||
expect(json.summary.errors).toBeDefined();
|
||||
expect(json.summary.warnings).toBeDefined();
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate with strict mode', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
const output = execSync(`node ${openspecBin} spec validate auth --strict --json`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
const json = JSON.parse(output);
|
||||
expect(json.valid).toBeDefined();
|
||||
// In strict mode, warnings also affect validity
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('should detect invalid spec structure', async () => {
|
||||
const invalidSpec = `## Purpose
|
||||
|
||||
## Requirements
|
||||
This section has no actual requirements`;
|
||||
|
||||
await fs.mkdir(path.join(specsDir, 'invalid'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'invalid', 'spec.md'), invalidSpec);
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
|
||||
// This should exit with non-zero code
|
||||
let exitCode = 0;
|
||||
try {
|
||||
execSync(`node ${openspecBin} spec validate invalid`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
} catch (error: any) {
|
||||
exitCode = error.status;
|
||||
}
|
||||
|
||||
expect(exitCode).not.toBe(0);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle non-existent spec gracefully', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
|
||||
let error: any;
|
||||
try {
|
||||
execSync(`node ${openspecBin} spec show nonexistent`, {
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error.status).not.toBe(0);
|
||||
expect(error.stderr.toString()).toContain('not found');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle missing specs directory gracefully', async () => {
|
||||
await fs.rm(specsDir, { recursive: true, force: true });
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+103
-7
@@ -99,12 +99,24 @@ describe('ArchiveCommand', () => {
|
||||
const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
// Create spec in change
|
||||
const specContent = '# Test Capability Spec\n\nTest content';
|
||||
// Create valid spec in change
|
||||
const specContent = `# Test Capability Spec
|
||||
|
||||
## Purpose
|
||||
This is a test capability specification for testing purposes.
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL provide test capability
|
||||
|
||||
#### Scenario: Basic test
|
||||
Given a test condition
|
||||
When an action occurs
|
||||
Then expected result happens`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
// Execute archive with --yes flag
|
||||
await archiveCommand.execute(changeName, { yes: true });
|
||||
// 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
|
||||
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
|
||||
@@ -171,6 +183,88 @@ describe('ArchiveCommand', () => {
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should skip spec updates when --skip-specs flag is used', async () => {
|
||||
const changeName = 'skip-specs-feature';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
// Create spec in change
|
||||
const specContent = '# Test Capability Spec\n\nTest content';
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
// Execute archive with --skip-specs flag and noValidate to skip validation
|
||||
await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, noValidate: true });
|
||||
|
||||
// Verify skip message was logged
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
'Skipping spec updates (--skip-specs flag provided).'
|
||||
);
|
||||
|
||||
// Verify spec was NOT copied to main specs
|
||||
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
|
||||
await expect(fs.access(mainSpecPath)).rejects.toThrow();
|
||||
|
||||
// Verify change was still archived
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives.length).toBe(1);
|
||||
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
|
||||
});
|
||||
|
||||
it('should proceed with archive when user declines spec updates', async () => {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const changeName = 'decline-specs-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
|
||||
|
||||
## Purpose
|
||||
This is a test capability specification.
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL provide test capability
|
||||
|
||||
#### Scenario: Basic test
|
||||
Given a test condition
|
||||
When an action occurs
|
||||
Then expected result happens`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
// Mock confirm to return false (decline spec updates)
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
// Execute archive without --yes flag
|
||||
await archiveCommand.execute(changeName);
|
||||
|
||||
// Verify user was prompted about specs
|
||||
expect(mockConfirm).toHaveBeenCalledWith({
|
||||
message: 'Proceed with spec updates?',
|
||||
default: true
|
||||
});
|
||||
|
||||
// Verify skip message was logged
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
'Skipping spec updates. Proceeding with archive.'
|
||||
);
|
||||
|
||||
// Verify spec was NOT copied to main specs
|
||||
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
|
||||
await expect(fs.access(mainSpecPath)).rejects.toThrow();
|
||||
|
||||
// Verify change was still archived
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives.length).toBe(1);
|
||||
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
@@ -253,11 +347,13 @@ describe('ArchiveCommand', () => {
|
||||
const tasksContent = '- [ ] Task 1';
|
||||
await fs.writeFile(path.join(changeDir, 'tasks.md'), tasksContent);
|
||||
|
||||
// Mock confirm to return false (cancel)
|
||||
// Mock confirm to return false (cancel) for validation skip
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
// Mock another false for task warning
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
// Execute without --yes flag
|
||||
await archiveCommand.execute(changeName);
|
||||
// Execute without --yes flag but skip validation to test task warning
|
||||
await archiveCommand.execute(changeName, { noValidate: true });
|
||||
|
||||
// Verify archive was cancelled
|
||||
expect(console.log).toHaveBeenCalledWith('Archive cancelled.');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { JsonConverter } from '../../../src/core/converters/json-converter.js';
|
||||
|
||||
describe('JsonConverter', () => {
|
||||
const testDir = path.join(process.cwd(), 'test-json-converter-tmp');
|
||||
const converter = new JsonConverter();
|
||||
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('convertSpecToJson', () => {
|
||||
it('should convert a spec to JSON format', async () => {
|
||||
const specContent = `# User Authentication Spec
|
||||
|
||||
## Purpose
|
||||
This specification defines the requirements for user authentication.
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL provide secure user authentication
|
||||
Users need to be able to log in securely.
|
||||
|
||||
#### Scenario: Successful login
|
||||
Given a user with valid credentials
|
||||
When they submit the login form
|
||||
Then they are authenticated`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
const json = converter.convertSpecToJson(specPath);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.name).toBe('spec');
|
||||
expect(parsed.overview).toContain('user authentication');
|
||||
expect(parsed.requirements).toHaveLength(1);
|
||||
expect(parsed.requirements[0].scenarios).toHaveLength(1);
|
||||
expect(parsed.metadata).toBeDefined();
|
||||
expect(parsed.metadata.format).toBe('openspec');
|
||||
expect(parsed.metadata.sourcePath).toBe(specPath);
|
||||
});
|
||||
|
||||
it('should extract spec name from directory structure', async () => {
|
||||
const specsDir = path.join(testDir, 'specs', 'user-auth');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
const specContent = `# User Auth
|
||||
|
||||
## Purpose
|
||||
Auth spec overview
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL authenticate users
|
||||
|
||||
#### Scenario: Login
|
||||
Given a user
|
||||
When they login
|
||||
Then authenticated`;
|
||||
|
||||
const specPath = path.join(specsDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
const json = converter.convertSpecToJson(specPath);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.name).toBe('user-auth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertChangeToJson', () => {
|
||||
it('should convert a change to JSON format', async () => {
|
||||
const changeContent = `# Add User Authentication
|
||||
|
||||
## Why
|
||||
We need to implement user authentication to secure the application and protect user data from unauthorized access.
|
||||
|
||||
## What Changes
|
||||
- **user-auth:** Add new user authentication specification
|
||||
- **api-endpoints:** Modify to include authentication endpoints`;
|
||||
|
||||
const changePath = path.join(testDir, 'change.md');
|
||||
await fs.writeFile(changePath, changeContent);
|
||||
|
||||
const json = await converter.convertChangeToJson(changePath);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.name).toBe('change');
|
||||
expect(parsed.why).toContain('secure the application');
|
||||
expect(parsed.deltas).toHaveLength(2);
|
||||
expect(parsed.deltas[0].spec).toBe('user-auth');
|
||||
expect(parsed.deltas[0].operation).toBe('ADDED');
|
||||
expect(parsed.metadata).toBeDefined();
|
||||
expect(parsed.metadata.format).toBe('openspec-change');
|
||||
expect(parsed.metadata.sourcePath).toBe(changePath);
|
||||
});
|
||||
|
||||
it('should extract change name from directory structure', async () => {
|
||||
const changesDir = path.join(testDir, 'changes', 'add-auth');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
const changeContent = `# Add Auth
|
||||
|
||||
## Why
|
||||
We need authentication for security reasons and to protect user data properly.
|
||||
|
||||
## What Changes
|
||||
- **auth:** Add authentication`;
|
||||
|
||||
const changePath = path.join(changesDir, 'proposal.md');
|
||||
await fs.writeFile(changePath, changeContent);
|
||||
|
||||
const json = await converter.convertChangeToJson(changePath);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.name).toBe('add-auth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON formatting', () => {
|
||||
it('should produce properly formatted JSON with indentation', async () => {
|
||||
const specContent = `# Test
|
||||
|
||||
## Purpose
|
||||
Test overview
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL test
|
||||
|
||||
#### Scenario: Test
|
||||
Given test
|
||||
When action
|
||||
Then result`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
const json = converter.convertSpecToJson(specPath);
|
||||
|
||||
// Check for proper indentation (2 spaces)
|
||||
expect(json).toContain(' "name"');
|
||||
expect(json).toContain(' "overview"');
|
||||
expect(json).toContain(' "requirements"');
|
||||
|
||||
// Check it's valid JSON
|
||||
expect(() => JSON.parse(json)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle special characters in content', async () => {
|
||||
const specContent = `# Test
|
||||
|
||||
## Purpose
|
||||
This has "quotes" and \\ backslashes and
|
||||
newlines
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL handle "special" characters
|
||||
|
||||
#### Scenario: Special chars
|
||||
Given a string with "quotes"
|
||||
When processing \\ backslash
|
||||
Then handle correctly`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
const json = converter.convertSpecToJson(specPath);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
expect(parsed.overview).toContain('"quotes"');
|
||||
expect(parsed.overview).toContain('\\');
|
||||
expect(parsed.requirements[0].text).toContain('"special"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MarkdownParser } from '../../../src/core/parsers/markdown-parser.js';
|
||||
|
||||
describe('MarkdownParser', () => {
|
||||
describe('parseSpec', () => {
|
||||
it('should parse a valid spec', () => {
|
||||
const content = `# User Authentication Spec
|
||||
|
||||
## Purpose
|
||||
This specification defines the requirements for user authentication.
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL provide secure user authentication
|
||||
Users need to be able to log in securely.
|
||||
|
||||
#### Scenario: Successful login
|
||||
Given a user with valid credentials
|
||||
When they submit the login form
|
||||
Then they are authenticated
|
||||
|
||||
### The system SHALL handle invalid login attempts
|
||||
The system must handle incorrect credentials.
|
||||
|
||||
#### Scenario: Invalid credentials
|
||||
Given a user with invalid credentials
|
||||
When they submit the login form
|
||||
Then they see an error message`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
const spec = parser.parseSpec('user-auth');
|
||||
|
||||
expect(spec.name).toBe('user-auth');
|
||||
expect(spec.overview).toContain('requirements for user authentication');
|
||||
expect(spec.requirements).toHaveLength(2);
|
||||
|
||||
const firstReq = spec.requirements[0];
|
||||
expect(firstReq.text).toBe('Users need to be able to log in securely.');
|
||||
expect(firstReq.scenarios).toHaveLength(1);
|
||||
|
||||
const scenario = firstReq.scenarios[0];
|
||||
expect(scenario.rawText).toContain('Given a user with valid credentials');
|
||||
expect(scenario.rawText).toContain('When they submit the login form');
|
||||
expect(scenario.rawText).toContain('Then they are authenticated');
|
||||
});
|
||||
|
||||
it('should handle multi-line scenarios', () => {
|
||||
const content = `# Test Spec
|
||||
|
||||
## Purpose
|
||||
Test overview
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL handle complex scenarios
|
||||
This requirement has content.
|
||||
|
||||
#### Scenario: Multi-line scenario
|
||||
Given a user with valid credentials
|
||||
and the user has admin privileges
|
||||
and the system is in maintenance mode
|
||||
When they attempt to login
|
||||
and provide their MFA token
|
||||
Then they are authenticated
|
||||
and redirected to admin dashboard
|
||||
and see a maintenance warning`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
const spec = parser.parseSpec('test');
|
||||
|
||||
const scenario = spec.requirements[0].scenarios[0];
|
||||
expect(scenario.rawText).toContain('Given a user with valid credentials');
|
||||
expect(scenario.rawText).toContain('and the user has admin privileges');
|
||||
expect(scenario.rawText).toContain('When they attempt to login');
|
||||
expect(scenario.rawText).toContain('and provide their MFA token');
|
||||
expect(scenario.rawText).toContain('Then they are authenticated');
|
||||
expect(scenario.rawText).toContain('and see a maintenance warning');
|
||||
});
|
||||
|
||||
it('should throw error for missing overview', () => {
|
||||
const content = `# Test Spec
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL do something
|
||||
|
||||
#### Scenario: Test
|
||||
Given test
|
||||
When action
|
||||
Then result`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
expect(() => parser.parseSpec('test')).toThrow('must have a Purpose section');
|
||||
});
|
||||
|
||||
it('should throw error for missing requirements', () => {
|
||||
const content = `# Test Spec
|
||||
|
||||
## Purpose
|
||||
This is a test spec`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
expect(() => parser.parseSpec('test')).toThrow('must have a Requirements section');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseChange', () => {
|
||||
it('should parse a valid change', () => {
|
||||
const content = `# Add User Authentication
|
||||
|
||||
## Why
|
||||
We need to implement user authentication to secure the application and protect user data from unauthorized access.
|
||||
|
||||
## What Changes
|
||||
- **user-auth:** Add new user authentication specification
|
||||
- **api-endpoints:** Modify to include authentication endpoints
|
||||
- **database:** Remove old session management tables`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
const change = parser.parseChange('add-user-auth');
|
||||
|
||||
expect(change.name).toBe('add-user-auth');
|
||||
expect(change.why).toContain('secure the application');
|
||||
expect(change.whatChanges).toContain('user-auth');
|
||||
expect(change.deltas).toHaveLength(3);
|
||||
|
||||
expect(change.deltas[0].spec).toBe('user-auth');
|
||||
expect(change.deltas[0].operation).toBe('ADDED');
|
||||
expect(change.deltas[0].description).toContain('Add new user authentication');
|
||||
|
||||
expect(change.deltas[1].spec).toBe('api-endpoints');
|
||||
expect(change.deltas[1].operation).toBe('MODIFIED');
|
||||
|
||||
expect(change.deltas[2].spec).toBe('database');
|
||||
expect(change.deltas[2].operation).toBe('REMOVED');
|
||||
});
|
||||
|
||||
it('should throw error for missing why section', () => {
|
||||
const content = `# Test Change
|
||||
|
||||
## What Changes
|
||||
- **test:** Add test`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
expect(() => parser.parseChange('test')).toThrow('must have a Why section');
|
||||
});
|
||||
|
||||
it('should throw error for missing what changes section', () => {
|
||||
const content = `# Test Change
|
||||
|
||||
## Why
|
||||
Because we need it`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
expect(() => parser.parseChange('test')).toThrow('must have a What Changes section');
|
||||
});
|
||||
|
||||
it('should handle changes without deltas', () => {
|
||||
const content = `# Test Change
|
||||
|
||||
## Why
|
||||
We need to make some changes for important reasons that justify this work.
|
||||
|
||||
## What Changes
|
||||
Some general description of changes without specific deltas`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
const change = parser.parseChange('test');
|
||||
|
||||
expect(change.deltas).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('section parsing', () => {
|
||||
it('should handle nested sections correctly', () => {
|
||||
const content = `# Test Spec
|
||||
|
||||
## Purpose
|
||||
This is the overview section for testing nested sections.
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL handle nested sections
|
||||
|
||||
#### Scenario: Test nested
|
||||
Given a nested structure
|
||||
When parsing sections
|
||||
Then handle correctly
|
||||
|
||||
### Another requirement SHALL work
|
||||
|
||||
#### Scenario: Another test
|
||||
Given another test
|
||||
When running
|
||||
Then success`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
const spec = parser.parseSpec('test');
|
||||
|
||||
// Should find the correct sections at different levels
|
||||
expect(spec).toBeDefined();
|
||||
expect(spec.overview).toContain('testing nested sections');
|
||||
expect(spec.requirements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should preserve content between headers', () => {
|
||||
const content = `# Test
|
||||
|
||||
## Purpose
|
||||
This is the overview.
|
||||
It has multiple lines.
|
||||
|
||||
Some more content here.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1
|
||||
Content for requirement 1`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
const spec = parser.parseSpec('test');
|
||||
|
||||
expect(spec.overview).toContain('multiple lines');
|
||||
expect(spec.overview).toContain('more content');
|
||||
});
|
||||
|
||||
it('should use requirement heading as fallback when no content is provided', () => {
|
||||
const content = `# Test Spec
|
||||
|
||||
## Purpose
|
||||
Test overview
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL use heading text when no content
|
||||
|
||||
#### Scenario: Test
|
||||
Given test
|
||||
When action
|
||||
Then result`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
const spec = parser.parseSpec('test');
|
||||
|
||||
expect(spec.requirements[0].text).toBe('The system SHALL use heading text when no content');
|
||||
});
|
||||
|
||||
it('should extract requirement text from first non-empty content line', () => {
|
||||
const content = `# Test Spec
|
||||
|
||||
## Purpose
|
||||
Test overview
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement heading
|
||||
|
||||
This is the actual requirement text.
|
||||
This is additional description.
|
||||
|
||||
#### Scenario: Test
|
||||
Given test
|
||||
When action
|
||||
Then result`;
|
||||
|
||||
const parser = new MarkdownParser(content);
|
||||
const spec = parser.parseSpec('test');
|
||||
|
||||
expect(spec.requirements[0].text).toBe('This is the actual requirement text.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { UpdateCommand } from '../../src/core/update.js';
|
||||
import { FileSystemUtils } from '../../src/utils/file-system.js';
|
||||
import { ToolRegistry } from '../../src/core/configurators/registry.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
|
||||
describe('UpdateCommand', () => {
|
||||
let testDir: string;
|
||||
let updateCommand: UpdateCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary test directory
|
||||
testDir = path.join(os.tmpdir(), `openspec-test-${Date.now()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
|
||||
// Create openspec directory
|
||||
const openspecDir = path.join(testDir, 'openspec');
|
||||
await fs.mkdir(openspecDir, { recursive: true });
|
||||
|
||||
updateCommand = new UpdateCommand();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test directory
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should update only existing CLAUDE.md file', async () => {
|
||||
// Create CLAUDE.md file with initial content
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
const initialContent = `# Project Instructions
|
||||
|
||||
Some existing content here.
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old OpenSpec content
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
More content after.`;
|
||||
await fs.writeFile(claudePath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that CLAUDE.md was updated
|
||||
const updatedContent = await fs.readFile(claudePath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('This project uses OpenSpec');
|
||||
expect(updatedContent).toContain('Some existing content here');
|
||||
expect(updatedContent).toContain('More content after');
|
||||
|
||||
// Check console output
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Updated OpenSpec instructions (README.md)\nUpdated AI tool files: CLAUDE.md'
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create CLAUDE.md if it does not exist', async () => {
|
||||
// Ensure CLAUDE.md does not exist
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that CLAUDE.md was not created
|
||||
const fileExists = await FileSystemUtils.fileExists(claudePath);
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle no AI tool files present', async () => {
|
||||
// Execute update command with no AI tool files
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Should only update OpenSpec instructions
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Updated OpenSpec instructions (README.md)');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should update multiple AI tool files if present', async () => {
|
||||
// TODO: When additional configurators are added (Cursor, Aider, etc.),
|
||||
// enhance this test to create multiple AI tool files and verify
|
||||
// that all existing files are updated in a single operation.
|
||||
// For now, we test with just CLAUDE.md.
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Should report updating with new format
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Updated OpenSpec instructions (README.md)\nUpdated AI tool files: CLAUDE.md'
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should never create new AI tool files', async () => {
|
||||
// Get all configurators
|
||||
const configurators = ToolRegistry.getAll();
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that no new AI tool files were created
|
||||
for (const configurator of configurators) {
|
||||
const configPath = path.join(testDir, configurator.configFileName);
|
||||
const fileExists = await FileSystemUtils.fileExists(configPath);
|
||||
expect(fileExists).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should update README.md in openspec directory', async () => {
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that README.md was created/updated
|
||||
const readmePath = path.join(testDir, 'openspec', 'README.md');
|
||||
const fileExists = await FileSystemUtils.fileExists(readmePath);
|
||||
expect(fileExists).toBe(true);
|
||||
|
||||
const content = await fs.readFile(readmePath, 'utf-8');
|
||||
expect(content).toContain('# OpenSpec Instructions');
|
||||
});
|
||||
|
||||
it('should throw error if openspec directory does not exist', async () => {
|
||||
// Remove openspec directory
|
||||
await fs.rm(path.join(testDir, 'openspec'), { recursive: true, force: true });
|
||||
|
||||
// Execute update command and expect error
|
||||
await expect(updateCommand.execute(testDir)).rejects.toThrow(
|
||||
"No OpenSpec directory found. Run 'openspec init' first."
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle configurator errors gracefully', async () => {
|
||||
// Create CLAUDE.md file but make it read-only to cause an error
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
|
||||
await fs.chmod(claudePath, 0o444); // Read-only
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
const errorSpy = vi.spyOn(console, 'error');
|
||||
|
||||
// Execute update command - should not throw
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Should report the failure
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Updated OpenSpec instructions (README.md)\nFailed to update: CLAUDE.md'
|
||||
);
|
||||
|
||||
// Restore permissions for cleanup
|
||||
await fs.chmod(claudePath, 0o644);
|
||||
consoleSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { Validator } from '../../src/core/validation/validator.js';
|
||||
import {
|
||||
ScenarioSchema,
|
||||
RequirementSchema,
|
||||
SpecSchema,
|
||||
ChangeSchema,
|
||||
DeltaSchema
|
||||
} from '../../src/core/schemas/index.js';
|
||||
|
||||
describe('Validation Schemas', () => {
|
||||
describe('ScenarioSchema', () => {
|
||||
it('should validate a valid scenario', () => {
|
||||
const scenario = {
|
||||
rawText: 'Given a user is logged in\nWhen they click logout\nThen they are redirected to login page',
|
||||
};
|
||||
|
||||
const result = ScenarioSchema.safeParse(scenario);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject scenario with empty text', () => {
|
||||
const scenario = {
|
||||
rawText: '',
|
||||
};
|
||||
|
||||
const result = ScenarioSchema.safeParse(scenario);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Scenario text cannot be empty');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RequirementSchema', () => {
|
||||
it('should validate a valid requirement', () => {
|
||||
const requirement = {
|
||||
text: 'The system SHALL provide user authentication',
|
||||
scenarios: [
|
||||
{
|
||||
rawText: 'Given a user with valid credentials\nWhen they submit the login form\nThen they are authenticated',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = RequirementSchema.safeParse(requirement);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject requirement without SHALL or MUST', () => {
|
||||
const requirement = {
|
||||
text: 'The system provides user authentication',
|
||||
scenarios: [
|
||||
{
|
||||
rawText: 'Given a user\nWhen they login\nThen authenticated',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = RequirementSchema.safeParse(requirement);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Requirement must contain SHALL or MUST keyword');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject requirement without scenarios', () => {
|
||||
const requirement = {
|
||||
text: 'The system SHALL provide user authentication',
|
||||
scenarios: [],
|
||||
};
|
||||
|
||||
const result = RequirementSchema.safeParse(requirement);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Requirement must have at least one scenario');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpecSchema', () => {
|
||||
it('should validate a valid spec', () => {
|
||||
const spec = {
|
||||
name: 'user-auth',
|
||||
overview: 'This spec defines user authentication requirements',
|
||||
requirements: [
|
||||
{
|
||||
text: 'The system SHALL provide user authentication',
|
||||
scenarios: [
|
||||
{
|
||||
rawText: 'Given a user with valid credentials\nWhen they submit the login form\nThen they are authenticated',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = SpecSchema.safeParse(spec);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject spec without requirements', () => {
|
||||
const spec = {
|
||||
name: 'user-auth',
|
||||
overview: 'This spec defines user authentication requirements',
|
||||
requirements: [],
|
||||
};
|
||||
|
||||
const result = SpecSchema.safeParse(spec);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Spec must have at least one requirement');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChangeSchema', () => {
|
||||
it('should validate a valid change', () => {
|
||||
const change = {
|
||||
name: 'add-user-auth',
|
||||
why: 'We need user authentication to secure the application and protect user data',
|
||||
whatChanges: 'Add authentication module with login and logout capabilities',
|
||||
deltas: [
|
||||
{
|
||||
spec: 'user-auth',
|
||||
operation: 'ADDED',
|
||||
description: 'Add new user authentication spec',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = ChangeSchema.safeParse(change);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject change with short why section', () => {
|
||||
const change = {
|
||||
name: 'add-user-auth',
|
||||
why: 'Need auth',
|
||||
whatChanges: 'Add authentication',
|
||||
deltas: [
|
||||
{
|
||||
spec: 'user-auth',
|
||||
operation: 'ADDED',
|
||||
description: 'Add auth',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = ChangeSchema.safeParse(change);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Why section must be at least 50 characters');
|
||||
}
|
||||
});
|
||||
|
||||
it('should warn about too many deltas', () => {
|
||||
const deltas = Array.from({ length: 11 }, (_, i) => ({
|
||||
spec: `spec-${i}`,
|
||||
operation: 'ADDED' as const,
|
||||
description: `Add spec ${i}`,
|
||||
}));
|
||||
|
||||
const change = {
|
||||
name: 'massive-change',
|
||||
why: 'This is a massive change that affects many parts of the system',
|
||||
whatChanges: 'Update everything',
|
||||
deltas,
|
||||
};
|
||||
|
||||
const result = ChangeSchema.safeParse(change);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Consider splitting changes with more than 10 deltas');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validator', () => {
|
||||
const testDir = path.join(process.cwd(), 'test-validation-tmp');
|
||||
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('validateSpec', () => {
|
||||
it('should validate a valid spec file', async () => {
|
||||
const specContent = `# User Authentication Spec
|
||||
|
||||
## Purpose
|
||||
This specification defines the requirements for user authentication in the system.
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL provide secure user authentication
|
||||
The system SHALL provide secure user authentication mechanisms.
|
||||
|
||||
#### Scenario: Successful login
|
||||
Given a user with valid credentials
|
||||
When they submit the login form
|
||||
Then they are authenticated and redirected to the dashboard
|
||||
|
||||
### The system SHALL handle invalid login attempts
|
||||
The system SHALL gracefully handle incorrect credentials.
|
||||
|
||||
#### Scenario: Invalid credentials
|
||||
Given a user with invalid credentials
|
||||
When they submit the login form
|
||||
Then they see an error message`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
const validator = new Validator();
|
||||
const report = await validator.validateSpec(specPath);
|
||||
|
||||
expect(report.valid).toBe(true);
|
||||
expect(report.summary.errors).toBe(0);
|
||||
});
|
||||
|
||||
it('should detect missing overview section', async () => {
|
||||
const specContent = `# User Authentication Spec
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL provide secure user authentication
|
||||
|
||||
#### Scenario: Login
|
||||
Given a user
|
||||
When they login
|
||||
Then authenticated`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
const validator = new Validator();
|
||||
const report = await validator.validateSpec(specPath);
|
||||
|
||||
expect(report.valid).toBe(false);
|
||||
expect(report.summary.errors).toBeGreaterThan(0);
|
||||
expect(report.issues.some(i => i.message.includes('Purpose'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateChange', () => {
|
||||
it('should validate a valid change file', async () => {
|
||||
const changeContent = `# Add User Authentication
|
||||
|
||||
## Why
|
||||
We need to implement user authentication to secure the application and protect user data from unauthorized access.
|
||||
|
||||
## What Changes
|
||||
- **user-auth:** Add new user authentication specification
|
||||
- **api-endpoints:** Modify to include auth endpoints`;
|
||||
|
||||
const changePath = path.join(testDir, 'change.md');
|
||||
await fs.writeFile(changePath, changeContent);
|
||||
|
||||
const validator = new Validator();
|
||||
const report = await validator.validateChange(changePath);
|
||||
|
||||
expect(report.valid).toBe(true);
|
||||
expect(report.summary.errors).toBe(0);
|
||||
});
|
||||
|
||||
it('should detect missing why section', async () => {
|
||||
const changeContent = `# Add User Authentication
|
||||
|
||||
## What Changes
|
||||
- **user-auth:** Add new user authentication specification`;
|
||||
|
||||
const changePath = path.join(testDir, 'change.md');
|
||||
await fs.writeFile(changePath, changeContent);
|
||||
|
||||
const validator = new Validator();
|
||||
const report = await validator.validateChange(changePath);
|
||||
|
||||
expect(report.valid).toBe(false);
|
||||
expect(report.summary.errors).toBeGreaterThan(0);
|
||||
expect(report.issues.some(i => i.message.includes('Why'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('strict mode', () => {
|
||||
it('should fail on warnings in strict mode', async () => {
|
||||
const specContent = `# Test Spec
|
||||
|
||||
## Purpose
|
||||
Brief overview
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL do something
|
||||
|
||||
#### Scenario: Test
|
||||
Given test
|
||||
When action
|
||||
Then result`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
const validator = new Validator(true); // strict mode
|
||||
const report = await validator.validateSpec(specPath);
|
||||
|
||||
expect(report.valid).toBe(false); // Should fail due to brief overview warning
|
||||
});
|
||||
|
||||
it('should pass warnings in non-strict mode', async () => {
|
||||
const specContent = `# Test Spec
|
||||
|
||||
## Purpose
|
||||
Brief overview
|
||||
|
||||
## Requirements
|
||||
|
||||
### The system SHALL do something
|
||||
|
||||
#### Scenario: Test
|
||||
Given test
|
||||
When action
|
||||
Then result`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
const validator = new Validator(false); // non-strict mode
|
||||
const report = await validator.validateSpec(specPath);
|
||||
|
||||
expect(report.valid).toBe(true); // Should pass despite warnings
|
||||
expect(report.summary.warnings).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user