Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 52d620e40e fix invalid files 2025-08-20 01:41:36 +10:00
Tabish Bidiwale 22082338fd Merge pull request #46 from Fission-AI/feat/adopt-verb-noun-cli-structure
Adopt verb-noun CLI structure
2025-08-20 01:06:28 +10:00
Tabish Bidiwale 6458b6ed39 feat: adopt verb-noun CLI structure 2025-08-20 01:01:17 +10:00
Tabish Bidiwale 01a2f5d600 Merge pull request #45 from Fission-AI/feat/improve-validation-error-messages
feat(validate): improve error messages with actionable guidance
2025-08-20 01:00:14 +10:00
Tabish Bidiwale 95d855d641 feat(validate): improve error messages with actionable guidance 2025-08-20 00:54:34 +10:00
Tabish Bidiwale 562530dfa8 Merge pull request #44 from Fission-AI/feat/add-interactive-show-command
feat: add unified show command with interactive selection
2025-08-20 00:17:26 +10:00
Tabish Bidiwale 1e17cfdd0b Address review 2025-08-20 00:14:34 +10:00
Tabish Bidiwale 5d185ba3a8 feat: add unified show command with interactive selection 2025-08-20 00:06:19 +10:00
Tabish Bidiwale 08b41c7bea Merge pull request #43 from Fission-AI/feat/validate-command-interactive-selection
feat: add unified validate command with interactive selection and bulk operations
2025-08-19 23:23:11 +10:00
32 changed files with 1562 additions and 187 deletions
+3 -2
View File
@@ -11,10 +11,11 @@ if (existsSync('dist')) {
rmSync('dist', { recursive: true, force: true });
}
// Run TypeScript compiler
// Run TypeScript compiler (use local version explicitly)
console.log('Compiling TypeScript...');
try {
execSync('tsc', { stdio: 'inherit' });
execSync('./node_modules/.bin/tsc -v', { stdio: 'inherit' });
execSync('./node_modules/.bin/tsc', { stdio: 'inherit' });
console.log('\n✅ Build completed successfully!');
} catch (error) {
console.error('\n❌ Build failed!');
+58
View File
@@ -49,6 +49,64 @@ openspec/
│ └── archive/ # Completed changes (dated)
```
## CLI Usage: show command
Use the `show` command to display change proposals or specs with automatic detection and interactive selection.
- Interactive (no args, in a TTY):
```bash
openspec show
# → prompts to pick change/spec, then item
```
- Direct item (auto-detect type):
```bash
openspec show demo # shows change 'demo'
openspec show auth # shows spec 'auth'
```
- Disambiguation when names collide:
```bash
openspec show foo # if both change/spec exist → error suggests --type
openspec show foo --type spec # forces spec
openspec show foo --type change
```
- Common flags:
```bash
# JSON output (both types)
openspec show <item> --json
# Change-only flags
openspec show <change-id> --json --deltas-only
openspec show <change-id> --json --requirements-only # deprecated alias of --deltas-only
# Spec-only flags
openspec show <spec-id> --json --requirements
openspec show <spec-id> --json --no-scenarios
openspec show <spec-id> --json -r 1 # show requirement 1 only (1-based)
```
- Interactivity controls:
```bash
openspec show --no-interactive # never prompt
# or via env
OPEN_SPEC_INTERACTIVE=0 openspec show
```
- Backwards compatibility (subcommands also support interactive selection when no arg):
```bash
openspec change show [change-id] [--json] [--deltas-only]
openspec spec show [spec-id] [--json] [--requirements] [--no-scenarios] [-r N]
```
### Capability Organization
**Use capabilities, not features** - Each directory under `specs/` represents a single, focused responsibility:
@@ -0,0 +1,142 @@
# Implementation Tasks — Add Interactive Show Command
## Goals
- Add a top-level `show` command with intelligent selection and type detection.
- Add interactive selection to `change show` and `spec show` when no ID is provided.
- Preserve raw-first output behavior and existing JSON formats/filters.
- Respect `--no-interactive` and `OPEN_SPEC_INTERACTIVE=0` consistently.
---
## 1) CLI wiring
- [x] In `src/cli/index.ts` add a top-level command: `program.command('show [item-name]')`
- Options:
- `--json`
- `--type <type>` where `<type>` is `change|spec`
- `--no-interactive`
- Allow passing-through type-specific flags using `.allowUnknownOption(true)` so the top-level can forward flags to the underlying type handler.
- Action: instantiate `new ShowCommand().execute(itemName, options)`.
- [x] Update `change show` subcommand to accept `--no-interactive` and pass it to `ChangeCommand.show(...)`.
- [x] Change `spec show` subcommand to accept optional ID (`show [spec-id]`), add `--no-interactive`, and pass to spec show implementation.
Acceptance:
- `openspec show` exists and prints a helpful hint in non-interactive contexts when no args.
- Unknown flags for other types do not crash parsing; they are warned/ignored appropriately.
---
## 2) New module: `src/commands/show.ts`
- [x] Create `ShowCommand` with:
- `execute(itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any })`
- Interactive path when `!itemName` and interactive is enabled:
- Prompt: "What would you like to show?" → `change` or `spec`.
- Load available IDs for the chosen type and prompt selection.
- Delegate to type-specific show implementation.
- Non-interactive path when `!itemName`:
- Print hint with examples:
- `openspec show <item>`
- `openspec change show`
- `openspec spec show`
- Exit with code 1.
- Direct item path when `itemName` is provided:
- Type override via `--type` takes precedence.
- Otherwise detect using `getActiveChangeIds()` and `getSpecIds()`.
- If ambiguous and no override: print error + suggestion to pass `--type` or use subcommands; exit code 1.
- If unknown: print not-found with nearest-match suggestions; exit code 1.
- On success: delegate to type-specific show.
- [x] Flag scoping and pass-through:
- Common: `--json` → forwarded to both types.
- Change-only: `--deltas-only`, `--requirements-only` (deprecated alias).
- Spec-only: `--requirements`, `--no-scenarios`, `-r/--requirement`.
- Warn and ignore irrelevant flags for the resolved type.
Acceptance:
- `openspec show <change-id> --json --deltas-only` matches `openspec change show <id> --json --deltas-only` output.
- `openspec show <spec-id> --json --requirements` matches `openspec spec show <id> --json --requirements` output.
- Ambiguity and not-found behaviors match the `cli-show` spec.
---
## 3) Refactor spec show into reusable API
- [x] In `src/commands/spec.ts`, extract show logic into an exported `SpecCommand` with `show(specId?: string, options?: { json?: boolean; requirements?: boolean; scenarios?: boolean; requirement?: string; noInteractive?: boolean })`.
- Reuse current helpers (`parseSpecFromFile`, `filterSpec`, raw-first printing).
- Keep `registerSpecCommand` but delegate to `new SpecCommand().show(...)`.
- [x] Update CLI spec show subcommand to optional arg and interactive behavior (see section 4).
Acceptance:
- Existing `spec show` tests continue to pass.
- New `SpecCommand.show` can be called from `ShowCommand`.
---
## 4) Backwards-compatible interactive in subcommands
- [x] `src/commands/change.ts` → extend `show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean })`:
- When `!changeName` and interactive enabled: prompt from `getActiveChangeIds()` and show the selected change.
- Non-interactive fallback: keep current behavior (print available IDs + `openspec change list` hint, set `process.exitCode = 1`).
- [x] `src/commands/spec.ts` → `SpecCommand.show` as above:
- When `!specId` and interactive enabled: prompt from `getSpecIds()` and show the selected spec.
- Non-interactive fallback: print the same error as existing behavior for missing `<spec-id>` and set non-zero exit code.
Acceptance:
- `openspec change show` in non-interactive prints list hint and exits non-zero.
- `openspec spec show` in non-interactive prints missing-arg error and exits non-zero.
---
## 5) Shared utilities
- [x] Extract `nearestMatches` and `levenshtein` from `src/commands/validate.ts` into `src/utils/match.ts` (exported helpers).
- [x] Update `ValidateCommand` and new `ShowCommand` to import from `utils/match`.
Acceptance:
- Build succeeds with shared helpers and no duplication.
---
## 6) Hints, warnings, and messages
- [x] Top-level `show` hint (non-interactive no-arg):
- Lines include: `openspec show <item>`, `openspec change show`, `openspec spec show`, and "Or run in an interactive terminal.".
- [x] Ambiguity message suggests `--type change|spec` and the subcommands.
- [x] Not-found suggests nearest matches (up to 5).
- [x] Irrelevant flag warnings for the resolved type (printed to stderr, no crash).
Acceptance:
- Messages match the `cli-show` spec wording intent and style used elsewhere.
---
## 7) Tests
Add tests mirroring existing patterns (non-TTY simulation via `OPEN_SPEC_INTERACTIVE=0`).
- [x] `test/commands/show.test.ts`
- Non-interactive, no arg → prints hint and exits non-zero.
- Direct item detection for change and for spec.
- Ambiguity case when both exist → error and suggestion for `--type`.
- Not-found case → nearest-match suggestions.
- Pass-through flags: change `--json --deltas-only`, spec `--json --requirements`.
- [x] `test/commands/change.interactive-show.test.ts` (non-interactive fallback)
- Ensure `openspec change show` without args prints available IDs + list hint and non-zero exit.
- [x] `test/commands/spec.interactive-show.test.ts` (non-interactive fallback)
- Ensure `openspec spec show` without args prints missing-arg error and non-zero exit.
Acceptance:
- All new tests pass after build; no regressions in existing tests.
---
## 8) Documentation (optional but recommended)
- [x] Update `openspec/README.md` usage examples to include the new `show` command with type detection and flags.
---
## 9) Non-functional checks
- [x] Run `pnpm build` and all tests (`pnpm test`).
- [x] Ensure no linter/type errors and messages are consistent with existing style.
---
## Notes on consistency
- Follow raw-first behavior for text output: passthrough file content with no formatting, mirroring current `change show` and `spec show`.
- Reuse `isInteractive` and `item-discovery` helpers for consistent prompting behavior.
- Keep JSON output shapes identical to current `ChangeCommand.show` and `spec show` outputs.
@@ -0,0 +1,19 @@
# Design: Verb–Noun CLI Structure Adoption
## Overview
We will make verb commands (`list`, `show`, `validate`, `diff`, `archive`) the primary interface and keep noun commands (`spec`, `change`) as deprecated aliases for one release.
## Decisions
1. Keep routing centralized in `src/cli/index.ts`.
2. Add `--specs`/`--changes` to `openspec list`, with `--changes` as default.
3. Show deprecation warnings for `openspec change list` and, more generally, for any `openspec change ...` and `openspec spec ...` subcommands.
4. Do not change `show`/`validate` behavior beyond help text; they already support `--type` for disambiguation.
## Backward Compatibility
All noun-based commands continue to work with clear deprecation warnings directing users to verb-first equivalents.
## Out of Scope
JSON output parity for `openspec list` across modes and `show --specs/--changes` discovery are follow-ups.
@@ -0,0 +1,67 @@
# Change: Adopt Verb–Noun CLI Structure (Deprecate Noun-Based Commands)
## Why
Most widely used CLIs (git, docker, kubectl) start with an action (verb) followed by the object (noun). This matches how users think: “do X to Y”. Using verbs as top-level commands improves clarity, discoverability, and extensibility.
## What Changes
- Promote top-level verb commands as primary entry points: `list`, `show`, `validate`, `diff`, `archive`.
- Deprecate noun-based top-level commands: `openspec spec ...` and `openspec change ...`.
- Introduce consistent noun scoping via flags where applicable (e.g., `--changes`, `--specs`) and keep smart defaults.
- Clarify disambiguation for `show` and `validate` when names collide.
### Mappings (From → To)
- **List**
- From: `openspec change list`
- To: `openspec list --changes` (default), or `openspec list --specs`
- **Show**
- From: `openspec spec show <spec-id>` / `openspec change show <change-id>`
- To: `openspec show <item-id>` with auto-detect, use `--type spec|change` if ambiguous
- **Validate**
- From: `openspec spec validate <spec-id>` / `openspec change validate <change-id>`
- To: `openspec validate <item-id> --type spec|change`, or bulk: `openspec validate --specs` / `--changes` / `--all`
### Backward Compatibility
- Keep `openspec spec` and `openspec change` available with deprecation warnings for one release cycle.
- Update help text to point users to the verb–noun alternatives.
## Impact
- **Affected specs**:
- `cli-list`: Add support for `--specs` and explicit `--changes` (default remains changes)
- `openspec-conventions`: Add explicit requirement establishing verb–noun CLI design and deprecation guidance
- **Affected code**:
- `src/cli/index.ts`: Un-deprecate top-level `list`; mark `change list` as deprecated; ensure help text and warnings align
- `src/core/list.ts`: Support listing specs via `--specs` and default to changes; shared output shape
- Optional follow-ups: tighten `show`/`validate` help and ambiguity handling
## Explicit Changes
**CLI Design**
- From: Mixed model with nouns (`spec`, `change`) and some top-level verbs; `openspec list` currently deprecated
- To: Verbs as primary: `openspec list|show|validate|diff|archive`; nouns scoped via flags or item ids; noun commands deprecated
- Reason: Align with common CLIs; improve UX; simpler mental model
- Impact: Non-breaking with deprecation period; users migrate incrementally
**Listing Behavior**
- From: `openspec change list` (primary), `openspec list` (deprecated)
- To: `openspec list` as primary, defaulting to `--changes`; add `--specs` to list specs
- Reason: Consistent verb–noun style; better discoverability
- Impact: New option; preserves existing behavior via default
## Rollout and Deprecation Policy
- Show deprecation warnings on noun-based commands for one release.
- Document new usage in `openspec/README.md` and CLI help.
- After one release, consider removing noun-based commands, or keep as thin aliases without warnings.
## Open Questions
- Should `show` also accept `--changes`/`--specs` for discovery without an id? (Out of scope here; current auto-detect and `--type` remain.)
@@ -0,0 +1,59 @@
# Delta: CLI List Command
## MODIFIED Requirements
### Requirement: Command Execution
The command SHALL scan and analyze either active changes or specs based on the selected mode.
#### Scenario: Scanning for changes (default)
- **WHEN** `openspec list` is executed without flags
- **THEN** scan the `openspec/changes/` directory for change directories
- **AND** exclude the `archive/` subdirectory from results
- **AND** parse each change's `tasks.md` file to count task completion
#### Scenario: Scanning for specs
- **WHEN** `openspec list --specs` is executed
- **THEN** scan the `openspec/specs/` directory for capabilities
- **AND** read each capability's `spec.md`
- **AND** parse requirements to compute requirement counts
### Requirement: Output Format
The command SHALL display items in a clear, readable table format with mode-appropriate progress or counts.
#### Scenario: Displaying change list (default)
- **WHEN** displaying the list of changes
- **THEN** show a table with columns:
- Change name (directory name)
- Task progress (e.g., "3/5 tasks" or "✓ Complete")
#### Scenario: Displaying spec list
- **WHEN** displaying the list of specs
- **THEN** show a table with columns:
- Spec id (directory name)
- Requirement count (e.g., "requirements 12")
### Requirement: Empty State
The command SHALL provide clear feedback when no items are present for the selected mode.
#### Scenario: Handling empty state (changes)
- **WHEN** no active changes exist (only archive/ or empty changes/)
- **THEN** display: "No active changes found."
#### Scenario: Handling empty state (specs)
- **WHEN** no specs directory exists or contains no capabilities
- **THEN** display: "No specs found."
## ADDED Requirements
### Requirement: Flags
The command SHALL accept flags to select the noun being listed.
#### Scenario: Selecting specs
- **WHEN** `--specs` is provided
- **THEN** list specs instead of changes
#### Scenario: Selecting changes
- **WHEN** `--changes` is provided
- **THEN** list changes explicitly (same as default behavior)
@@ -0,0 +1,23 @@
# Delta: OpenSpec Conventions — Verb–Noun CLI Design
## ADDED Requirements
### Requirement: Verb–Noun CLI Command Structure
OpenSpec CLI design SHALL use verbs as top-level commands with nouns provided as arguments or flags for scoping.
#### Scenario: Verb-first command discovery
- **WHEN** a user runs a command like `openspec list`
- **THEN** the verb communicates the action clearly
- **AND** nouns refine scope via flags or arguments (e.g., `--changes`, `--specs`)
#### Scenario: Backward compatibility for noun commands
- **WHEN** users run noun-prefixed commands such as `openspec spec ...` or `openspec change ...`
- **THEN** the CLI SHALL continue to support them for at least one release
- **AND** display a deprecation warning that points to verb-first alternatives
#### Scenario: Disambiguation guidance
- **WHEN** item names are ambiguous between changes and specs
- **THEN** `openspec show` and `openspec validate` SHALL accept `--type spec|change`
- **AND** the help text SHALL document this clearly
@@ -0,0 +1,27 @@
# Implementation Tasks
## 1. CLI Behavior and Help
- [x] 1.1 Un-deprecate top-level `openspec list`; mark `change list` as deprecated with warning that points to `openspec list`
- [x] 1.2 Add support to list specs via `openspec list --specs` and keep `--changes` as default
- [x] 1.3 Update command descriptions and `--help` output to emphasize verb–noun pattern
- [x] 1.4 Keep `openspec spec ...` and `openspec change ...` commands working but print deprecation notices
## 2. Core List Logic
- [x] 2.1 Extend `src/core/list.ts` to accept a mode: `changes` (default) or `specs`
- [x] 2.2 Implement `specs` listing: scan `openspec/specs/*/spec.md`, compute requirement count via parser, format output consistently
- [x] 2.3 Share output structure for both modes; preserve current text table; ensure JSON parity in future change
## 3. Specs and Conventions
- [x] 3.1 Update `openspec/specs/cli-list/spec.md` to document `--specs` (and default to changes)
- [x] 3.2 Update `openspec/specs/openspec-conventions/spec.md` with a requirement for verb–noun CLI design and deprecation guidance
## 4. Tests and Docs
- [x] 4.1 Update tests: ensure `openspec list` works for changes and specs; keep `change list` tests but assert warning
- [ ] 4.2 Update README and any usage docs to show new primary commands
- [ ] 4.3 Add migration notes in repo CHANGELOG or README
## 5. Follow-ups (Optional, not in this change)
- [ ] 5.1 Consider `openspec show --specs/--changes` for discovery without ids
- [ ] 5.2 Consider JSON output for `openspec list` with `--json` for both modes
@@ -106,6 +106,8 @@ Where `Issue` follows the existing per-item validation report shape `{ level: "E
### Requirement: Item type detection and ambiguity handling
The validate command SHALL handle ambiguous names and explicit type overrides to ensure clear, deterministic behavior.
#### Scenario: Direct item validation with automatic type detection
- **WHEN** executing `openspec validate <item-name>`
@@ -138,4 +140,10 @@ Where `Issue` follows the existing per-item validation report shape `{ level: "E
- The CLI SHALL respect `--no-interactive` to disable prompts.
- The CLI SHALL respect `OPEN_SPEC_INTERACTIVE=0` to disable prompts globally.
- Interactive prompts SHALL only be shown when stdin is a TTY and interactivity is not disabled.
- Interactive prompts SHALL only be shown when stdin is a TTY and interactivity is not disabled.
#### Scenario: Disabling prompts via flags or environment
- **WHEN** `openspec validate` is executed with `--no-interactive` or with environment `OPEN_SPEC_INTERACTIVE=0`
- **THEN** the CLI SHALL not display interactive prompts
- **AND** SHALL print non-interactive hints or chosen outputs as appropriate
@@ -0,0 +1,25 @@
# improve-validate-error-messages
## Why
Developers struggle to resolve validation failures because current errors lack actionable guidance. Common issues include: missing deltas, missing required sections, and misformatted scenarios that are silently ignored. Without clear remediation steps, users cannot quickly correct structure or formatting, leading to frustration and rework. Improving error messages with concrete fixes, file/section hints, and suggested commands will significantly reduce time-to-green and make OpenSpec more approachable.
## What Changes
- Validation errors SHALL include specific remediation steps (what to change and where).
- "No deltas found" error SHALL guide users to create `specs/` with proper delta headers and suggest debug commands.
- Missing required sections (Spec: Purpose/Requirements; Change: Why/What Changes) SHALL include expected header names and a minimal skeleton example.
- Likely misformatted scenarios (bulleted WHEN/THEN/AND) SHALL emit a targeted warning explaining the `#### Scenario:` format and show a conversion template.
- All reported issues SHALL include the source file path and structured location (e.g., `deltas[0].requirements[0]`).
- Non-JSON output SHOULD end with a short "Next steps" footer when invalid.
## Impact
- Affected CLI: validate
- Affected code:
- `src/commands/validate.ts`
- `src/core/validation/validator.ts`
- `src/core/validation/constants.ts`
- `src/core/parsers/*` (wrapping thrown errors with richer context)
@@ -0,0 +1,55 @@
# Validate Command
## ADDED Requirements
### Requirement: Validation SHALL provide actionable remediation steps
Validation output SHALL include specific guidance to fix each error, including expected structure, example headers, and suggested commands to verify fixes.
#### Scenario: No deltas found in change
- **WHEN** validating a change with zero parsed deltas
- **THEN** show error "No deltas found" with guidance:
- Ensure `openspec/changes/{id}/specs/` exists with `.md` files
- Use delta headers: `## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, `## RENAMED Requirements`
- Each requirement must include at least one `#### Scenario:` block
- Try: `openspec change show {id} --json --deltas-only` to inspect what was parsed
#### Scenario: Missing required sections
- **WHEN** a required section is missing
- **THEN** the validator SHALL include expected header names and a minimal skeleton:
- For Spec: `## Purpose`, `## Requirements`
- For Change: `## Why`, `## What Changes`
- Show an example snippet of the missing section
### Requirement: Validator SHALL detect likely misformatted scenarios and warn with a fix
The validator SHALL recognize bulleted lines that look like scenarios (e.g., lines beginning with WHEN/THEN/AND) and emit a targeted warning with a conversion example to `#### Scenario:`.
#### Scenario: Bulleted WHEN/THEN under a Requirement
- **WHEN** bullets that start with WHEN/THEN/AND are found under a requirement without any `#### Scenario:` headers
- **THEN** emit warning: "Scenarios must use '#### Scenario:' headers", and show a conversion template:
```
#### Scenario: Short name
- **WHEN** ...
- **THEN** ...
- **AND** ...
```
### Requirement: All issues SHALL include file paths and structured locations
Error, warning, and info messages SHALL include:
- Source file path (`openspec/changes/{id}/proposal.md`, `.../specs/{cap}/spec.md`)
- Structured path (e.g., `deltas[0].requirements[0].scenarios`)
#### Scenario: Zod validation error
- **WHEN** a schema validation fails
- **THEN** the message SHALL include `file`, `path`, and a remediation hint if applicable
### Requirement: Invalid results SHALL include a Next steps footer in human-readable output
The CLI SHALL append a Next steps footer when the item is invalid and not using `--json`, including:
- Summary line with counts
- Top-3 guidance bullets (contextual to the most frequent or blocking errors)
- A suggestion to re-run with `--json` and/or the debug command
#### Scenario: Change invalid summary
- **WHEN** a change validation fails
- **THEN** print "Next steps" with 2-3 targeted bullets and suggest `openspec change show <id> --json --deltas-only`
@@ -0,0 +1,21 @@
## 1. Enhance validation messages
- [x] 1.1 Add remediation guidance for "No deltas found"
- [x] 1.2 Include file path and structured path in all issues
- [x] 1.3 Improve messages for missing required sections (Spec, Change)
- [x] 1.4 Detect likely misformatted scenarios and warn with conversion example
- [x] 1.5 Add "Next steps" footer for non-JSON invalid output
## 2. Update constants and helpers
- [x] 2.1 Centralize guidance snippets in `VALIDATION_MESSAGES`
- [x] 2.2 Provide minimal skeleton examples for missing sections
## 3. Parser integration
- [x] 3.1 Capture parser-thrown errors and wrap with richer context
- [x] 3.2 Add file/section references to surfaced parser errors
## 4. Tests
- [x] 4.1 Unit tests for validator message composition
- [x] 4.2 CLI integration tests for human-readable output (with footer)
- [x] 4.3 JSON mode tests (structure unchanged, content enriched)
-2
View File
@@ -129,8 +129,6 @@ The spec update confirmation SHALL provide clear visibility into changes before
- **AND** display message: "Archive cancelled. No changes were made."
- **AND** exit with non-zero status code
## Error Handling
### Requirement: Error Conditions
The command SHALL handle various error conditions gracefully.
+38 -5
View File
@@ -8,15 +8,22 @@ The `openspec list` command SHALL provide developers with a quick overview of al
### Requirement: Command Execution
The command SHALL scan and analyze all active changes to provide a comprehensive overview.
The command SHALL scan and analyze items based on the selected mode to provide a comprehensive overview.
#### Scenario: Scanning for changes
- **WHEN** `openspec list` is executed
- **WHEN** `openspec list` is executed (default)
- **THEN** scan the `openspec/changes/` directory for change directories
- **AND** exclude the `archive/` subdirectory from results
- **AND** parse each change's `tasks.md` file to count task completion
#### Scenario: Scanning for specs
- **WHEN** `openspec list --specs` is executed
- **THEN** scan the `openspec/specs/` directory for capability directories
- **AND** read each capability's `spec.md`
- **AND** parse the requirements to compute requirement counts
### Requirement: Task Counting
The command SHALL accurately count task completion status using standard markdown checkbox patterns.
@@ -31,7 +38,7 @@ The command SHALL accurately count task completion status using standard markdow
### Requirement: Output Format
The command SHALL display changes in a clear, readable table format with progress indicators.
The command SHALL display items in a clear, readable table format with mode-appropriate indicators.
#### Scenario: Displaying change list
@@ -39,6 +46,13 @@ The command SHALL display changes in a clear, readable table format with progres
- **THEN** show a table with columns:
- Change name (directory name)
- Task progress (e.g., "3/5 tasks" or "✓ Complete")
#### Scenario: Displaying spec list
- **WHEN** displaying the list for specs mode
- **THEN** show a table with columns:
- Spec id (directory name)
- Requirement count (e.g., "requirements 12")
- **AND** use status indicators:
- `✓` for fully completed changes (all tasks done)
- Progress fraction for partial completion
@@ -52,15 +66,34 @@ Changes:
add-list-command 1/4 tasks
```
### Requirement: Flags
The command SHALL accept flags to choose the noun being listed.
#### Scenario: Selecting specs
- **WHEN** `--specs` is provided
- **THEN** list specs instead of changes
#### Scenario: Selecting changes
- **WHEN** `--changes` is provided
- **THEN** list changes explicitly (same as default behavior)
### Requirement: Empty State
The command SHALL provide clear feedback when no active changes are present.
The command SHALL provide clear feedback when no items are present for the selected mode.
#### Scenario: Handling empty state
#### Scenario: Handling empty state (changes)
- **WHEN** no active changes exist (only archive/ or empty changes/)
- **THEN** display: "No active changes found."
#### Scenario: Handling empty state (specs)
- **WHEN** no specs exist or the `openspec/specs/` directory is missing
- **THEN** display: "No specs found."
### Requirement: Error Handling
The command SHALL gracefully handle missing files and directories with appropriate messages.
+1 -1
View File
@@ -4,7 +4,7 @@
As a developer using OpenSpec, I want to update the OpenSpec instructions in my project when new versions are released, so that I can benefit from improvements to AI agent instructions.
## Core Requirements
## Requirements
### Requirement: Update Behavior
+185
View File
@@ -4,6 +4,191 @@
OpenSpec conventions SHALL define how system capabilities are documented, how changes are proposed and tracked, and how specifications evolve over time. This meta-specification serves as the source of truth for OpenSpec's own conventions.
## Requirements
### Requirement: Structured conventions for specs and changes
OpenSpec conventions SHALL mandate a structured spec format with clear requirement and scenario sections so tooling can parse consistently.
#### Scenario: Following the structured spec format
- **WHEN** writing or updating OpenSpec specifications
- **THEN** authors SHALL use `### Requirement: ...` followed by at least one `#### Scenario: ...` section
### Requirement: Project Structure
An OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.
#### Scenario: Initializing project structure
- **WHEN** an OpenSpec project is initialized
- **THEN** it SHALL have this structure:
```
openspec/
├── project.md # Project-specific context
├── README.md # AI assistant instructions
├── specs/ # Current deployed capabilities
│ └── [capability]/ # Single, focused capability
│ ├── spec.md # WHAT and WHY
│ └── design.md # HOW (optional, for established patterns)
└── changes/ # Proposed changes
├── [change-name]/ # Descriptive change identifier
│ ├── proposal.md # Why, what, and impact
│ ├── tasks.md # Implementation checklist
│ ├── design.md # Technical decisions (optional)
│ └── specs/ # Complete future state
│ └── [capability]/
│ └── spec.md # Clean markdown (no diff syntax)
└── archive/ # Completed changes
└── YYYY-MM-DD-[name]/
```
### Requirement: Structured Format for Behavioral Specs
Behavioral specifications SHALL use a structured format with consistent section headers and keywords to ensure visual consistency and parseability.
#### Scenario: Writing requirement sections
- **WHEN** documenting a requirement in a behavioral specification
- **THEN** use a level-3 heading with format `### Requirement: [Name]`
- **AND** immediately follow with a SHALL statement describing core behavior
- **AND** keep requirement names descriptive and under 50 characters
#### Scenario: Documenting scenarios
- **WHEN** documenting specific behaviors or use cases
- **THEN** use level-4 headings with format `#### Scenario: [Description]`
- **AND** use bullet points with bold keywords for steps:
- **GIVEN** for initial state (optional)
- **WHEN** for conditions or triggers
- **THEN** for expected outcomes
- **AND** for additional outcomes or conditions
#### Scenario: Adding implementation details
- **WHEN** a step requires additional detail
- **THEN** use sub-bullets under the main step
- **AND** maintain consistent indentation
- Sub-bullets provide examples or specifics
- Keep sub-bullets concise
### Requirement: Header-Based Requirement Identification
Requirement headers SHALL serve as unique identifiers for programmatic matching between current specs and proposed changes.
#### Scenario: Matching requirements programmatically
- **WHEN** processing delta changes
- **THEN** use the `### Requirement: [Name]` header as the unique identifier
- **AND** match using normalized headers: `normalize(header) = trim(header)`
- **AND** compare headers with case-sensitive equality after normalization
#### Scenario: Handling requirement renames
- **WHEN** renaming a requirement
- **THEN** use a special `## RENAMED Requirements` section
- **AND** specify both old and new names explicitly:
```markdown
## RENAMED Requirements
- FROM: `### Requirement: Old Name`
- TO: `### Requirement: New Name`
```
- **AND** if content also changes, include under MODIFIED using the NEW header
#### Scenario: Validating header uniqueness
- **WHEN** creating or modifying requirements
- **THEN** ensure no duplicate headers exist within a spec
- **AND** validation tools SHALL flag duplicate headers as errors
### Requirement: Change Storage Convention
Change proposals SHALL store only the additions, modifications, and removals to specifications, not complete future states.
#### Scenario: Creating change proposals with additions
- **WHEN** creating a change proposal that adds new requirements
- **THEN** include only the new requirements under `## ADDED Requirements`
- **AND** each requirement SHALL include its complete content
- **AND** use the standard structured format for requirements and scenarios
#### Scenario: Creating change proposals with modifications
- **WHEN** creating a change proposal that modifies existing requirements
- **THEN** include the modified requirements under `## MODIFIED Requirements`
- **AND** use the same header text as in the current spec (normalized)
- **AND** include the complete modified requirement (not a diff)
- **AND** optionally annotate what changed with inline comments like `← (was X)`
#### Scenario: Creating change proposals with removals
- **WHEN** creating a change proposal that removes requirements
- **THEN** list them under `## REMOVED Requirements`
- **AND** use the normalized header text for identification
- **AND** include reason for removal
- **AND** document any migration path if applicable
The `changes/[name]/specs/` directory SHALL contain:
- Delta files showing only what changes
- Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements
- Normalized header matching for requirement identification
- Complete requirements using the structured format
- Clear indication of change type for each requirement
### Requirement: Archive Process Enhancement
The archive process SHALL programmatically apply delta changes to current specifications using header-based matching.
#### Scenario: Archiving changes with deltas
- **WHEN** archiving a completed change
- **THEN** the archive command SHALL:
1. Parse RENAMED sections first and apply renames
2. Parse REMOVED sections and remove by normalized header match
3. Parse MODIFIED sections and replace by normalized header match (using new names if renamed)
4. Parse ADDED sections and append new requirements
- **AND** validate that all MODIFIED/REMOVED headers exist in current spec
- **AND** validate that ADDED headers don't already exist
- **AND** generate the updated spec in the main specs/ directory
#### Scenario: Handling conflicts during archive
- **WHEN** delta changes conflict with current spec state
- **THEN** the archive command SHALL report specific conflicts
- **AND** require manual resolution before proceeding
- **AND** provide clear guidance on resolving conflicts
### Requirement: Proposal Format
Proposals SHALL explicitly document all changes with clear from/to comparisons.
#### Scenario: Documenting changes
- **WHEN** documenting what changes
- **THEN** the proposal SHALL explicitly describe each change:
```markdown
**[Section or Behavior Name]**
- From: [current state/requirement]
- To: [future state/requirement]
- Reason: [why this change is needed]
- Impact: [breaking/non-breaking, who's affected]
```
This explicit format compensates for not having inline diffs and ensures reviewers understand exactly what will change.
### Requirement: Change Review
The system SHALL support multiple methods for reviewing proposed changes.
#### Scenario: Reviewing changes
- **WHEN** reviewing proposed changes
- **THEN** reviewers can compare using:
- GitHub PR diff view when changes are committed
- Command line: `diff -u specs/[capability]/spec.md changes/[name]/specs/[capability]/spec.md`
- Any visual diff tool comparing current vs future state
## Core Principles
The system SHALL follow these principles:
+43 -6
View File
@@ -10,6 +10,7 @@ import { ArchiveCommand } from '../core/archive.js';
import { registerSpecCommand } from '../commands/spec.js';
import { ChangeCommand } from '../commands/change.js';
import { ValidateCommand } from '../commands/validate.js';
import { ShowCommand } from '../commands/show.js';
const program = new Command();
@@ -93,12 +94,14 @@ program
program
.command('list')
.description('List all active changes with their task status (DEPRECATED: use "openspec change list" instead)')
.action(async () => {
.description('List items (changes by default). Use --specs to list specs.')
.option('--specs', 'List specs instead of changes')
.option('--changes', 'List changes explicitly (default)')
.action(async (options?: { specs?: boolean; changes?: boolean }) => {
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();
const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes';
await listCommand.execute('.', mode);
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
@@ -111,13 +114,19 @@ const changeCmd = program
.command('change')
.description('Manage OpenSpec change proposals');
// Deprecation notice for noun-based commands
changeCmd.hook('preAction', () => {
console.error('Warning: The "openspec change ..." commands are deprecated. Prefer verb-first commands (e.g., "openspec list", "openspec validate --changes").');
});
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 }) => {
.option('--no-interactive', 'Disable interactive prompts')
.action(async (changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }) => {
try {
const changeCommand = new ChangeCommand();
await changeCommand.show(changeName, options);
@@ -129,11 +138,12 @@ changeCmd
changeCmd
.command('list')
.description('List all active changes')
.description('List all active changes (DEPRECATED: use "openspec list" instead)')
.option('--json', 'Output as JSON')
.option('--long', 'Show id and title with counts')
.action(async (options?: { json?: boolean; long?: boolean }) => {
try {
console.error('Warning: "openspec change list" is deprecated. Use "openspec list".');
const changeCommand = new ChangeCommand();
await changeCommand.list(options);
} catch (error) {
@@ -200,4 +210,31 @@ program
}
});
// Top-level show command
program
.command('show [item-name]')
.description('Show a change or spec')
.option('--json', 'Output as JSON')
.option('--type <type>', 'Specify item type when ambiguous: change|spec')
.option('--no-interactive', 'Disable interactive prompts')
// change-only flags
.option('--deltas-only', 'Show only deltas (JSON only, change)')
.option('--requirements-only', 'Alias for --deltas-only (deprecated, change)')
// spec-only flags
.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)')
// allow unknown options to pass-through to underlying command implementation
.allowUnknownOption(true)
.action(async (itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any }) => {
try {
const showCommand = new ShowCommand();
await showCommand.execute(itemName, options ?? {});
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
program.parse();
+16 -7
View File
@@ -26,19 +26,28 @@ export class ChangeCommand {
* - 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> {
async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }): Promise<void> {
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
if (!changeName) {
const canPrompt = isInteractive(options?.noInteractive);
const changes = await this.getActiveChanges(changesPath);
if (changes.length === 0) {
console.error('No change specified. No active changes found.');
if (canPrompt && changes.length > 0) {
const selected = await select({
message: 'Select a change to show',
choices: changes.map(id => ({ name: id, value: id })),
});
changeName = selected;
} else {
console.error(`No change specified. Available IDs: ${changes.join(', ')}`);
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;
}
console.error('Hint: use "openspec change list" to view available changes.');
process.exitCode = 1;
return;
}
const proposalPath = path.join(changesPath, changeName, 'proposal.md');
+139
View File
@@ -0,0 +1,139 @@
import { select } from '@inquirer/prompts';
import path from 'path';
import { isInteractive } from '../utils/interactive.js';
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
import { ChangeCommand } from './change.js';
import { SpecCommand } from './spec.js';
import { nearestMatches } from '../utils/match.js';
type ItemType = 'change' | 'spec';
const CHANGE_FLAG_KEYS = new Set(['deltasOnly', 'requirementsOnly']);
const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']);
export class ShowCommand {
async execute(itemName?: string, options: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any } = {}): Promise<void> {
const interactive = isInteractive(options.noInteractive);
const typeOverride = this.normalizeType(options.type);
if (!itemName) {
if (interactive) {
const type = await select<ItemType>({
message: 'What would you like to show?',
choices: [
{ name: 'Change', value: 'change' as const },
{ name: 'Spec', value: 'spec' as const },
],
});
await this.runInteractiveByType(type, options);
return;
}
this.printNonInteractiveHint();
process.exitCode = 1;
return;
}
await this.showDirect(itemName, { typeOverride, options });
}
private normalizeType(value?: string): ItemType | undefined {
if (!value) return undefined;
const v = value.toLowerCase();
if (v === 'change' || v === 'spec') return v;
return undefined;
}
private async runInteractiveByType(type: ItemType, options: { json?: boolean; noInteractive?: boolean; [k: string]: any }): Promise<void> {
if (type === 'change') {
const changes = await getActiveChangeIds();
if (changes.length === 0) {
console.error('No changes found.');
process.exitCode = 1;
return;
}
const picked = await select<string>({ message: 'Pick a change', choices: changes.map(id => ({ name: id, value: id })) });
const cmd = new ChangeCommand();
await cmd.show(picked, options as any);
return;
}
const specs = await getSpecIds();
if (specs.length === 0) {
console.error('No specs found.');
process.exitCode = 1;
return;
}
const picked = await select<string>({ message: 'Pick a spec', choices: specs.map(id => ({ name: id, value: id })) });
const cmd = new SpecCommand();
await cmd.show(picked, options as any);
}
private async showDirect(itemName: string, params: { typeOverride?: ItemType; options: { json?: boolean; [k: string]: any } }): Promise<void> {
// Optimize lookups when type is pre-specified
let isChange = false;
let isSpec = false;
let changes: string[] = [];
let specs: string[] = [];
if (params.typeOverride === 'change') {
changes = await getActiveChangeIds();
isChange = changes.includes(itemName);
} else if (params.typeOverride === 'spec') {
specs = await getSpecIds();
isSpec = specs.includes(itemName);
} else {
[changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);
isChange = changes.includes(itemName);
isSpec = specs.includes(itemName);
}
const resolvedType = params.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined);
if (!resolvedType) {
console.error(`Unknown item '${itemName}'`);
const suggestions = nearestMatches(itemName, [...changes, ...specs]);
if (suggestions.length) console.error(`Did you mean: ${suggestions.join(', ')}?`);
process.exitCode = 1;
return;
}
if (!params.typeOverride && isChange && isSpec) {
console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`);
console.error('Pass --type change|spec, or use: openspec change show / openspec spec show');
process.exitCode = 1;
return;
}
this.warnIrrelevantFlags(resolvedType, params.options);
if (resolvedType === 'change') {
const cmd = new ChangeCommand();
await cmd.show(itemName, params.options as any);
return;
}
const cmd = new SpecCommand();
await cmd.show(itemName, params.options as any);
}
private printNonInteractiveHint(): void {
console.error('Nothing to show. Try one of:');
console.error(' openspec show <item>');
console.error(' openspec change show');
console.error(' openspec spec show');
console.error('Or run in an interactive terminal.');
}
private warnIrrelevantFlags(type: ItemType, options: { [k: string]: any }): boolean {
const irrelevant: string[] = [];
if (type === 'change') {
for (const k of SPEC_FLAG_KEYS) if (k in options) irrelevant.push(k);
} else {
for (const k of CHANGE_FLAG_KEYS) if (k in options) irrelevant.push(k);
}
if (irrelevant.length > 0) {
console.error(`Warning: Ignoring flags not applicable to ${type}: ${irrelevant.join(', ')}`);
return true;
}
return false;
}
}
+107 -80
View File
@@ -10,99 +10,126 @@ import { getSpecIds } from '../utils/item-discovery.js';
const SPECS_DIR = 'openspec/specs';
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
noInteractive?: boolean;
}
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);
}
export class SpecCommand {
private SPECS_DIR = 'openspec/specs';
async show(specId?: string, options: ShowOptions = {}): Promise<void> {
if (!specId) {
const canPrompt = isInteractive(options?.noInteractive);
const specIds = await getSpecIds();
if (canPrompt && specIds.length > 0) {
specId = await select({
message: 'Select a spec to show',
choices: specIds.map(id => ({ name: id, value: id })),
});
} else {
throw new Error('Missing required argument <spec-id>');
}
}
const specPath = join(this.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));
return;
}
printSpecTextRaw(specPath);
}
}
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);
}
// Deprecation notice for noun-based commands
specCommand.hook('preAction', () => {
console.error('Warning: The "openspec spec ..." commands are deprecated. Prefer verb-first commands (e.g., "openspec show", "openspec validate --specs").');
});
specCommand
.command('show <spec-id>')
.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) => {
.option('--no-interactive', 'Disable interactive prompts')
.action(async (specId: string | undefined, options: ShowOptions & { noInteractive?: 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`);
}
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);
}
const cmd = new SpecCommand();
await cmd.show(specId, options as any);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
process.exitCode = 1;
+17 -25
View File
@@ -4,6 +4,7 @@ import path from 'path';
import { Validator } from '../core/validation/validator.js';
import { isInteractive } from '../utils/interactive.js';
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
import { nearestMatches } from '../utils/match.js';
type ItemType = 'change' | 'spec';
@@ -159,9 +160,25 @@ export class ValidateCommand {
const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`);
}
this.printNextSteps(type);
}
}
private printNextSteps(type: ItemType): void {
const bullets: string[] = [];
if (type === 'change') {
bullets.push('- Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements');
bullets.push('- Each requirement MUST include at least one #### Scenario: block');
bullets.push('- Debug parsed deltas: openspec change show <id> --json --deltas-only');
} else {
bullets.push('- Ensure spec includes ## Purpose and ## Requirements sections');
bullets.push('- Each requirement MUST include at least one #### Scenario: block');
bullets.push('- Re-run with --json to see structured report');
}
console.error('Next steps:');
bullets.forEach(b => console.error(` ${b}`));
}
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
const spinner = !opts.json ? ora('Validating...').start() : undefined;
const [changeIds, specIds] = await Promise.all([
@@ -262,31 +279,6 @@ function summarizeType(results: BulkItemResult[], type: ItemType) {
return { items, passed, failed };
}
function nearestMatches(input: string, candidates: string[], max: number = 5): string[] {
const scored = candidates.map(c => ({ c, d: levenshtein(input, c) }));
scored.sort((a, b) => a.d - b.d);
return scored.slice(0, max).map(s => s.c);
}
function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
);
}
}
return dp[m][n];
}
function normalizeConcurrency(value?: string): number | undefined {
if (!value) return undefined;
const n = parseInt(value, 10);
+82 -38
View File
@@ -1,6 +1,9 @@
import { promises as fs } from 'fs';
import path from 'path';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
import { readFileSync } from 'fs';
import { join } from 'path';
import { MarkdownParser } from './parsers/markdown-parser.js';
interface ChangeInfo {
name: string;
@@ -9,52 +12,93 @@ interface ChangeInfo {
}
export class ListCommand {
async execute(targetPath: string = '.'): Promise<void> {
const changesDir = path.join(targetPath, 'openspec', 'changes');
// Check if changes directory exists
try {
await fs.access(changesDir);
} catch {
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
}
async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes'): Promise<void> {
if (mode === 'changes') {
const changesDir = path.join(targetPath, 'openspec', 'changes');
// Check if changes directory exists
try {
await fs.access(changesDir);
} catch {
throw new Error("No OpenSpec changes directory found. Run 'openspec init' first.");
}
// Get all directories in changes (excluding archive)
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const changeDirs = entries
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
.map(entry => entry.name);
// Get all directories in changes (excluding archive)
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const changeDirs = entries
.filter(entry => entry.isDirectory() && entry.name !== 'archive')
.map(entry => entry.name);
if (changeDirs.length === 0) {
console.log('No active changes found.');
if (changeDirs.length === 0) {
console.log('No active changes found.');
return;
}
// Collect information about each change
const changes: ChangeInfo[] = [];
for (const changeDir of changeDirs) {
const progress = await getTaskProgressForChange(changesDir, changeDir);
changes.push({
name: changeDir,
completedTasks: progress.completed,
totalTasks: progress.total
});
}
// Sort alphabetically by name
changes.sort((a, b) => a.name.localeCompare(b.name));
// Display results
console.log('Changes:');
const padding = ' ';
const nameWidth = Math.max(...changes.map(c => c.name.length));
for (const change of changes) {
const paddedName = change.name.padEnd(nameWidth);
const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
console.log(`${padding}${paddedName} ${status}`);
}
return;
}
// Collect information about each change
const changes: ChangeInfo[] = [];
for (const changeDir of changeDirs) {
const progress = await getTaskProgressForChange(changesDir, changeDir);
changes.push({
name: changeDir,
completedTasks: progress.completed,
totalTasks: progress.total
});
// specs mode
const specsDir = path.join(targetPath, 'openspec', 'specs');
try {
await fs.access(specsDir);
} catch {
console.log('No specs found.');
return;
}
// Sort alphabetically by name
changes.sort((a, b) => a.name.localeCompare(b.name));
const entries = await fs.readdir(specsDir, { withFileTypes: true });
const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name);
if (specDirs.length === 0) {
console.log('No specs found.');
return;
}
// Display results
console.log('Changes:');
for (const change of changes) {
const padding = ' ';
const nameWidth = Math.max(...changes.map(c => c.name.length));
const paddedName = change.name.padEnd(nameWidth);
const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
console.log(`${padding}${paddedName} ${status}`);
type SpecInfo = { id: string; requirementCount: number };
const specs: SpecInfo[] = [];
for (const id of specDirs) {
const specPath = join(specsDir, id, 'spec.md');
try {
const content = readFileSync(specPath, 'utf-8');
const parser = new MarkdownParser(content);
const spec = parser.parseSpec(id);
specs.push({ id, requirementCount: spec.requirements.length });
} catch {
// If spec cannot be read or parsed, include with 0 count
specs.push({ id, requirementCount: 0 });
}
}
specs.sort((a, b) => a.id.localeCompare(b.id));
console.log('Specs:');
const padding = ' ';
const nameWidth = Math.max(...specs.map(s => s.id.length));
for (const spec of specs) {
const padded = spec.id.padEnd(nameWidth);
console.log(`${padding}${padded} requirements ${spec.requirementCount}`);
}
}
}
+10
View File
@@ -35,4 +35,14 @@ export const VALIDATION_MESSAGES = {
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',
// Guidance snippets (appended to primary messages for remediation)
GUIDE_NO_DELTAS:
'No deltas found. Ensure your change has a specs/ directory with .md files using delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. Tip: run "openspec change show <change-id> --json --deltas-only" to inspect parsed deltas.',
GUIDE_MISSING_SPEC_SECTIONS:
'Missing required sections. Expected headers: "## Purpose" and "## Requirements". Example:\n## Purpose\n[brief purpose]\n\n## Requirements\n### Requirement: Clear requirement statement\nUsers SHALL ...\n\n#### Scenario: Descriptive name\n- **WHEN** ...\n- **THEN** ...',
GUIDE_MISSING_CHANGE_SECTIONS:
'Missing required sections. Expected headers: "## Why" and "## What Changes". Ensure deltas are documented in specs/ using delta headers.',
GUIDE_SCENARIO_FORMAT:
'Scenarios must use level-4 headers. Convert bullet lists into:\n#### Scenario: Short name\n- **WHEN** ...\n- **THEN** ...\n- **AND** ...',
} as const;
+34 -12
View File
@@ -20,11 +20,10 @@ export class Validator {
async validateSpec(filePath: string): Promise<ValidationReport> {
const issues: ValidationIssue[] = [];
const specName = this.extractNameFromPath(filePath);
try {
const content = readFileSync(filePath, 'utf-8');
const parser = new MarkdownParser(content);
const specName = this.extractNameFromPath(filePath);
const spec = parser.parseSpec(specName);
@@ -37,10 +36,12 @@ export class Validator {
issues.push(...this.applySpecRules(spec, content));
} catch (error) {
const baseMessage = error instanceof Error ? error.message : 'Unknown error';
const enriched = this.enrichTopLevelError(specName, baseMessage);
issues.push({
level: 'ERROR',
path: 'file',
message: error instanceof Error ? error.message : 'Unknown error',
message: enriched,
});
}
@@ -49,10 +50,9 @@ export class Validator {
async validateChange(filePath: string): Promise<ValidationReport> {
const issues: ValidationIssue[] = [];
const changeName = this.extractNameFromPath(filePath);
try {
const content = readFileSync(filePath, 'utf-8');
const changeName = this.extractNameFromPath(filePath);
const changeDir = path.dirname(filePath);
const parser = new ChangeParser(content, changeDir);
@@ -67,10 +67,12 @@ export class Validator {
issues.push(...this.applyChangeRules(change, content));
} catch (error) {
const baseMessage = error instanceof Error ? error.message : 'Unknown error';
const enriched = this.enrichTopLevelError(changeName, baseMessage);
issues.push({
level: 'ERROR',
path: 'file',
message: error instanceof Error ? error.message : 'Unknown error',
message: enriched,
});
}
@@ -78,11 +80,17 @@ export class Validator {
}
private convertZodErrors(error: ZodError): ValidationIssue[] {
return error.issues.map(err => ({
level: 'ERROR' as ValidationLevel,
path: err.path.join('.'),
message: err.message,
}));
return error.issues.map(err => {
let message = err.message;
if (message === VALIDATION_MESSAGES.CHANGE_NO_DELTAS) {
message = `${message}. ${VALIDATION_MESSAGES.GUIDE_NO_DELTAS}`;
}
return {
level: 'ERROR' as ValidationLevel,
path: err.path.join('.'),
message,
};
});
}
private applySpecRules(spec: Spec, content: string): ValidationIssue[] {
@@ -109,7 +117,7 @@ export class Validator {
issues.push({
level: 'WARNING',
path: `requirements[${index}].scenarios`,
message: 'Requirement has no scenarios',
message: `${VALIDATION_MESSAGES.REQUIREMENT_NO_SCENARIOS}. ${VALIDATION_MESSAGES.GUIDE_SCENARIO_FORMAT}`,
});
}
});
@@ -144,6 +152,20 @@ export class Validator {
return issues;
}
private enrichTopLevelError(itemId: string, baseMessage: string): string {
const msg = baseMessage.trim();
if (msg === VALIDATION_MESSAGES.CHANGE_NO_DELTAS) {
return `${msg}. ${VALIDATION_MESSAGES.GUIDE_NO_DELTAS}`;
}
if (msg.includes('Spec must have a Purpose section') || msg.includes('Spec must have a Requirements section')) {
return `${msg}. ${VALIDATION_MESSAGES.GUIDE_MISSING_SPEC_SECTIONS}`;
}
if (msg.includes('Change must have a Why section') || msg.includes('Change must have a What Changes section')) {
return `${msg}. ${VALIDATION_MESSAGES.GUIDE_MISSING_CHANGE_SECTIONS}`;
}
return msg;
}
private extractNameFromPath(filePath: string): string {
const parts = filePath.split('/');
+26
View File
@@ -0,0 +1,26 @@
export function nearestMatches(input: string, candidates: string[], max: number = 5): string[] {
const scored = candidates.map(candidate => ({ candidate, distance: levenshtein(input, candidate) }));
scored.sort((a, b) => a.distance - b.distance);
return scored.slice(0, max).map(s => s.candidate);
}
export function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
);
}
}
return dp[m][n];
}
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
describe('change show (interactive behavior)', () => {
const projectRoot = process.cwd();
const testDir = path.join(projectRoot, 'test-change-show-tmp');
const changesDir = path.join(testDir, 'openspec', 'changes');
const bin = path.join(projectRoot, 'bin', 'openspec.js');
beforeAll(() => {
execSync('pnpm -s build', { stdio: 'pipe' });
});
beforeEach(async () => {
await fs.mkdir(changesDir, { recursive: true });
const content = `# Change: Demo\n\n## Why\n\n## What Changes\n- x`;
await fs.mkdir(path.join(changesDir, 'demo'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'demo', 'proposal.md'), content, 'utf-8');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('prints list hint and exits non-zero when no arg and non-interactive', () => {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
try {
process.chdir(testDir);
process.env.OPEN_SPEC_INTERACTIVE = '0';
let err: any;
try {
execSync(`node ${bin} change show`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
expect(err.stderr.toString()).toContain('Available IDs:');
expect(err.stderr.toString()).toContain('openspec change list');
} finally {
process.chdir(originalCwd);
process.env = originalEnv;
}
});
});
+126
View File
@@ -0,0 +1,126 @@
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('top-level show command', () => {
const projectRoot = process.cwd();
const testDir = path.join(projectRoot, 'test-show-command-tmp');
const changesDir = path.join(testDir, 'openspec', 'changes');
const specsDir = path.join(testDir, 'openspec', 'specs');
const openspecBin = path.join(projectRoot, 'bin', 'openspec.js');
beforeAll(() => {
execSync('pnpm -s build', { stdio: 'pipe' });
});
beforeEach(async () => {
await fs.mkdir(changesDir, { recursive: true });
await fs.mkdir(specsDir, { recursive: true });
const changeContent = `# Change: Demo\n\n## Why\nBecause reasons.\n\n## What Changes\n- **auth:** Add requirement\n`;
await fs.mkdir(path.join(changesDir, 'demo'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'demo', 'proposal.md'), changeContent, 'utf-8');
const specContent = `## Purpose\nAuth spec.\n\n## Requirements\n\n### Requirement: User Authentication\nText\n`;
await fs.mkdir(path.join(specsDir, 'auth'), { recursive: true });
await fs.writeFile(path.join(specsDir, 'auth', 'spec.md'), specContent, 'utf-8');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('prints hint and non-zero exit when no args and non-interactive', () => {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
try {
process.chdir(testDir);
process.env.OPEN_SPEC_INTERACTIVE = '0';
let err: any;
try {
execSync(`node ${openspecBin} show`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
const stderr = err.stderr.toString();
expect(stderr).toContain('Nothing to show.');
expect(stderr).toContain('openspec show <item>');
expect(stderr).toContain('openspec change show');
expect(stderr).toContain('openspec spec show');
} finally {
process.chdir(originalCwd);
process.env = originalEnv;
}
});
it('auto-detects change id and supports --json', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
const output = execSync(`node ${openspecBin} show demo --json`, { encoding: 'utf-8' });
const json = JSON.parse(output);
expect(json.id).toBe('demo');
expect(Array.isArray(json.deltas)).toBe(true);
} finally {
process.chdir(originalCwd);
}
});
it('auto-detects spec id and supports spec-only flags', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
const output = execSync(`node ${openspecBin} show auth --json --requirements`, { encoding: 'utf-8' });
const json = JSON.parse(output);
expect(json.id).toBe('auth');
expect(Array.isArray(json.requirements)).toBe(true);
} finally {
process.chdir(originalCwd);
}
});
it('handles ambiguity and suggests --type', async () => {
// create matching spec and change named 'foo'
await fs.mkdir(path.join(changesDir, 'foo'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'foo', 'proposal.md'), '# Change: Foo\n\n## Why\n\n## What Changes\n', 'utf-8');
await fs.mkdir(path.join(specsDir, 'foo'), { recursive: true });
await fs.writeFile(path.join(specsDir, 'foo', 'spec.md'), '## Purpose\n\n## Requirements\n\n### Requirement: R\nX', 'utf-8');
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let err: any;
try {
execSync(`node ${openspecBin} show foo`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
const stderr = err.stderr.toString();
expect(stderr).toContain('Ambiguous item');
expect(stderr).toContain('--type change|spec');
} finally {
process.chdir(originalCwd);
}
});
it('prints nearest matches when not found', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let err: any;
try {
execSync(`node ${openspecBin} show unknown-item`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
const stderr = err.stderr.toString();
expect(stderr).toContain("Unknown item 'unknown-item'");
expect(stderr).toContain('Did you mean:');
} finally {
process.chdir(originalCwd);
}
});
});
@@ -0,0 +1,47 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
describe('spec show (interactive behavior)', () => {
const projectRoot = process.cwd();
const testDir = path.join(projectRoot, 'test-spec-show-tmp');
const specsDir = path.join(testDir, 'openspec', 'specs');
const bin = path.join(projectRoot, 'bin', 'openspec.js');
beforeAll(() => {
execSync('pnpm -s build', { stdio: 'pipe' });
});
beforeEach(async () => {
await fs.mkdir(specsDir, { recursive: true });
const content = `## Purpose\nX\n\n## Requirements\n\n### Requirement: R\nText`;
await fs.mkdir(path.join(specsDir, 's1'), { recursive: true });
await fs.writeFile(path.join(specsDir, 's1', 'spec.md'), content, 'utf-8');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('errors when no arg and non-interactive', () => {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
try {
process.chdir(testDir);
process.env.OPEN_SPEC_INTERACTIVE = '0';
let err: any;
try {
execSync(`node ${bin} spec show`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
expect(err.stderr.toString()).toContain('Missing required argument <spec-id>');
} finally {
process.chdir(originalCwd);
process.env = originalEnv;
}
});
});
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
describe('validate command enriched human output', () => {
const projectRoot = process.cwd();
const testDir = path.join(projectRoot, 'test-validate-enriched-tmp');
const changesDir = path.join(testDir, 'openspec', 'changes');
const bin = path.join(projectRoot, 'bin', 'openspec.js');
beforeAll(() => {
// Build once so the bin can resolve dist
try { execSync('pnpm -s build', { stdio: 'pipe' }); } catch {}
});
beforeEach(async () => {
await fs.mkdir(changesDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('prints Next steps footer and guidance on invalid change', () => {
const changeContent = `# Test Change\n\n## Why\nThis is a sufficiently long explanation to pass the why length requirement for validation purposes.\n\n## What Changes\nThere are changes proposed, but no delta specs provided yet.`;
const changeId = 'c-next-steps';
const changePath = path.join(changesDir, changeId);
execSync(`mkdir -p ${changePath}`);
execSync(`bash -lc "cat > ${path.join(changePath, 'proposal.md')} <<'EOF'\n${changeContent}\nEOF"`);
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let code = 0;
let stderr = '';
try {
execSync(`node ${bin} change validate ${changeId}`, { encoding: 'utf-8', stdio: 'pipe' });
} catch (e: any) {
code = e?.status ?? 1;
stderr = e?.stderr?.toString?.() ?? '';
}
expect(code).not.toBe(0);
expect(stderr).toContain('has issues');
expect(stderr).toContain('Next steps:');
expect(stderr).toContain('openspec change show');
} finally {
process.chdir(originalCwd);
}
});
});
+6 -6
View File
@@ -34,7 +34,7 @@ describe('ListCommand', () => {
it('should handle missing openspec/changes directory', async () => {
const listCommand = new ListCommand();
await expect(listCommand.execute(tempDir)).rejects.toThrow(
await expect(listCommand.execute(tempDir, 'changes')).rejects.toThrow(
"No OpenSpec changes directory found. Run 'openspec init' first."
);
});
@@ -44,7 +44,7 @@ describe('ListCommand', () => {
await fs.mkdir(changesDir, { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
await listCommand.execute(tempDir, 'changes');
expect(logOutput).toEqual(['No active changes found.']);
});
@@ -61,7 +61,7 @@ describe('ListCommand', () => {
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
await listCommand.execute(tempDir, 'changes');
expect(logOutput).toContain('Changes:');
expect(logOutput.some(line => line.includes('my-change'))).toBe(true);
@@ -85,7 +85,7 @@ Regular text that should be ignored
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
await listCommand.execute(tempDir, 'changes');
expect(logOutput.some(line => line.includes('2/5 tasks'))).toBe(true);
});
@@ -100,7 +100,7 @@ Regular text that should be ignored
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
await listCommand.execute(tempDir, 'changes');
expect(logOutput.some(line => line.includes('✓ Complete'))).toBe(true);
});
@@ -110,7 +110,7 @@ Regular text that should be ignored
await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
await listCommand.execute(tempDir, 'changes');
expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true);
});
@@ -0,0 +1,74 @@
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';
describe('Validator enriched messages', () => {
const testDir = path.join(process.cwd(), 'test-validation-enriched-tmp');
beforeEach(async () => {
await fs.mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('adds guidance for no deltas in change', async () => {
const changeContent = `# Test Change
## Why
This is a sufficiently long explanation to pass the why length requirement for validation purposes.
## What Changes
There are changes proposed, but no delta specs provided yet.`;
const changePath = path.join(testDir, 'proposal.md');
await fs.writeFile(changePath, changeContent);
const validator = new Validator();
const report = await validator.validateChange(changePath);
expect(report.valid).toBe(false);
const msg = report.issues.map(i => i.message).join('\n');
expect(msg).toContain('Change must have at least one delta');
expect(msg).toContain('Ensure your change has a specs/ directory');
expect(msg).toContain('## ADDED/MODIFIED/REMOVED/RENAMED Requirements');
});
it('adds guidance when spec missing Purpose/Requirements', async () => {
const specContent = `# Test Spec\n\n## Requirements\n\n### Requirement: Foo\nFoo SHALL ...\n\n#### Scenario: Bar\nWhen...`;
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);
const msg = report.issues.map(i => i.message).join('\n');
expect(msg).toContain('Spec must have a Purpose section');
expect(msg).toContain('Expected headers: "## Purpose" and "## Requirements"');
});
it('warns with scenario conversion template when missing scenarios', async () => {
const specContent = `# Test Spec
## Purpose
This is a sufficiently long purpose section to avoid warnings about brevity.
## Requirements
### Requirement: Foo SHALL be described
Text of requirement
`;
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);
const warn = report.issues.find(i => i.path.includes('requirements[0].scenarios'));
expect(warn?.message).toContain('Requirement must have at least one scenario');
expect(warn?.message).toContain('Scenarios must use level-4 headers');
expect(warn?.message).toContain('#### Scenario:');
});
});
+2 -2
View File
@@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"module": "NodeNext",
"lib": ["ES2022"],
"moduleResolution": "node",
"moduleResolution": "NodeNext",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,