Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale f0b1e00c65 Address review 2025-08-19 18:44:37 +10:00
Tabish Bidiwale 5fe72ddc5d remove archive file 2025-08-19 18:26:34 +10:00
Tabish Bidiwale c18f3b2b2e feat(archive): apply delta-based updates with header matching, skeleton creation, atomic writes, and per-spec counts; add requirement-block parser; add tests covering normalization, order, validation, rename+modify, and multi-spec; update proposal and tasks with product decisions 2025-08-19 18:24:27 +10:00
Tabish Bidiwale 33344727a8 Merge pull request #39 from Fission-AI/make-commands-consistent
Make change and spec commands consistent (raw-first)
2025-08-18 23:26:47 +10:00
Tabish Bidiwale 828e5ba316 chore: remove unused imports; improve no-change messaging; update README for raw-first, JSON-only filters, --long, and --no-color 2025-08-18 23:20:47 +10:00
Tabish Bidiwale 31c57f5c0f Follow-up: add debug logging and tests for raw-first behavior\n\n- change list: add conditional debug logging when reading tasks.md fails\n- spec tests: align with raw-first (JSON-only filters), add --no-color test, raw text passthrough, missing ID error\n- fix change show JSON mapping to stable keys (id/title) and types; ensure build passes 2025-08-18 22:47:03 +10:00
Tabish Bidiwale 767a0053e8 Make change and spec commands consistent (raw-first)\n\n- Require explicit IDs for show; no auto-pick\n- Text mode: raw markdown passthrough; no filters\n- JSON contracts: minimal objects (no top-level arrays)\n- change show: add --deltas-only (JSON); deprecate --requirements-only\n- change list: ids by default; --long for minimal details; unified JSON { id, title, deltaCount, taskStatus }\n- spec show: raw text; JSON { id, title, overview, requirementCount, requirements, metadata }\n- spec list: ids by default; --long for minimal details; unified JSON { id, title, requirementCount }\n- Errors: console.error + process.exitCode\n- Add global --no-color\n- Update tests to new contracts 2025-08-18 22:31:17 +10:00
Tabish Bidiwale fd65b99c91 Merge pull request #38 from Fission-AI/add-change-commands
feat: add change command with show, list, and validate subcommands
2025-08-16 17:19:35 +10:00
Tabish Bidiwale e170df9f41 test(change): add minimal tests for change parser and command; fix async converter usage 2025-08-16 17:18:32 +10:00
Tabish Bidiwale b91040e8c2 chore(cli): tighten change show help text and remove unused imports 2025-08-16 17:18:32 +10:00
Tabish Bidiwale 8824bd2a42 feat: implement change-specific parser for delta format 2025-08-16 17:18:32 +10:00
Tabish Bidiwale 1d3c292d94 fix: address code review feedback for change commands
- Replace any types with proper Change and Delta types from schemas
- Add error logging in catch blocks when DEBUG env var is set
- Include file paths in error messages for better debugging
- Extract regex patterns and magic strings as constants
- Improve code maintainability and type safety
2025-08-16 17:18:32 +10:00
Tabish Bidiwale cfe6da96ac feat: add change command with show, list, and validate subcommands
- Add new `openspec change` command with three subcommands
- Implement JSON output capability for change proposals
- Add deprecation warning to legacy `openspec list` command
- Enable --requirements-only filtering for change show
- Support --strict mode and --json flags for validation

This provides programmatic access to change proposals through JSON output
and establishes a consistent resource-based command structure.
2025-08-16 17:18:32 +10:00
Tabish Bidiwale c3c78551d0 Merge pull request #37 from Fission-AI/add-spec-commands
Add spec command for programmatic access to specifications
2025-08-16 11:40:25 +10:00
Tabish Bidiwale f1fabc5f18 refactor: standardize spec format to use Purpose and Requirements sections only 2025-08-16 11:34:09 +10:00
Tabish Bidiwale 166b960428 chore(deps): upgrade zod to v4 and verify compatibility 2025-08-16 00:39:47 +10:00
Tabish Bidiwale ef1a6c0f0b docs: mark all spec command tasks as complete 2025-08-16 00:39:42 +10:00
Tabish Bidiwale 9b3944bd09 refactor(cli): simplify spec command parsing and filtering; improve option validation and exits 2025-08-16 00:32:55 +10:00
Tabish Bidiwale 7917d08a50 feat(cli): add spec command for programmatic access to specifications
Implements new `openspec spec` command with three subcommands:
- `spec show`: Display specs with JSON output and filtering options
- `spec list`: List all available specifications
- `spec validate`: Validate spec structure with detailed reporting

Key features:
- JSON output for CI/CD integration (--json flag)
- Content filtering (--requirements, --no-scenarios, -r)
- Strict validation mode (--strict)
- Support for both Overview/Purpose and Requirements/Behavior sections

This enables programmatic access to OpenSpec specifications for external
tools and automated pipelines as specified in the add-spec-commands change.
2025-08-15 23:54:26 +10:00
Tabish Bidiwale 4a8e5986f0 Merge pull request #35 from Fission-AI/add-zod-validation
feat: Add Zod runtime validation for specs and changes
2025-08-15 23:34:48 +10:00
Tabish Bidiwale 103838f371 fix: address PR review comments for validation improvements
- Use word boundaries in delta operation detection to avoid false matches
- Standardize name extraction to use directory name after specs/changes
- Combine archive validation warning into single prompt for better UX
- Add change.md validation to archive command before archiving
2025-08-15 23:32:51 +10:00
Tabish Bidiwale 0a26c686f9 refactor: simplify scenario parsing to store raw text
- Replace structured {given, when, then} with {rawText} field
- Remove complex Given/When/Then parsing logic
- Preserve original formatting and whitespace
- Update all tests to use rawText field
- Remove unnecessary validation checks

This change reduces complexity while preserving all content exactly
as written, making the system more maintainable and flexible.
2025-08-15 23:25:24 +10:00
Tabish Bidiwale efcf766193 docs: add validation and migration documentation to openspec
- Add VALIDATION.md with comprehensive schema and rules documentation
- Add MIGRATION.md with guide for future command integration
- Update task references to correct documentation locations
2025-08-15 23:07:00 +10:00
Tabish Bidiwale 151eddb759 fix: address minor code review issues
- Extract magic numbers to named constants in validation/constants.ts
- Update task 3.2 to reflect actual implementation (constants.ts)
- Add comprehensive documentation for validation system
- Add migration guide for future command integration
- Update CLI help text for diff command
2025-08-15 23:05:47 +10:00
Tabish Bidiwale cb0d6f3189 feat: add zod runtime validation for specs and changes
- Add Zod schemas for specs, changes, requirements, and scenarios
- Implement markdown parser for extracting structured data
- Create validation infrastructure with error/warning/info levels
- Enhance archive command with pre-archive validation
- Add --no-validate flag with confirmation prompt for emergencies
- Enhance diff command with non-blocking validation warnings
- Add JSON converters for spec and change formats
- Add comprehensive test coverage for all validation components
2025-08-15 22:50:05 +10:00
Tabish Bidiwale a897c697a5 Merge pull request #34 from Fission-AI/json-zod-implementation-plan
Add JSON output and Zod validation change proposals
2025-08-15 22:01:58 +10:00
39 changed files with 3943 additions and 140 deletions
+76
View File
@@ -0,0 +1,76 @@
## openspec change vs spec: behavior differences and recommendations
This document compares how `openspec change` and `openspec spec` behave today (focused on `show` and `list`) and recommends a raw-first, minimal standard to keep behavior simple and predictable before adding smarter formatting later.
## Summary of key differences and recommendations
| Area | Current: change | Current: spec | Recommendation |
|---|---|---|---|
| Invocation (show) | `openspec change show [change-name]` auto-picks when only one active change | `openspec spec show <spec-id>` requires id | Require explicit IDs for both. If `change-name` is omitted, print available IDs and a short hint (e.g., use `openspec change list`) and exit non-zero. No auto-pick. No interactive picker. |
| Default text output (show) | Raw `proposal.md` content | Formatted summary | Default both to RAW: print the underlying Markdown file as-is. Provide a future `--pretty` flag (non-default) for formatted output. |
| Filtering flags (show) | `--requirements-only` affects text and JSON | `--requirements`, `--no-scenarios`, `-r/--requirement` | Raw-first: in TEXT mode, no filtering. All filtering applies only to JSON output. Deprecate text-mode filters. Keep minimal JSON filters only. |
| JSON shape (show) | Full change object; `--requirements-only` can return an array | Filtered object | Always return an OBJECT. Minimal, stable shape. Change: `{ id, title, deltaCount, deltas, taskStatus? }`. Spec: `{ id, title, overview, requirementCount, requirements, metadata }`. No top-level arrays. |
| Text output (list) | "Active Changes" with progress | "Available Specifications" with teaser | Default both to RAW/minimal: print IDs only by default. Add `--long` to show `id + title` and minimal details (counts). No teasers. |
| JSON shape (list) | `[{ name, title, deltas, taskStatus }]` | `[{ id, title, overview, requirementCount }]` | Unify minimal keys: `id`, `title`, counts only. Change: `deltaCount`, `taskStatus`. Spec: `requirementCount`. Drop `overview` from list JSON. Sort by `id`. |
| Error/exit policy | Spinner + `process.exit(1)` in some paths | `exitCode` in others | Raw-first: no spinners in errors. Use `console.error` + `process.exitCode = 1` consistently. |
| Empty states (list) | Graceful | Errors if specs missing | Raw-first: graceful empty state everywhere. Print "No items found" and exit 0. |
| Multi-selection (change show) | Auto-pick single; error when multiple | N/A | Keep auto-pick single. If multiple, print IDs inline and exit non-zero. No interactive prompts. |
| Colors/TTY | Mixed | Chalk only | Raw-first: minimal color; honor `NO_COLOR` and add `--no-color`. |
## Detailed guidance
### 1) Unify flags and semantics (raw-first)
- Text mode: no filters; just raw file content.
- JSON mode: allow minimal filters only.
- Specs: `--json` returns the structured spec. Optional: `-r/--requirement <n>` and `--requirements-only` apply to JSON only.
- Changes: `--json` returns the structured change. Optional: `--deltas-only` applies to JSON only.
- Deprecate text-mode filtering flags across both commands.
- Optional future: `--pretty` (text formatting) as a non-default enhancement.
### 2) Normalize JSON contracts (minimal and stable)
- Show (change): `{ id, title, deltaCount, deltas: [...], taskStatus?: { total, completed } }`
- Show (spec): `{ id, title, overview, requirementCount, requirements: [...], metadata: { format, version } }`
- List (change): `[{ id, title, deltaCount, taskStatus }]`
- List (spec): `[{ id, title, requirementCount }]`
- Notes:
- No top-level arrays for filtered show responses; always objects.
- Avoid derived/pretty fields (e.g., teasers, percentages). Counts only.
### 3) Standardize text UI (minimal)
- Show: print raw Markdown file contents.
- List (default): print IDs only, one per line.
- List (`--long`): print `id: title` plus minimal counts where relevant.
- Keep colors minimal; support `--no-color` and respect `NO_COLOR`.
### 4) Consistent error handling and empty states
- Use `console.error` + `process.exitCode = 1`. Avoid `process.exit(1)`.
- No spinners (`ora`) in raw-first mode.
- Empty states print a simple message and exit 0.
### 5) Discoverability and UX
- No `change-name` provided: print IDs inline and a short hint; exit non-zero. No auto-pick. No interactive prompts.
- Add `--no-color` for deterministic logs and pipelines.
### 6) Backwards compatibility and deprecation
- Keep legacy flags as aliases for one minor release.
- Print a clear deprecation warning when a legacy flag is used.
- Update CLI docs/README/specs to reflect raw-first behavior.
### 7) Test coverage updates
- Add tests asserting raw text outputs (file passthrough) for `show`.
- Add tests for minimal list outputs (IDs by default, `--long` for details).
- Add JSON contract tests asserting minimal, stable shapes and JSON-only filtering.
### 8) Library alignment with existing commands
- No new dependencies.
- Do not use `@inquirer/prompts` in `change`/`spec` show/list (keep non-interactive). Interaction remains limited to `init`, `diff`, and `archive` where already in use.
- Do not use `ora` in `change`/`spec` (including validate). Use `console.error` and `process.exitCode`.
- Use `chalk` minimally; support `--no-color` and respect `NO_COLOR`.
- Keep using the shared `Validator` where applicable.
- Do not introduce `jest-diff` into `change`/`spec` (remains specific to `diff`).
## Why this approach
- Keeps the system raw and predictable; easy to compose in scripts.
- Minimizes UI/formatting logic until real needs emerge.
- Stabilizes JSON for tooling and avoids top-level arrays.
- Simple to extend later with `--pretty` and richer filtering if needed.
+39 -7
View File
@@ -17,8 +17,9 @@ openspec init
# Update existing OpenSpec instructions (team-friendly)
openspec update
# List all specifications
openspec list
# List specs or changes
openspec spec list # specs (IDs by default; use --long for details)
openspec change list # changes (IDs by default; use --long for details)
# Show differences between specs and proposed changes
openspec diff [change-name]
@@ -47,12 +48,37 @@ Updates OpenSpec instructions to the latest version. This command is **team-frie
This allows team members to use different AI tools without conflicts. Each developer can maintain their preferred AI tool configuration file, and `openspec update` will respect their choice.
### `openspec list`
### `openspec spec`
Lists all specifications and pending changes in your project:
- Shows current specifications in `openspec/specs/`
- Shows pending changes in `openspec/changes/`
- Shows archived changes in `openspec/changes/archive/`
Manage and view specifications.
Examples:
- `openspec spec show <spec-id>`
- Text mode: prints raw `spec.md` content
- JSON mode (`--json`): returns minimal, stable shape
- Filters are JSON-only: `--requirements`, `--no-scenarios`, `-r/--requirement <1-based>`
- `openspec spec list`
- Prints IDs only by default
- Use `--long` to include `title` and `[requirements N]`
- `openspec spec validate <spec-id>`
- Text: human-readable summary to stdout/stderr
- `--json` for structured report
### `openspec change`
Manage and view change proposals.
Examples:
- `openspec change show <change-id>`
- Text mode: prints raw `proposal.md` content
- JSON mode (`--json`): `{ id, title, deltaCount, deltas }`
- Filtering is JSON-only: `--deltas-only` (alias: `--requirements-only`, deprecated)
- `openspec change list`
- Prints IDs only by default
- Use `--long` to include `title` and counts `[deltas N] [tasks x/y]`
- `openspec change validate <change-id>`
- Text: human-readable result
- `--json` for structured report
### `openspec diff [change-name]`
@@ -82,6 +108,12 @@ OpenSpec is designed for team collaboration:
See `openspec/specs/` for the current system specifications and `openspec/changes/` for pending improvements.
## Notes
- The legacy `openspec list` command is deprecated. Use `openspec spec list` and `openspec change list`.
- Text output is raw-first (no formatting or filtering). Prefer `--json` for tooling-friendly output.
- Global `--no-color` disables ANSI colors and respects `NO_COLOR`.
## License
MIT
@@ -6,11 +6,8 @@ OpenSpec change proposals currently can only be viewed as markdown files, creati
## What Changes
- Add new `openspec change` command with three subcommands: `show`, `list`, and `validate`
- Implement JSON output capability for change proposals
- Add Zod schemas for change structure validation
- Maintain backward compatibility with existing `openspec list` command
- Enable filtering options specific to changes (requirements-only view)
- **cli-change:** Add new command for managing change proposals with show, list, and validate subcommands
- **cli-list:** Add deprecation notice for legacy list command to guide users to the new change list command
## Impact
+24 -24
View File
@@ -1,34 +1,34 @@
# Implementation Tasks (Phase 2: Builds on add-zod-validation)
## 1. Command Implementation
- [ ] 1.1 Create src/commands/change.ts
- [ ] 1.2 Import ChangeSchema and DeltaSchema from src/core/schemas/change.schema.ts
- [ ] 1.3 Import markdown parser from src/core/parsers/markdown-parser.ts
- [ ] 1.4 Import ChangeValidator from src/core/validation/validator.ts
- [ ] 1.5 Import JSON converter from src/core/converters/json-converter.ts
- [ ] 1.6 Implement show subcommand with JSON output using existing converter
- [ ] 1.7 Implement list subcommand
- [ ] 1.8 Implement validate subcommand using existing ChangeValidator
- [ ] 1.9 Add --requirements-only filtering option
- [ ] 1.10 Add --strict mode support (leveraging existing validation infrastructure)
- [ ] 1.11 Add --json flag for validation reports
- [x] 1.1 Create src/commands/change.ts
- [x] 1.2 Import ChangeSchema and DeltaSchema from src/core/schemas/change.schema.ts
- [x] 1.3 Import markdown parser from src/core/parsers/markdown-parser.ts
- [x] 1.4 Import ChangeValidator from src/core/validation/validator.ts
- [x] 1.5 Import JSON converter from src/core/converters/json-converter.ts
- [x] 1.6 Implement show subcommand with JSON output using existing converter
- [x] 1.7 Implement list subcommand
- [x] 1.8 Implement validate subcommand using existing ChangeValidator
- [x] 1.9 Add --requirements-only filtering option
- [x] 1.10 Add --strict mode support (leveraging existing validation infrastructure)
- [x] 1.11 Add --json flag for validation reports
## 2. Change-Specific Parser Extensions
- [ ] 2.1 Create src/core/parsers/change-parser.ts (extends base markdown parser)
- [ ] 2.2 Parse proposal structure (Why, What Changes sections)
- [ ] 2.3 Extract ADDED/MODIFIED/REMOVED/RENAMED sections
- [ ] 2.4 Parse delta operations within each section
- [ ] 2.5 Add tests for change parser
- [x] 2.1 Create src/core/parsers/change-parser.ts (extends base markdown parser)
- [x] 2.2 Parse proposal structure (Why, What Changes sections)
- [x] 2.3 Extract ADDED/MODIFIED/REMOVED/RENAMED sections
- [x] 2.4 Parse delta operations within each section
- [x] 2.5 Add tests for change parser
## 3. Legacy Compatibility
- [ ] 3.1 Update src/core/list.ts to add deprecation notice
- [ ] 3.2 Ensure existing list command continues to work
- [ ] 3.3 Add console warning for deprecated command usage
- [x] 3.1 Update src/core/list.ts to add deprecation notice
- [x] 3.2 Ensure existing list command continues to work
- [x] 3.3 Add console warning for deprecated command usage
## 4. Integration
- [ ] 4.1 Register change command in src/cli/index.ts
- [x] 4.1 Register change command in src/cli/index.ts
- [ ] 4.2 Add integration tests for all subcommands
- [ ] 4.3 Test JSON output for changes
- [ ] 4.4 Test legacy compatibility
- [ ] 4.5 Test validation with strict mode
- [ ] 4.6 Update CLI help documentation (add 'change' command to main help, document subcommands: show, list, validate)
- [x] 4.3 Test JSON output for changes
- [x] 4.4 Test legacy compatibility
- [x] 4.5 Test validation with strict mode
- [x] 4.6 Update CLI help documentation (add 'change' command to main help, document subcommands: show, list, validate)
+17 -17
View File
@@ -1,22 +1,22 @@
# Implementation Tasks (Phase 3: Builds on add-zod-validation and add-change-commands)
## 1. Command Implementation
- [ ] 1.1 Create src/commands/spec.ts
- [ ] 1.2 Import RequirementSchema, ScenarioSchema, SpecSchema from src/core/schemas/
- [ ] 1.3 Import markdown parser from src/core/parsers/markdown-parser.ts
- [ ] 1.4 Import SpecValidator from src/core/validation/validator.ts
- [ ] 1.5 Import JSON converter from src/core/converters/json-converter.ts
- [ ] 1.6 Implement show subcommand with JSON output using existing converter
- [ ] 1.7 Implement list subcommand
- [ ] 1.8 Implement validate subcommand using existing SpecValidator
- [ ] 1.9 Add filtering options (--requirements, --no-scenarios, -r)
- [ ] 1.10 Add --strict mode support (leveraging existing validation infrastructure)
- [ ] 1.11 Add --json flag for validation reports
- [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
- [ ] 2.1 Register spec command in src/cli/index.ts
- [ ] 2.2 Add integration tests for all subcommands
- [ ] 2.3 Test JSON output validation
- [ ] 2.4 Test filtering options
- [ ] 2.5 Test validation with strict mode
- [ ] 2.6 Update CLI help documentation (add 'spec' command to main help, document subcommands: show, list, validate)
- [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)
+40 -40
View File
@@ -1,59 +1,59 @@
# Implementation Tasks (Foundation Phase)
## 1. Core Schemas
- [ ] 1.1 Add zod dependency to package.json
- [ ] 1.2 Create src/core/schemas/base.schema.ts with ScenarioSchema and RequirementSchema
- [ ] 1.3 Create src/core/schemas/spec.schema.ts with SpecSchema
- [ ] 1.4 Create src/core/schemas/change.schema.ts with DeltaSchema and ChangeSchema
- [ ] 1.5 Create src/core/schemas/index.ts to export all 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
- [ ] 2.1 Create src/core/parsers/markdown-parser.ts
- [ ] 2.2 Implement heading extraction (##, ###, ####)
- [ ] 2.3 Implement content capture between headings
- [ ] 2.4 Add tests for parser edge cases
- [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
- [ ] 3.1 Create src/core/validation/types.ts with ValidationLevel, ValidationIssue, ValidationReport types
- [ ] 3.2 Create src/core/validation/rules.ts with enhanced validation rules
- [ ] 3.3 Create src/core/validation/validator.ts with SpecValidator and ChangeValidator classes
- [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
- [ ] 4.1 Add RequirementValidation refinements (must have scenarios, must contain SHALL)
- [ ] 4.2 Add SpecValidation refinements (must have requirements)
- [ ] 4.3 Add ChangeValidation refinements (must have deltas, why section length)
- [ ] 4.4 Implement custom error messages for each rule
- [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
- [ ] 5.1 Create src/core/converters/json-converter.ts
- [ ] 5.2 Implement spec-to-JSON conversion
- [ ] 5.3 Implement change-to-JSON conversion
- [ ] 5.4 Add metadata fields (version, format, sourcePath)
- [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
- [ ] 6.1 Add pre-archive validation check using new validators
- [ ] 6.2 Add --no-validate flag with required confirmation prompt and warning message: "⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)"
- [ ] 6.3 Display validation errors before aborting
- [ ] 6.4 Log all --no-validate usages to console with timestamp and affected files
- [ ] 6.5 Add tests for validation scenarios including --no-validate confirmation flow
- [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
- [ ] 7.1 Add validation check before diff using new validators
- [ ] 7.2 Show validation warnings (non-blocking)
- [ ] 7.3 Continue with diff even if warnings present
- [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
- [ ] 8.1 Unit tests for all schemas
- [ ] 8.2 Unit tests for parser
- [ ] 8.3 Unit tests for validation rules
- [ ] 8.4 Integration tests for validation reports
- [ ] 8.5 Test various invalid spec/change formats
- [ ] 8.6 Test strict mode behavior
- [ ] 8.7 Test pre-archive validation
- [ ] 8.8 Test validation report JSON output
- [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
- [ ] 9.1 Document schema structure and validation rules
- [ ] 9.2 Update CLI help for archive (document --no-validate flag and its warnings)
- [ ] 9.3 Update CLI help for diff (document validation warnings behavior)
- [ ] 9.4 Create migration guide for future command integration
- [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)
@@ -63,4 +63,31 @@ This makes reviews focused and changes explicit.
## Conflict Resolution
Git naturally detects conflicts when two changes modify the same requirement header. This is actually better than full-state storage where Git might silently merge incompatible changes.
Git naturally detects conflicts when two changes modify the same requirement header. This is actually better than full-state storage where Git might silently merge incompatible changes.
## Decisions and Product Guidelines
To keep the archive flow lean and predictable, the following decisions apply:
- New spec creation: When a target spec does not exist, auto-generate a minimal skeleton and insert ADDED requirements only. Skeleton format:
- `# [Spec Name] Specification`
- `## Purpose` with placeholder: "TBD — created by archiving change [change-name]. Update Purpose after archive."
- `## Requirements`
- If a non-existent spec includes MODIFIED/REMOVED/RENAMED, abort with guidance to create via ADDED-only first.
- Requirement identification: Match requirements by exact header `### Requirement: [Name]` with trim-only normalization and case-sensitive comparison. Use a requirement-block extractor that preserves the exact header and captures full content (including scenarios) for both main specs and delta files.
- Application order and atomicity: Apply deltas in order RENAMED → REMOVED → MODIFIED → ADDED. Validate all operations first, apply in-memory, and write each spec once. On any validation failure, abort without writing partial results. An aggregated totals line is displayed across all specs: `Totals: + A, ~ M, - R, → N`.
- Validation matrix: Enforce that MODIFIED/REMOVED exist; ADDED do not exist; RENAMED FROM exists and TO does not; no duplicates after all operations; and no cross-section conflicts (e.g., same item in MODIFIED and REMOVED). When a rename and modify apply to the same item, MODIFIED must reference the NEW header.
- Idempotency: Keep v1 simple. Abort on precondition failures (e.g., ADDED already exists) with clear errors. Do not implement no-op detection in v1.
- Output and UX: For each spec, display operation counts using standard symbols `+ ~ - →`. Optionally include a short aggregated totals line at the end. Keep messages concise and actionable.
- Error messaging: Standardize messages as `[spec] [operation] failed for header "### Requirement: X" — reason`. On abort, explicitly state: `Aborted. No files were changed.`
- Subsections: Any subsections under a requirement (e.g., `#### Scenario: ...`) are preserved verbatim during parsing and application.
- Backward compatibility: Reject full future-state spec copies for existing specs with guidance to convert to deltas. Allow brand-new specs to be created via ADDED-only deltas using the skeleton above.
- Dry-run: Deferred for v1 to keep scope minimal.
@@ -17,20 +17,36 @@
- [ ] 2.6 Add tests for side-by-side view formatting
## 3. Update Archive Command
- [ ] 3.1 Update cli-archive spec with delta processing behavior
- [ ] 3.2 Implement normalized header matching (trim whitespace)
- [ ] 3.3 Parse delta sections (ADDED/MODIFIED/REMOVED/RENAMED)
- [ ] 3.4 Apply changes in order: RENAMED → REMOVED → MODIFIED → ADDED
- [ ] 3.5 Validate delta operations:
- [ ] 3.5.1 MODIFIED/REMOVED requirements exist
- [ ] 3.5.2 ADDED requirements don't already exist
- [ ] 3.5.3 RENAMED FROM headers exist, TO headers don't
- [ ] 3.5.4 No duplicate headers within specs
- [ ] 3.5.5 Renamed requirements aren't also in ADDED
- [ ] 3.6 Display operation counts (+ 2 added, ~ 3 modified, etc.)
- [ ] 3.7 Add tests for header normalization
- [ ] 3.8 Add tests for applying deltas in correct order
- [ ] 3.9 Add tests for validation edge cases
- [x] 3.1 Update cli-archive spec with delta processing behavior
- [x] 3.2 Implement requirement-block extractor that preserves exact headers (`### Requirement: [Name]`) and captures full content (including scenarios)
- [x] 3.3 Implement normalized header matching (trim-only, case-sensitive)
- [x] 3.4 Parse delta sections (ADDED/MODIFIED/REMOVED/RENAMED)
- [x] 3.5 New spec creation when target spec does not exist
- [x] 3.5.1 Auto-generate minimal skeleton: `# [Spec Name] Specification`, `## Purpose` placeholder, `## Requirements`
- [x] 3.5.2 Allow only ADDED operations for non-existent specs; abort if MODIFIED/REMOVED/RENAMED present
- [x] 3.6 Apply changes in order: RENAMED → REMOVED → MODIFIED → ADDED
- [x] 3.7 Validation and conflict checks
- [x] 3.7.1 MODIFIED/REMOVED requirements exist (after applying rename mappings)
- [x] 3.7.2 ADDED requirements don't already exist (consider post-rename state)
- [x] 3.7.3 RENAMED FROM headers exist; TO headers don't (including collisions with ADDED)
- [x] 3.7.4 No duplicate headers within specs after all operations
- [x] 3.7.5 Detect cross-section conflicts (e.g., same requirement in MODIFIED and REMOVED)
- [x] 3.7.6 When a rename exists, require MODIFIED to reference the NEW header
- [x] 3.8 Atomic updates
- [x] 3.8.1 Validate all deltas first; stage updates in-memory per spec
- [x] 3.8.2 Single write per spec; abort entire archive on any validation failure (no partial writes)
- [x] 3.9 Output and error messaging
- [x] 3.9.1 Display per-spec operation counts with symbols: `+` added, `~` modified, `-` removed, `→` renamed
- [x] 3.9.2 Optionally display an aggregated totals line across all specs
- [x] 3.9.3 Standardize error message format: `[spec] [operation] failed for header "### Requirement: X" — reason`; end with `Aborted. No files were changed.` on failure
- [x] 3.10 Idempotency behavior (v1): abort on precondition failures (e.g., ADDED already exists); do not implement no-op detection
- [x] 3.11 Tests
- [x] 3.11.1 Header normalization (trim-only) matching
- [x] 3.11.2 Apply in correct order (RENAMED → REMOVED → MODIFIED → ADDED)
- [x] 3.11.3 Validation edge cases (missing headers, duplicates, rename collisions, conflicting sections)
- [x] 3.11.4 Rename + modify interplay (MODIFIED uses new header)
- [x] 3.11.5 New spec creation via skeleton
- [x] 3.11.6 Multi-spec mixed operations with independent validation and write
## Notes
- Archive command is critical path - must work reliably
+1 -1
View File
@@ -11,7 +11,7 @@ openspec archive [change-name] [--yes|-y]
Options:
- `--yes`, `-y`: Skip confirmation prompts (for automation)
## Behavior
## Requirements
### Requirement: Change Selection
+1 -1
View File
@@ -10,7 +10,7 @@ The `openspec diff` command provides developers with a visual comparison between
openspec diff [change-name]
```
## Behavior
## Requirements
### Requirement: Without Arguments
+1 -1
View File
@@ -4,7 +4,7 @@
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
### Requirement: Progress Indicators
+1 -1
View File
@@ -4,7 +4,7 @@
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
### Requirement: Command Execution
+2 -1
View File
@@ -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"
}
}
+8
View File
@@ -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: {}
+71 -3
View File
@@ -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,12 +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')
.option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)')
.action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean }) => {
.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);
@@ -107,4 +173,6 @@ program
}
});
registerSpecCommand(program);
program.parse();
+248
View File
@@ -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 };
}
}
+206
View File
@@ -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;
}
+366 -8
View File
@@ -2,6 +2,14 @@ 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';
import {
extractRequirementsSection,
parseDeltaSpec,
normalizeRequirementName,
type RequirementBlock,
} from './parsers/requirement-blocks.js';
interface SpecUpdate {
source: string;
@@ -10,7 +18,7 @@ interface SpecUpdate {
}
export class ArchiveCommand {
async execute(changeName?: string, options: { yes?: boolean; skipSpecs?: 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 +53,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);
@@ -91,10 +184,31 @@ export class ArchiveCommand {
}
if (shouldUpdateSpecs) {
// Update specs
for (const update of specUpdates) {
await this.updateSpec(update);
// Prepare all updates first (validation pass, no writes)
const prepared: Array<{ update: SpecUpdate; rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> = [];
try {
for (const update of specUpdates) {
const built = await this.buildUpdatedSpec(update, changeName!);
prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });
}
} catch (err: any) {
console.log(String(err.message || err));
console.log('Aborted. No files were changed.');
return;
}
// All validations passed; write files and display counts
let totals = { added: 0, modified: 0, removed: 0, renamed: 0 };
for (const p of prepared) {
await this.writeUpdatedSpec(p.update, p.rebuilt, p.counts);
totals.added += p.counts.added;
totals.modified += p.counts.modified;
totals.removed += p.counts.removed;
totals.renamed += p.counts.renamed;
}
console.log(
`Totals: + ${totals.added}, ~ ${totals.modified}, - ${totals.removed}, → ${totals.renamed}`
);
console.log('Specs updated successfully.');
}
}
@@ -214,14 +328,258 @@ export class ArchiveCommand {
return updates;
}
private async updateSpec(update: SpecUpdate): Promise<void> {
private async buildUpdatedSpec(update: SpecUpdate, changeName: string): Promise<{ rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> {
// Read change spec content (delta-format expected)
const changeContent = await fs.readFile(update.source, 'utf-8');
// Parse deltas from the change spec file
const plan = parseDeltaSpec(changeContent);
const specName = path.basename(path.dirname(update.target));
// Pre-validate duplicates within sections
const addedNames = new Set<string>();
for (const add of plan.added) {
const name = normalizeRequirementName(add.name);
if (addedNames.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in ADDED for header "### Requirement: ${add.name}"`
);
}
addedNames.add(name);
}
const modifiedNames = new Set<string>();
for (const mod of plan.modified) {
const name = normalizeRequirementName(mod.name);
if (modifiedNames.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in MODIFIED for header "### Requirement: ${mod.name}"`
);
}
modifiedNames.add(name);
}
const removedNamesSet = new Set<string>();
for (const rem of plan.removed) {
const name = normalizeRequirementName(rem);
if (removedNamesSet.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in REMOVED for header "### Requirement: ${rem}"`
);
}
removedNamesSet.add(name);
}
const renamedFromSet = new Set<string>();
const renamedToSet = new Set<string>();
for (const { from, to } of plan.renamed) {
const fromNorm = normalizeRequirementName(from);
const toNorm = normalizeRequirementName(to);
if (renamedFromSet.has(fromNorm)) {
throw new Error(
`${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"`
);
}
if (renamedToSet.has(toNorm)) {
throw new Error(
`${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"`
);
}
renamedFromSet.add(fromNorm);
renamedToSet.add(toNorm);
}
// Pre-validate cross-section conflicts
const conflicts: Array<{ name: string; a: string; b: string }> = [];
for (const n of modifiedNames) {
if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'REMOVED' });
if (addedNames.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'ADDED' });
}
for (const n of addedNames) {
if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'ADDED', b: 'REMOVED' });
}
// Renamed interplay: MODIFIED must reference the NEW header, not FROM
for (const { from, to } of plan.renamed) {
const fromNorm = normalizeRequirementName(from);
const toNorm = normalizeRequirementName(to);
if (modifiedNames.has(fromNorm)) {
throw new Error(
`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`
);
}
// Detect ADDED colliding with a RENAMED TO
if (addedNames.has(toNorm)) {
throw new Error(
`${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"`
);
}
}
if (conflicts.length > 0) {
const c = conflicts[0];
throw new Error(
`${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"`
);
}
const hasAnyDelta = (plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length) > 0;
if (!hasAnyDelta) {
throw new Error(
`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
`Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`
);
}
// Load or create base target content
let targetContent: string;
try {
targetContent = await fs.readFile(update.target, 'utf-8');
} catch {
// Target spec does not exist; only ADDED operations are permitted
if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) {
throw new Error(
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs.`
);
}
targetContent = this.buildSpecSkeleton(specName, changeName);
}
// Extract requirements section and build name->block map
const parts = extractRequirementsSection(targetContent);
const nameToBlock = new Map<string, RequirementBlock>();
for (const block of parts.bodyBlocks) {
nameToBlock.set(normalizeRequirementName(block.name), block);
}
// Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
// RENAMED
for (const r of plan.renamed) {
const from = normalizeRequirementName(r.from);
const to = normalizeRequirementName(r.to);
if (!nameToBlock.has(from)) {
throw new Error(
`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`
);
}
if (nameToBlock.has(to)) {
throw new Error(
`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`
);
}
const block = nameToBlock.get(from)!;
const newHeader = `### Requirement: ${to}`;
const rawLines = block.raw.split('\n');
rawLines[0] = newHeader;
const renamedBlock: RequirementBlock = {
headerLine: newHeader,
name: to,
raw: rawLines.join('\n'),
};
nameToBlock.delete(from);
nameToBlock.set(to, renamedBlock);
}
// REMOVED
for (const name of plan.removed) {
const key = normalizeRequirementName(name);
if (!nameToBlock.has(key)) {
throw new Error(
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
);
}
nameToBlock.delete(key);
}
// MODIFIED
for (const mod of plan.modified) {
const key = normalizeRequirementName(mod.name);
if (!nameToBlock.has(key)) {
throw new Error(
`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`
);
}
// Replace block with provided raw (ensure header line matches key)
const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/);
if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
throw new Error(
`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`
);
}
nameToBlock.set(key, mod);
}
// ADDED
for (const add of plan.added) {
const key = normalizeRequirementName(add.name);
if (nameToBlock.has(key)) {
throw new Error(
`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`
);
}
nameToBlock.set(key, add);
}
// Duplicates within resulting map are implicitly prevented by key uniqueness.
// Recompose requirements section preserving original ordering where possible
const keptOrder: RequirementBlock[] = [];
const seen = new Set<string>();
for (const block of parts.bodyBlocks) {
const key = normalizeRequirementName(block.name);
const replacement = nameToBlock.get(key);
if (replacement) {
keptOrder.push(replacement);
seen.add(key);
}
}
// Append any newly added that were not in original order
for (const [key, block] of nameToBlock.entries()) {
if (!seen.has(key)) {
keptOrder.push(block);
}
}
const reqBody = [
parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : ''
]
.filter(Boolean)
.concat(keptOrder.map(b => b.raw))
.join('\n\n')
.trimEnd();
const rebuilt = [
parts.before.trimEnd(),
parts.headerLine,
reqBody,
parts.after
]
.filter((s, idx) => !(idx === 0 && s === ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n');
return {
rebuilt,
counts: {
added: plan.added.length,
modified: plan.modified.length,
removed: plan.removed.length,
renamed: plan.renamed.length,
}
};
}
private async writeUpdatedSpec(update: SpecUpdate, rebuilt: string, counts: { added: number; modified: number; removed: number; renamed: number }): Promise<void> {
// Create target directory if needed
const targetDir = path.dirname(update.target);
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(update.target, rebuilt);
// Copy spec file
const content = await fs.readFile(update.source, 'utf-8');
await fs.writeFile(update.target, content);
const specName = path.basename(path.dirname(update.target));
console.log(`Applying changes to openspec/specs/${specName}/spec.md:`);
if (counts.added) console.log(` + ${counts.added} added`);
if (counts.modified) console.log(` ~ ${counts.modified} modified`);
if (counts.removed) console.log(` - ${counts.removed} removed`);
if (counts.renamed) console.log(` → ${counts.renamed} renamed`);
}
private buildSpecSkeleton(specFolderName: string, changeName: string): string {
const titleBase = specFolderName;
return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`;
}
private getArchiveDate(): string {
+59
View File
@@ -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', '');
}
}
+48
View File
@@ -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;
+229
View File
@@ -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;
}
}
+232
View File
@@ -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;
}
}
+201
View File
@@ -0,0 +1,201 @@
export interface RequirementBlock {
headerLine: string; // e.g., '### Requirement: Something'
name: string; // e.g., 'Something'
raw: string; // full block including headerLine and following content
}
export interface RequirementsSectionParts {
before: string;
headerLine: string; // the '## Requirements' line
preamble: string; // content between headerLine and first requirement block
bodyBlocks: RequirementBlock[]; // parsed requirement blocks in order
after: string;
}
export function normalizeRequirementName(name: string): string {
return name.trim();
}
const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/;
/**
* Extracts the Requirements section from a spec file and parses requirement blocks.
*/
export function extractRequirementsSection(content: string): RequirementsSectionParts {
const lines = content.split('\n');
const reqHeaderIndex = lines.findIndex(l => /^##\s+Requirements\s*$/i.test(l));
if (reqHeaderIndex === -1) {
// No requirements section; create an empty one at the end
const before = content.trimEnd();
const headerLine = '## Requirements';
return {
before: before ? before + '\n\n' : '',
headerLine,
preamble: '',
bodyBlocks: [],
after: '\n',
};
}
// Find end of this section: next line that starts with '## ' at same or higher level
let endIndex = lines.length;
for (let i = reqHeaderIndex + 1; i < lines.length; i++) {
if (/^##\s+/.test(lines[i])) {
endIndex = i;
break;
}
}
const before = lines.slice(0, reqHeaderIndex).join('\n');
const headerLine = lines[reqHeaderIndex];
const sectionBodyLines = lines.slice(reqHeaderIndex + 1, endIndex);
// Parse requirement blocks within section body
const blocks: RequirementBlock[] = [];
let cursor = 0;
let preambleLines: string[] = [];
// Collect preamble lines until first requirement header
while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/.test(sectionBodyLines[cursor])) {
preambleLines.push(sectionBodyLines[cursor]);
cursor++;
}
while (cursor < sectionBodyLines.length) {
const headerStart = cursor;
const headerLineCandidate = sectionBodyLines[cursor];
const headerMatch = headerLineCandidate.match(REQUIREMENT_HEADER_REGEX);
if (!headerMatch) {
// Not a requirement header; skip line defensively
cursor++;
continue;
}
const name = normalizeRequirementName(headerMatch[1]);
cursor++;
// Gather lines until next requirement header or end of section
const bodyLines: string[] = [headerLineCandidate];
while (cursor < sectionBodyLines.length && !/^###\s+Requirement:/.test(sectionBodyLines[cursor]) && !/^##\s+/.test(sectionBodyLines[cursor])) {
bodyLines.push(sectionBodyLines[cursor]);
cursor++;
}
const raw = bodyLines.join('\n').trimEnd();
blocks.push({ headerLine: headerLineCandidate, name, raw });
}
const after = lines.slice(endIndex).join('\n');
const preamble = preambleLines.join('\n').trimEnd();
return {
before: before.trimEnd() ? before + '\n' : before,
headerLine,
preamble,
bodyBlocks: blocks,
after: after.startsWith('\n') ? after : '\n' + after,
};
}
export interface DeltaPlan {
added: RequirementBlock[];
modified: RequirementBlock[];
removed: string[]; // requirement names
renamed: Array<{ from: string; to: string }>;
}
/**
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
*/
export function parseDeltaSpec(content: string): DeltaPlan {
const sections = splitTopLevelSections(content);
const added = parseRequirementBlocksFromSection(sections['ADDED Requirements'] || '');
const modified = parseRequirementBlocksFromSection(sections['MODIFIED Requirements'] || '');
const removedNames = parseRemovedNames(sections['REMOVED Requirements'] || '');
const renamedPairs = parseRenamedPairs(sections['RENAMED Requirements'] || '');
return { added, modified, removed: removedNames, renamed: renamedPairs };
}
function splitTopLevelSections(content: string): Record<string, string> {
const lines = content.split('\n');
const result: Record<string, string> = {};
const indices: Array<{ title: string; index: number; level: number }> = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^(##)\s+(.+)$/);
if (m) {
const level = m[1].length; // only care for '##'
indices.push({ title: m[2].trim(), index: i, level });
}
}
for (let i = 0; i < indices.length; i++) {
const current = indices[i];
const next = indices[i + 1];
const body = lines.slice(current.index + 1, next ? next.index : lines.length).join('\n');
result[current.title] = body;
}
return result;
}
function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {
if (!sectionBody) return [];
const lines = sectionBody.split('\n');
const blocks: RequirementBlock[] = [];
let i = 0;
while (i < lines.length) {
// Seek next requirement header
while (i < lines.length && !/^###\s+Requirement:/.test(lines[i])) i++;
if (i >= lines.length) break;
const headerLine = lines[i];
const m = headerLine.match(REQUIREMENT_HEADER_REGEX);
if (!m) { i++; continue; }
const name = normalizeRequirementName(m[1]);
const buf: string[] = [headerLine];
i++;
while (i < lines.length && !/^###\s+Requirement:/.test(lines[i]) && !/^##\s+/.test(lines[i])) {
buf.push(lines[i]);
i++;
}
blocks.push({ headerLine, name, raw: buf.join('\n').trimEnd() });
}
return blocks;
}
function parseRemovedNames(sectionBody: string): string[] {
if (!sectionBody) return [];
const names: string[] = [];
const lines = sectionBody.split('\n');
for (const line of lines) {
const m = line.match(REQUIREMENT_HEADER_REGEX);
if (m) {
names.push(normalizeRequirementName(m[1]));
continue;
}
// Also support bullet list of headers
const bullet = line.match(/^\s*-\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
if (bullet) {
names.push(normalizeRequirementName(bullet[1]));
}
}
return names;
}
function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: string }> {
if (!sectionBody) return [];
const pairs: Array<{ from: string; to: string }> = [];
const lines = sectionBody.split('\n');
let current: { from?: string; to?: string } = {};
for (const line of lines) {
const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
const toMatch = line.match(/^\s*-?\s*TO:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
if (fromMatch) {
current.from = normalizeRequirementName(fromMatch[1]);
} else if (toMatch) {
current.to = normalizeRequirementName(toMatch[1]);
if (current.from && current.to) {
pairs.push({ from: current.from, to: current.to });
current = {};
}
}
}
return pairs;
}
+20
View File
@@ -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>;
+42
View File
@@ -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>;
+20
View File
@@ -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';
+17
View File
@@ -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>;
+1 -1
View File
@@ -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
+38
View File
@@ -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;
+19
View File
@@ -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;
};
}
+187
View File
@@ -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;
}
}
+328
View File
@@ -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);
}
});
});
});
+315 -15
View File
@@ -93,23 +93,37 @@ describe('ArchiveCommand', () => {
);
});
it('should update specs when archiving', async () => {
it('should update specs when archiving (delta-based ADDED) and include change name in skeleton', async () => {
const changeName = 'spec-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'test-capability');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create spec in change
const specContent = '# Test Capability Spec\n\nTest content';
// Create delta-based change spec (ADDED requirement)
const specContent = `# Test Capability Spec - Changes
## ADDED Requirements
### Requirement: 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
// Verify spec was created from skeleton and ADDED requirement applied
const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'test-capability', 'spec.md');
const copiedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(copiedContent).toBe(specContent);
const updatedContent = await fs.readFile(mainSpecPath, 'utf-8');
expect(updatedContent).toContain('# test-capability Specification');
expect(updatedContent).toContain('## Purpose');
expect(updatedContent).toContain(`created by archiving change ${changeName}`);
expect(updatedContent).toContain('## Requirements');
expect(updatedContent).toContain('### Requirement: The system SHALL provide test capability');
expect(updatedContent).toContain('#### Scenario: Basic test');
});
it('should throw error if change does not exist', async () => {
@@ -182,8 +196,8 @@ describe('ArchiveCommand', () => {
const specContent = '# Test Capability Spec\n\nTest content';
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
// Execute archive with --skip-specs flag
await archiveCommand.execute(changeName, { yes: true, skipSpecs: true });
// 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(
@@ -210,8 +224,20 @@ 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.
## 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)
@@ -241,6 +267,278 @@ describe('ArchiveCommand', () => {
expect(archives.length).toBe(1);
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
});
it('should support header trim-only normalization for matching', async () => {
const changeName = 'normalize-headers';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'alpha');
await fs.mkdir(changeSpecDir, { recursive: true });
// Create existing main spec with a requirement (no extra trailing spaces)
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'alpha');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# alpha Specification
## Purpose
Alpha purpose.
## Requirements
### Requirement: Important Rule
Some details.`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Change attempts to modify the same requirement but with trailing spaces after the name
const deltaContent = `# Alpha - Changes
## MODIFIED Requirements
### Requirement: Important Rule
Updated details.`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(updated).toContain('### Requirement: Important Rule');
expect(updated).toContain('Updated details.');
});
it('should apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED', async () => {
const changeName = 'apply-order';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'beta');
await fs.mkdir(changeSpecDir, { recursive: true });
// Main spec with two requirements A and B
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'beta');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# beta Specification
## Purpose
Beta purpose.
## Requirements
### Requirement: A
content A
### Requirement: B
content B`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Rename A->C, Remove B, Modify C, Add D
const deltaContent = `# Beta - Changes
## RENAMED Requirements
- FROM: \`### Requirement: A\`
- TO: \`### Requirement: C\`
## REMOVED Requirements
### Requirement: B
## MODIFIED Requirements
### Requirement: C
updated C
## ADDED Requirements
### Requirement: D
content D`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(updated).toContain('### Requirement: C');
expect(updated).toContain('updated C');
expect(updated).toContain('### Requirement: D');
expect(updated).not.toContain('### Requirement: A');
expect(updated).not.toContain('### Requirement: B');
});
it('should abort with error when MODIFIED/REMOVED reference non-existent requirements', async () => {
const changeName = 'validate-missing';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'gamma');
await fs.mkdir(changeSpecDir, { recursive: true });
// Main spec with no requirements
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'gamma');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# gamma Specification
## Purpose
Gamma purpose.
## Requirements`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Delta tries to modify and remove non-existent requirement
const deltaContent = `# Gamma - Changes
## MODIFIED Requirements
### Requirement: Missing
new text
## REMOVED Requirements
### Requirement: Another Missing`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaContent);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Should not change the main spec and should not archive the change dir
const still = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(still).toBe(mainContent);
// Change dir should still exist since operation aborted
await expect(fs.access(changeDir)).resolves.not.toThrow();
});
it('should require MODIFIED to reference the NEW header when a rename exists (error format)', async () => {
const changeName = 'rename-modify-new-header';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const changeSpecDir = path.join(changeDir, 'specs', 'delta');
await fs.mkdir(changeSpecDir, { recursive: true });
// Main spec with Old
const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'delta');
await fs.mkdir(mainSpecDir, { recursive: true });
const mainContent = `# delta Specification
## Purpose
Delta purpose.
## Requirements
### Requirement: Old
old body`;
await fs.writeFile(path.join(mainSpecDir, 'spec.md'), mainContent);
// Delta: rename Old->New, but MODIFIED references Old (should abort)
const badDelta = `# Delta - Changes
## RENAMED Requirements
- FROM: \`### Requirement: Old\`
- TO: \`### Requirement: New\`
## MODIFIED Requirements
### Requirement: Old
new body`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), badDelta);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const unchanged = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(unchanged).toBe(mainContent);
// Assert error message format and abort notice
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('delta validation failed')
);
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Aborted. No files were changed.')
);
// Fix MODIFIED to reference New (should succeed)
const goodDelta = `# Delta - Changes
## RENAMED Requirements
- FROM: \`### Requirement: Old\`
- TO: \`### Requirement: New\`
## MODIFIED Requirements
### Requirement: New
new body`;
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), goodDelta);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const updated = await fs.readFile(path.join(mainSpecDir, 'spec.md'), 'utf-8');
expect(updated).toContain('### Requirement: New');
expect(updated).toContain('new body');
expect(updated).not.toContain('### Requirement: Old');
});
it('should process multiple specs atomically (any failure aborts all)', async () => {
const changeName = 'multi-spec-atomic';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const spec1Dir = path.join(changeDir, 'specs', 'epsilon');
const spec2Dir = path.join(changeDir, 'specs', 'zeta');
await fs.mkdir(spec1Dir, { recursive: true });
await fs.mkdir(spec2Dir, { recursive: true });
// Existing main specs
const epsilonMain = path.join(tempDir, 'openspec', 'specs', 'epsilon', 'spec.md');
await fs.mkdir(path.dirname(epsilonMain), { recursive: true });
await fs.writeFile(epsilonMain, `# epsilon Specification
## Purpose
Epsilon purpose.
## Requirements
### Requirement: E1
e1`);
const zetaMain = path.join(tempDir, 'openspec', 'specs', 'zeta', 'spec.md');
await fs.mkdir(path.dirname(zetaMain), { recursive: true });
await fs.writeFile(zetaMain, `# zeta Specification
## Purpose
Zeta purpose.
## Requirements
### Requirement: Z1
z1`);
// Delta: epsilon is valid modification; zeta tries to remove non-existent -> should abort both
await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Epsilon - Changes
## MODIFIED Requirements
### Requirement: E1
E1 updated`);
await fs.writeFile(path.join(spec2Dir, 'spec.md'), `# Zeta - Changes
## REMOVED Requirements
### Requirement: Missing`);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
const e1 = await fs.readFile(epsilonMain, 'utf-8');
const z1 = await fs.readFile(zetaMain, 'utf-8');
expect(e1).toContain('### Requirement: E1');
expect(e1).not.toContain('E1 updated');
expect(z1).toContain('### Requirement: Z1');
// changeDir should still exist
await expect(fs.access(changeDir)).resolves.not.toThrow();
});
it('should display aggregated totals across multiple specs', async () => {
const changeName = 'multi-spec-totals';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
const spec1Dir = path.join(changeDir, 'specs', 'omega');
const spec2Dir = path.join(changeDir, 'specs', 'psi');
await fs.mkdir(spec1Dir, { recursive: true });
await fs.mkdir(spec2Dir, { recursive: true });
// Existing main specs
const omegaMain = path.join(tempDir, 'openspec', 'specs', 'omega', 'spec.md');
await fs.mkdir(path.dirname(omegaMain), { recursive: true });
await fs.writeFile(omegaMain, `# omega Specification\n\n## Purpose\nOmega purpose.\n\n## Requirements\n\n### Requirement: O1\no1`);
const psiMain = path.join(tempDir, 'openspec', 'specs', 'psi', 'spec.md');
await fs.mkdir(path.dirname(psiMain), { recursive: true });
await fs.writeFile(psiMain, `# psi Specification\n\n## Purpose\nPsi purpose.\n\n## Requirements\n\n### Requirement: P1\np1`);
// Deltas: omega add one, psi rename and modify -> totals: +1, ~1, -0, →1
await fs.writeFile(path.join(spec1Dir, 'spec.md'), `# Omega - Changes\n\n## ADDED Requirements\n\n### Requirement: O2\nnew`);
await fs.writeFile(path.join(spec2Dir, 'spec.md'), `# Psi - Changes\n\n## RENAMED Requirements\n- FROM: \`### Requirement: P1\`\n- TO: \`### Requirement: P2\`\n\n## MODIFIED Requirements\n### Requirement: P2\nupdated`);
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
// Verify aggregated totals line was printed
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Totals: + 1, ~ 1, - 0, → 1')
);
});
});
describe('error handling', () => {
@@ -323,11 +621,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;
}
});
});
+184
View File
@@ -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"');
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
import { promises as fs } from 'fs';
import os from 'os';
import { ChangeParser } from '../../../src/core/parsers/change-parser.js';
async function withTempDir(run: (dir: string) => Promise<void>) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-change-parser-'));
try {
await run(dir);
} finally {
// Best-effort cleanup
try { await fs.rm(dir, { recursive: true, force: true }); } catch {}
}
}
describe('ChangeParser', () => {
it('parses simple What Changes bullet list', async () => {
const content = `# Test Change\n\n## Why\nWe need it because reasons that are sufficiently long.\n\n## What Changes\n- **spec-a:** Add a new requirement to A\n- **spec-b:** Rename requirement X to Y\n- **spec-c:** Remove obsolete requirement`;
const parser = new ChangeParser(content, process.cwd());
const change = await parser.parseChangeWithDeltas('test-change');
expect(change.name).toBe('test-change');
expect(change.deltas.length).toBe(3);
expect(change.deltas[0].spec).toBe('spec-a');
expect(['ADDED', 'MODIFIED', 'REMOVED', 'RENAMED']).toContain(change.deltas[1].operation);
});
it('prefers delta-format specs over simple bullets when both exist', async () => {
await withTempDir(async (dir) => {
const changeDir = dir;
const specsDir = path.join(changeDir, 'specs', 'foo');
await fs.mkdir(specsDir, { recursive: true });
const content = `# Test Change\n\n## Why\nWe need it because reasons that are sufficiently long.\n\n## What Changes\n- **foo:** Add something via bullets (should be overridden)`;
const deltaSpec = `# Delta for Foo\n\n## ADDED Requirements\n\n### Requirement: New thing\n\n#### Scenario: basic\nGiven X\nWhen Y\nThen Z`;
await fs.writeFile(path.join(specsDir, 'spec.md'), deltaSpec, 'utf8');
const parser = new ChangeParser(content, changeDir);
const change = await parser.parseChangeWithDeltas('test-change');
expect(change.deltas.length).toBeGreaterThan(0);
// Since delta spec exists, the description should reflect delta-derived entries
expect(change.deltas[0].spec).toBe('foo');
expect(change.deltas[0].description).toContain('Add requirement:');
expect(change.deltas[0].operation).toBe('ADDED');
expect(change.deltas[0].requirement).toBeDefined();
});
});
});
+272
View File
@@ -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.');
});
});
});
+341
View File
@@ -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);
});
});
});