From bbce5f9fefa9ab4626cf418da138894de68976ba Mon Sep 17 00:00:00 2001 From: feiniao <2648955710@qq.com> Date: Tue, 11 Aug 2026 13:08:36 +0800 Subject: [PATCH 1/3] fix(cli): remove deprecated noun command groups Co-Authored-By: Claude --- docs/agent-contract.md | 2 +- docs/stores-beta/user-guide.md | 7 +- openspec/specs/cli-change/spec.md | 101 ++---- openspec/specs/cli-show/spec.md | 4 +- openspec/specs/cli-spec/spec.md | 102 ++---- openspec/specs/cli-validate/spec.md | 6 +- openspec/specs/openspec-conventions/spec.md | 8 +- src/cli/index.ts | 65 ---- src/commands/change.ts | 6 +- src/commands/show.ts | 6 +- src/commands/validate.ts | 4 +- src/core/completions/command-registry.ts | 104 ------ src/core/validation/constants.ts | 2 +- test/cli-e2e/validate-scenario-loss.test.ts | 7 +- test/cli-e2e/validate-task-numbering.test.ts | 4 +- test/commands/change.interactive-show.test.ts | 45 --- .../change.interactive-validate.test.ts | 48 --- test/commands/legacy-groups-removed.test.ts | 26 +- test/commands/show.test.ts | 27 +- test/commands/spec.interactive-show.test.ts | 44 --- .../spec.interactive-validate.test.ts | 44 --- test/commands/spec.test.ts | 324 ------------------ .../commands/validate.enriched-output.test.ts | 6 +- .../core/completions/command-registry.test.ts | 4 + 24 files changed, 109 insertions(+), 887 deletions(-) delete mode 100644 test/commands/change.interactive-show.test.ts delete mode 100644 test/commands/change.interactive-validate.test.ts delete mode 100644 test/commands/spec.interactive-show.test.ts delete mode 100644 test/commands/spec.interactive-validate.test.ts delete mode 100644 test/commands/spec.test.ts diff --git a/docs/agent-contract.md b/docs/agent-contract.md index 63e469e48f..991f49dd56 100644 --- a/docs/agent-contract.md +++ b/docs/agent-contract.md @@ -138,4 +138,4 @@ Recorded by the capstone audit; published-key renames are product decisions defe 5. `list --json` reuses the `status` key as a string enum per change. 6. Only `validate` output carries a `version` field. 7. `schemas`/`templates` ignore root selection (cwd-based, no `--store`). -8. Deprecated noun forms (`change`/`spec` subcommands) emit unenveloped payloads without `root`/`status`. +8. Deprecated noun-form `change`/`spec` command groups are removed; the root-aware verb-first `list`, `show`, and `validate` shapes above are the supported CLI contracts. diff --git a/docs/stores-beta/user-guide.md b/docs/stores-beta/user-guide.md index 4a4db4ccc3..2d692db07c 100644 --- a/docs/stores-beta/user-guide.md +++ b/docs/stores-beta/user-guide.md @@ -427,9 +427,10 @@ tells you which case you're in. `openspec/config.yaml` declares `store: ` is treated as externalized planning, not as a store checkout to register. Remove the `store:` line first if you intentionally want to convert that repo into a local store root. -- **Some commands stay where they are.** `view`, `templates`, `schemas`, - and the deprecated noun forms (`openspec change show`, ...) act on the - current directory only — no `--store`. +- **Some commands stay where they are.** `view`, `templates`, and `schemas` + act on the current directory only — no `--store`. The deprecated noun-form + command groups (`openspec change ...` and `openspec spec ...`) are removed; + use the root-aware verb-first commands instead. - **Per-machine state is per-machine.** The store registry and worksets are local settings. Nothing about your machine's layout is ever committed to shared planning. diff --git a/openspec/specs/cli-change/spec.md b/openspec/specs/cli-change/spec.md index 46866f98a9..fe262dc5a3 100644 --- a/openspec/specs/cli-change/spec.md +++ b/openspec/specs/cli-change/spec.md @@ -1,92 +1,31 @@ # cli-change Specification ## Purpose -Define `openspec change` command behavior for showing, listing, and validating change proposals and deltas. +Document the removal of the deprecated noun-form `openspec change ...` command group and the canonical verb-first replacements. ## Requirements -### Requirement: Change Command +### Requirement: Deprecated change command group is removed +The CLI SHALL NOT register a top-level `change` command group. -The system SHALL provide a `change` command with subcommands for displaying, listing, and validating change proposals. +#### Scenario: Removed change command is unavailable +- **WHEN** executing `openspec change ...` +- **THEN** the command SHALL fail without running a change command action or emitting the former deprecation warning +- **AND** users SHALL use `openspec show`, `openspec list`, or `openspec validate` instead -#### Scenario: Show change as JSON +### Requirement: Canonical change operations remain available +The CLI SHALL provide change operations through the canonical verb-first commands. -- **WHEN** executing `openspec change show update-error --json` -- **THEN** parse the markdown change file -- **AND** extract change structure and deltas -- **AND** output valid JSON to stdout +#### Scenario: Show a change +- **WHEN** executing `openspec show --type change` +- **THEN** display the change using the existing top-level show behavior -#### Scenario: List all changes +#### Scenario: List changes +- **WHEN** executing `openspec list` or `openspec list --changes` +- **THEN** display active changes using the existing list behavior -- **WHEN** executing `openspec change list` -- **THEN** scan the openspec/changes directory -- **AND** return list of all pending changes -- **AND** support JSON output with `--json` flag - -#### Scenario: Show only requirement changes - -- **WHEN** executing `openspec change show update-error --requirements-only` -- **THEN** display only the requirement changes (ADDED/MODIFIED/REMOVED/RENAMED) -- **AND** exclude why and what changes sections - -#### Scenario: Validate change structure - -- **WHEN** executing `openspec change validate update-error` -- **THEN** parse the change file -- **AND** validate against Zod schema -- **AND** ensure deltas are well-formed - -### Requirement: Legacy Compatibility - -The system SHALL maintain backward compatibility with the existing `list` command while showing deprecation notices. - -#### Scenario: Legacy list command - -- **WHEN** executing `openspec list` -- **THEN** display current list of changes (existing behavior) -- **AND** show deprecation notice: "Note: 'openspec list' is deprecated. Use 'openspec change list' instead." - -#### Scenario: Legacy list with --all flag - -- **WHEN** executing `openspec list --all` -- **THEN** display all changes (existing behavior) -- **AND** show same deprecation notice - -### Requirement: Interactive show selection - -The change show command SHALL support interactive selection when no change name is provided. - -#### Scenario: Interactive change selection for show - -- **WHEN** executing `openspec change show` without arguments -- **THEN** display an interactive list of available changes -- **AND** allow the user to select a change to show -- **AND** display the selected change content -- **AND** maintain all existing show options (--json, --deltas-only) - -#### Scenario: Non-interactive fallback keeps current behavior - -- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0` -- **WHEN** executing `openspec change show` without a change name -- **THEN** do not prompt interactively -- **AND** print the existing hint including available change IDs -- **AND** set `process.exitCode = 1` - -### Requirement: Interactive validation selection - -The change validate command SHALL support interactive selection when no change name is provided. - -#### Scenario: Interactive change selection for validation - -- **WHEN** executing `openspec change validate` without arguments -- **THEN** display an interactive list of available changes -- **AND** allow the user to select a change to validate -- **AND** validate the selected change - -#### Scenario: Non-interactive fallback keeps current behavior - -- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0` -- **WHEN** executing `openspec change validate` without a change name -- **THEN** do not prompt interactively -- **AND** print the existing hint including available change IDs -- **AND** set `process.exitCode = 1` +#### Scenario: Validate a change +- **WHEN** executing `openspec validate --type change` +- **THEN** validate the change using the existing top-level validate behavior +### Requirement: Change data is preserved +Removing the command group SHALL NOT delete or migrate change directories, proposals, delta specs, tasks, or archived changes. diff --git a/openspec/specs/cli-show/spec.md b/openspec/specs/cli-show/spec.md index 0a4aa019a0..cf5469be0c 100644 --- a/openspec/specs/cli-show/spec.md +++ b/openspec/specs/cli-show/spec.md @@ -20,7 +20,7 @@ The CLI SHALL provide a top-level `show` command for displaying changes and spec - **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0` - **WHEN** executing `openspec show` without arguments - **THEN** do not prompt -- **AND** print a helpful hint with examples for `openspec show ` or `openspec change/spec show` +- **AND** print a helpful hint with examples for `openspec show ` and `--type change|spec` - **AND** exit with code 1 #### Scenario: Direct item display @@ -34,7 +34,7 @@ The CLI SHALL provide a top-level `show` command for displaying changes and spec - **WHEN** executing `openspec show ` - **THEN** if `` uniquely matches a change or a spec, show that item -- **AND** if it matches both, print an ambiguity error and suggest `--type change|spec` or using `openspec change show`/`openspec spec show` +- **AND** if it matches both, print an ambiguity error and suggest `--type change|spec` - **AND** if it matches neither, print not-found with nearest-match suggestions #### Scenario: Explicit type override diff --git a/openspec/specs/cli-spec/spec.md b/openspec/specs/cli-spec/spec.md index 1277d8b9c3..91d29c400c 100644 --- a/openspec/specs/cli-spec/spec.md +++ b/openspec/specs/cli-spec/spec.md @@ -1,88 +1,44 @@ # cli-spec Specification ## Purpose -Define `openspec spec` command behavior for listing, showing, and validating source-of-truth specifications. +Document the canonical verb-first commands for showing, listing, and validating source-of-truth specifications. ## Requirements -### Requirement: Interactive spec show +### Requirement: Canonical spec show +The CLI SHALL show specifications through the top-level `show` command. -The spec show command SHALL support interactive selection when no spec-id is provided. +#### Scenario: Interactive spec show +- **WHEN** executing `openspec show` without an item in an interactive terminal +- **THEN** allow selection of a spec and display its content -#### Scenario: Interactive spec selection for show +#### Scenario: Direct spec show +- **WHEN** executing `openspec show --type spec` +- **THEN** parse and display the specification +- **AND** preserve `--json`, `--requirements`, `--no-scenarios`, and `-r/--requirement` -- **WHEN** executing `openspec spec show` without arguments -- **THEN** display an interactive list of available specs -- **AND** allow the user to select a spec to show -- **AND** display the selected spec content -- **AND** maintain all existing show options (--json, --requirements, --no-scenarios, -r) - -#### Scenario: Non-interactive fallback keeps current behavior - -- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0` -- **WHEN** executing `openspec spec show` without a spec-id -- **THEN** do not prompt interactively -- **AND** print the existing error message for missing spec-id -- **AND** set non-zero exit code - -### Requirement: Spec Command - -The system SHALL provide a `spec` command with subcommands for displaying, listing, and validating specifications. - -#### Scenario: Show spec as JSON - -- **WHEN** executing `openspec spec show init --json` -- **THEN** parse the markdown spec file -- **AND** extract headings and content hierarchically -- **AND** output valid JSON to stdout +### Requirement: Canonical spec list +The CLI SHALL list specifications through `openspec list --specs`. #### Scenario: List all specs +- **WHEN** executing `openspec list --specs` +- **THEN** scan `openspec/specs/` +- **AND** report available capabilities and requirement counts using the existing list behavior -- **WHEN** executing `openspec spec list` -- **THEN** scan the openspec/specs directory -- **AND** return list of all available capabilities -- **AND** support JSON output with `--json` flag - -#### Scenario: Filter spec content - -- **WHEN** executing `openspec spec show init --requirements` -- **THEN** display only requirement names and SHALL statements -- **AND** exclude scenario content - -#### Scenario: Validate spec structure - -- **WHEN** executing `openspec spec validate init` -- **THEN** parse the spec file -- **AND** validate against Zod schema -- **AND** report any structural issues - -### Requirement: JSON Schema Definition - -The system SHALL define Zod schemas that accurately represent the spec structure for runtime validation. - -#### Scenario: Schema validation - -- **WHEN** parsing a spec into JSON -- **THEN** validate the structure using Zod schemas -- **AND** ensure all required fields are present -- **AND** provide clear error messages for validation failures - -### Requirement: Interactive spec validation - -The spec validate command SHALL support interactive selection when no spec-id is provided. - -#### Scenario: Interactive spec selection for validation +### Requirement: Canonical spec validation +The CLI SHALL validate specifications through the top-level `validate` command. -- **WHEN** executing `openspec spec validate` without arguments -- **THEN** display an interactive list of available specs -- **AND** allow the user to select a spec to validate -- **AND** validate the selected spec -- **AND** maintain all existing validation options (--strict, --json) +#### Scenario: Validate a spec +- **WHEN** executing `openspec validate --type spec` +- **THEN** validate the specification using the existing top-level validate behavior +- **AND** preserve `--strict` and `--json` -#### Scenario: Non-interactive fallback keeps current behavior +### Requirement: Removed spec command group +The CLI SHALL NOT register a top-level `spec` command group. -- **GIVEN** stdin is not a TTY or `--no-interactive` is provided or environment variable `OPEN_SPEC_INTERACTIVE=0` -- **WHEN** executing `openspec spec validate` without a spec-id -- **THEN** do not prompt interactively -- **AND** print the existing error message for missing spec-id -- **AND** set non-zero exit code +#### Scenario: Removed spec command is unavailable +- **WHEN** executing `openspec spec ...` +- **THEN** the command SHALL fail without running a spec command action or emitting the former deprecation warning +- **AND** users SHALL use the canonical verb-first commands above +### Requirement: Spec data is preserved +Removing the command group SHALL NOT delete or migrate files under `openspec/specs/`. diff --git a/openspec/specs/cli-validate/spec.md b/openspec/specs/cli-validate/spec.md index 3f04425af0..8a8d3e8a41 100644 --- a/openspec/specs/cli-validate/spec.md +++ b/openspec/specs/cli-validate/spec.md @@ -13,7 +13,7 @@ Validation output SHALL include specific guidance to fix each error, including e - Explain that change specs must include `## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, or `## RENAMED Requirements` - Remind authors that files must live under `openspec/changes/{id}/specs//spec.md` - Include an explicit note: "Spec delta files cannot start with titles before the operation headers" - - Suggest running `openspec change show {id} --json --deltas-only` for debugging + - Suggest running `openspec show {id} --type change --json --deltas-only` for debugging #### Scenario: Missing required sections - **WHEN** a required section is missing @@ -88,7 +88,7 @@ The CLI SHALL append a Next steps footer when the item is invalid and not using #### Scenario: Change invalid summary - **WHEN** a change validation fails -- **THEN** print "Next steps" with 2-3 targeted bullets and suggest `openspec change show --json --deltas-only` +- **THEN** print "Next steps" with 2-3 targeted bullets and suggest `openspec show --type change --json --deltas-only` ### Requirement: Change validation SHALL report scenarios a MODIFIED block would drop @@ -235,7 +235,7 @@ The validate command SHALL handle ambiguous names and explicit type overrides to - **GIVEN** `` exists both as a change and as a spec - **WHEN** executing `openspec validate ` - **THEN** print an ambiguity error explaining both matches -- **AND** suggest passing `--type change` or `--type spec`, or using `openspec change validate` / `openspec spec validate` +- **AND** suggest passing `--type change` or `--type spec` - **AND** exit with code 1 without performing validation #### Scenario: Unknown item name diff --git a/openspec/specs/openspec-conventions/spec.md b/openspec/specs/openspec-conventions/spec.md index 54fc0e0f3b..2802cbff49 100644 --- a/openspec/specs/openspec-conventions/spec.md +++ b/openspec/specs/openspec-conventions/spec.md @@ -245,10 +245,10 @@ OpenSpec CLI design SHALL use verbs as top-level commands with nouns provided as - **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: Removed noun command groups +- **WHEN** users need to show, list, or validate changes and specs +- **THEN** they SHALL use the canonical verb-first commands `openspec show`, `openspec list`, and `openspec validate` +- **AND** the removed noun-form command groups `openspec spec ...` and `openspec change ...` SHALL NOT be presented as supported entry points #### Scenario: Disambiguation guidance - **WHEN** item names are ambiguous between changes and specs diff --git a/src/cli/index.ts b/src/cli/index.ts index 619f958ecc..033a07d236 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,8 +21,6 @@ import { ListCommand } from '../core/list.js'; import { ArchiveCommand, type ArchiveOptions } from '../core/archive.js'; import { ViewCommand } from '../core/view.js'; import { resolveRootForCommand, toRootOutput } from '../core/root-selection.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'; import { CompletionCommand } from '../commands/completion.js'; @@ -343,68 +341,6 @@ program } }); -// Change command with subcommands -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)') - .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); - } catch (error) { - console.error(`Error: ${(error as Error).message}`); - process.exitCode = 1; - } - }); - -changeCmd - .command('list') - .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) { - console.error(`Error: ${(error as Error).message}`); - process.exitCode = 1; - } - }); - -changeCmd - .command('validate [change-name]') - .description('Validate a change proposal') - .option('--strict', 'Enable strict validation mode') - .option('--json', 'Output validation report as JSON') - .option('--no-interactive', 'Disable interactive prompts') - .action(async (changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => { - try { - const changeCommand = new ChangeCommand(); - await changeCommand.validate(changeName, options); - if (typeof process.exitCode === 'number' && process.exitCode !== 0) { - process.exit(process.exitCode); - } - } 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') @@ -424,7 +360,6 @@ program } }); -registerSpecCommand(program); registerConfigCommand(program); registerSchemaCommand(program); registerStoreCommand(program); diff --git a/src/commands/change.ts b/src/commands/change.ts index 4c58af7892..487883c961 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -73,7 +73,7 @@ export class ChangeCommand { } else { console.error(`No change specified. Available IDs: ${changes.join(', ')}`); } - console.error('Hint: use "openspec change list" to view available changes.'); + console.error('Hint: use "openspec list" to view available changes.'); process.exitCode = 1; return; } @@ -248,7 +248,7 @@ export class ChangeCommand { } else { console.error(`No change specified. Available IDs: ${changes.join(', ')}`); } - console.error('Hint: use "openspec change list" to view available changes.'); + console.error('Hint: use "openspec list" to view available changes.'); process.exitCode = 1; return; } @@ -317,7 +317,7 @@ export class ChangeCommand { } else { 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 --json --deltas-only'); + bullets.push('- Debug parsed deltas: openspec show --type change --json --deltas-only'); } console.error('Next steps:'); bullets.forEach(b => console.error(` ${b}`)); diff --git a/src/commands/show.ts b/src/commands/show.ts index bfdb2958dc..b9b64518a5 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -171,7 +171,7 @@ export class ShowCommand { if (isStoreSelectedRoot(root)) { console.error('Pass --type change|spec.'); } else { - console.error('Pass --type change|spec, or use: openspec change show / openspec spec show'); + console.error('Pass --type change|spec, or use: openspec show --type change|spec'); } process.exitCode = 1; return; @@ -195,8 +195,8 @@ export class ShowCommand { console.error(` ${withStoreFlag(root, 'openspec show --type change')}`); console.error(` ${withStoreFlag(root, 'openspec show --type spec')}`); } else { - console.error(' openspec change show'); - console.error(' openspec spec show'); + console.error(' openspec show --type change'); + console.error(' openspec show --type spec'); } console.error('Or run in an interactive terminal.'); } diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 7c474cd0c2..df9c5fa3b5 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -183,7 +183,7 @@ export class ValidateCommand { if (isStoreSelectedRoot(root)) { console.error('Pass --type change|spec.'); } else { - console.error('Pass --type change|spec, or use: openspec change validate / openspec spec validate'); + console.error('Pass --type change|spec, or use: openspec validate --type change|spec'); } process.exitCode = 1; return; @@ -255,7 +255,7 @@ export class ValidateCommand { } else 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: ${withStoreFlag(root, `openspec show ${id} --json --deltas-only`)}`); + bullets.push(`- Debug parsed deltas: ${withStoreFlag(root, `openspec show ${id} --type change --json --deltas-only`)}`); } else { bullets.push('- Ensure spec includes ## Purpose and ## Requirements sections'); bullets.push('- Each requirement MUST include at least one #### Scenario: block'); diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 2d139b3043..0475ad94f0 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -461,110 +461,6 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, ], }, - { - name: 'change', - description: 'Manage OpenSpec change proposals (deprecated)', - flags: [], - subcommands: [ - { - name: 'show', - description: 'Show a change proposal', - acceptsPositional: true, - positionalType: 'change-id', - positionals: [{ name: 'change-name', type: 'change-id', optional: true }], - flags: [ - COMMON_FLAGS.json, - { - name: 'deltas-only', - description: 'Show only deltas (JSON only)', - }, - { - name: 'requirements-only', - description: 'Alias for --deltas-only (deprecated)', - }, - COMMON_FLAGS.noInteractive, - ], - }, - { - name: 'list', - description: 'List all active changes (deprecated)', - flags: [ - COMMON_FLAGS.json, - { - name: 'long', - description: 'Show id and title with counts', - }, - ], - }, - { - name: 'validate', - description: 'Validate a change proposal', - acceptsPositional: true, - positionalType: 'change-id', - positionals: [{ name: 'change-name', type: 'change-id', optional: true }], - flags: [ - COMMON_FLAGS.strict, - COMMON_FLAGS.jsonValidation, - COMMON_FLAGS.noInteractive, - ], - }, - ], - }, - { - name: 'spec', - description: 'Manage OpenSpec specifications', - flags: [], - subcommands: [ - { - name: 'show', - description: 'Show a specification', - acceptsPositional: true, - positionalType: 'spec-id', - positionals: [{ name: 'spec-id', type: 'spec-id', optional: true }], - flags: [ - COMMON_FLAGS.json, - { - name: 'requirements', - description: 'Show only requirements, exclude scenarios (JSON only)', - }, - { - name: 'no-scenarios', - description: 'Exclude scenario content (JSON only)', - }, - { - name: 'requirement', - short: 'r', - description: 'Show specific requirement by ID (JSON only)', - takesValue: true, - }, - COMMON_FLAGS.noInteractive, - ], - }, - { - name: 'list', - description: 'List all specifications', - flags: [ - COMMON_FLAGS.json, - { - name: 'long', - description: 'Show id and title with counts', - }, - ], - }, - { - name: 'validate', - description: 'Validate a specification', - acceptsPositional: true, - positionalType: 'spec-id', - positionals: [{ name: 'spec-id', type: 'spec-id', optional: true }], - flags: [ - COMMON_FLAGS.strict, - COMMON_FLAGS.jsonValidation, - COMMON_FLAGS.noInteractive, - ], - }, - ], - }, { name: 'completion', description: 'Manage shell completions for OpenSpec CLI', diff --git a/src/core/validation/constants.ts b/src/core/validation/constants.ts index d08cd47ebe..79ef56ea91 100644 --- a/src/core/validation/constants.ts +++ b/src/core/validation/constants.ts @@ -44,7 +44,7 @@ export const VALIDATION_MESSAGES = { // Guidance snippets (appended to primary messages for remediation) GUIDE_NO_DELTAS: - 'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. If this change intentionally modifies no specs (pure refactor, tooling, docs), set "skip_specs: true" in the change\'s .openspec.yaml instead. Tip: run "openspec change show --json --deltas-only" to inspect parsed deltas.', + 'No deltas found. Ensure your change has a specs/ directory with capability folders (e.g. specs/http-server/spec.md) containing .md files that use delta headers (## ADDED/MODIFIED/REMOVED/RENAMED Requirements) and that each requirement includes at least one "#### Scenario:" block. If this change intentionally modifies no specs (pure refactor, tooling, docs), set "skip_specs: true" in the change\'s .openspec.yaml instead. Tip: run "openspec show --type change --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: diff --git a/test/cli-e2e/validate-scenario-loss.test.ts b/test/cli-e2e/validate-scenario-loss.test.ts index cd0375bff4..cad5668f59 100644 --- a/test/cli-e2e/validate-scenario-loss.test.ts +++ b/test/cli-e2e/validate-scenario-loss.test.ts @@ -86,8 +86,11 @@ describe('openspec validate reports scenarios a MODIFIED block would drop (#1477 expect(byId['keeps-every-scenario']).toBe(true); }); - it('reports it through the deprecated `change validate` command', async () => { - const result = await runCLI(['change', 'validate', 'drops-a-scenario'], { cwd: projectDir }); + it('reports it through explicit change validation', async () => { + const result = await runCLI( + ['validate', 'drops-a-scenario', '--type', 'change'], + { cwd: projectDir } + ); expect(result.exitCode).toBe(1); expect(result.stderr).toContain('omits scenario(s)'); diff --git a/test/cli-e2e/validate-task-numbering.test.ts b/test/cli-e2e/validate-task-numbering.test.ts index 2d6a133b12..9a71630027 100644 --- a/test/cli-e2e/validate-task-numbering.test.ts +++ b/test/cli-e2e/validate-task-numbering.test.ts @@ -198,9 +198,9 @@ describe('openspec validate checks task numbering (#1520)', () => { expect(taskIssues).toEqual([]); }); - it('applies the same warnings to the deprecated change validate command', async () => { + it('applies the same warnings with an explicit change type', async () => { const result = await runCLI( - ['change', 'validate', 'bad-numbering', '--strict'], + ['validate', 'bad-numbering', '--type', 'change', '--strict'], { cwd: projectDir } ); diff --git a/test/commands/change.interactive-show.test.ts b/test/commands/change.interactive-show.test.ts deleted file mode 100644 index 426117fc42..0000000000 --- a/test/commands/change.interactive-show.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { promises as fs } from 'fs'; -import path from 'path'; -import { execFileSync } 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'); - - - 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 { - execFileSync('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; - } - }); -}); - - diff --git a/test/commands/change.interactive-validate.test.ts b/test/commands/change.interactive-validate.test.ts deleted file mode 100644 index 1872e68ec5..0000000000 --- a/test/commands/change.interactive-validate.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { promises as fs } from 'fs'; -import path from 'path'; -import { execFileSync } from 'child_process'; - -// Note: We cannot truly simulate TTY prompts in this test runner easily. -// Instead, we verify non-interactive fallback behavior and basic invocation. - -describe('change validate (interactive behavior)', () => { - const projectRoot = process.cwd(); - const testDir = path.join(projectRoot, 'test-change-validate-tmp'); - const changesDir = path.join(testDir, 'openspec', 'changes'); - const bin = path.join(projectRoot, 'bin', 'openspec.js'); - - - beforeEach(async () => { - await fs.mkdir(changesDir, { recursive: true }); - const content = `# Change: Demo\n\n## Why\nBecause reasons that are sufficiently long.\n\n## What Changes\n- **spec-x:** Add something`; - await fs.mkdir(path.join(changesDir, 'demo'), { recursive: true }); - await fs.writeFile(path.join(changesDir, 'demo', 'proposal.md'), content, 'utf-8'); - }); - - afterEach(async () => { - await fs.rm(testDir, { recursive: true, force: true }); - }); - - it('prints list hint and exits non-zero when no arg and non-interactive', () => { - const originalCwd = process.cwd(); - const originalEnv = { ...process.env }; - try { - process.chdir(testDir); - process.env.OPEN_SPEC_INTERACTIVE = '0'; - let err: any; - try { - execFileSync('node', [bin, 'change', 'validate'], { encoding: 'utf-8' }); - } catch (e) { err = e; } - expect(err).toBeDefined(); - expect(err.status).not.toBe(0); - expect(err.stderr.toString()).toContain('Available IDs:'); - expect(err.stderr.toString()).toContain('openspec change list'); - } finally { - process.chdir(originalCwd); - process.env = originalEnv; - } - }); -}); - - diff --git a/test/commands/legacy-groups-removed.test.ts b/test/commands/legacy-groups-removed.test.ts index 99c9700abc..72cfaf62e7 100644 --- a/test/commands/legacy-groups-removed.test.ts +++ b/test/commands/legacy-groups-removed.test.ts @@ -65,20 +65,34 @@ describe('legacy command groups are removed', () => { } it('rejects the deleted groups as unknown commands', async () => { - for (const group of ['workspace', 'initiative']) { + for (const group of ['workspace', 'initiative', 'change', 'spec']) { const result = await runCLI([group, 'list'], { cwd: tempDir, env }); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain(`unknown command '${group}'`); + expect(result.exitCode).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).not.toContain('deprecated'); } }); - it('lists neither group in --help', async () => { + it('lists neither deleted group in --help', async () => { const result = await runCLI(['--help'], { cwd: tempDir, env }); expect(result.exitCode).toBe(0); - expect(result.stdout).not.toMatch(/^\s*workspace\s/m); - expect(result.stdout).not.toMatch(/^\s*initiative\s/m); + expect(result.stdout).not.toMatch(/^[ \t]*workspace[ \t]/m); + expect(result.stdout).not.toMatch(/^[ \t]*initiative[ \t]/m); + expect(result.stdout).not.toMatch(/^[ \t]*change[ \t]/m); + expect(result.stdout).not.toMatch(/^[ \t]*spec[ \t]/m); + }); + + it('does not advertise deleted groups in generated bash completion', async () => { + const result = await runCLI(['completion', 'generate', 'bash'], { cwd: tempDir, env }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('change show'); + expect(result.stdout).not.toContain('change list'); + expect(result.stdout).not.toContain('change validate'); + expect(result.stdout).not.toContain('spec show'); + expect(result.stdout).not.toContain('spec list'); + expect(result.stdout).not.toContain('spec validate'); }); it('update falls through to the standard no-project error in a view dir', async () => { diff --git a/test/commands/show.test.ts b/test/commands/show.test.ts index 19ec6b2820..9fade08ec0 100644 --- a/test/commands/show.test.ts +++ b/test/commands/show.test.ts @@ -43,8 +43,8 @@ describe('top-level show command', () => { const stderr = err.stderr.toString(); expect(stderr).toContain('Nothing to show.'); expect(stderr).toContain('openspec show '); - expect(stderr).toContain('openspec change show'); - expect(stderr).toContain('openspec spec show'); + expect(stderr).toContain('openspec show --type change'); + expect(stderr).toContain('openspec show --type spec'); } finally { process.chdir(originalCwd); process.env = originalEnv; @@ -146,29 +146,6 @@ describe('top-level show command', () => { } }); - it('offers a scaffolded change when "change show" is called without a name', async () => { - await fs.mkdir(path.join(changesDir, 'scaffolded'), { recursive: true }); - await fs.writeFile(path.join(changesDir, 'scaffolded', '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); - - const originalCwd = process.cwd(); - const originalEnv = { ...process.env }; - try { - process.chdir(testDir); - process.env.OPEN_SPEC_INTERACTIVE = '0'; - let err: any; - try { - execFileSync('node', [openspecBin, 'change', 'show'], { encoding: 'utf-8' }); - } catch (e) { err = e; } - expect(err).toBeDefined(); - const stderr = err.stderr.toString(); - expect(stderr).toContain('Available IDs:'); - expect(stderr).toContain('scaffolded'); - } finally { - process.chdir(originalCwd); - process.env = originalEnv; - } - }); - it('prints nearest matches when not found', () => { const originalCwd = process.cwd(); try { diff --git a/test/commands/spec.interactive-show.test.ts b/test/commands/spec.interactive-show.test.ts deleted file mode 100644 index 8c90ab656f..0000000000 --- a/test/commands/spec.interactive-show.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { promises as fs } from 'fs'; -import path from 'path'; -import { execFileSync } 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'); - - - 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 { - execFileSync('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 '); - } finally { - process.chdir(originalCwd); - process.env = originalEnv; - } - }); -}); - - diff --git a/test/commands/spec.interactive-validate.test.ts b/test/commands/spec.interactive-validate.test.ts deleted file mode 100644 index 7475e31e31..0000000000 --- a/test/commands/spec.interactive-validate.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { promises as fs } from 'fs'; -import path from 'path'; -import { execFileSync } from 'child_process'; - -describe('spec validate (interactive behavior)', () => { - const projectRoot = process.cwd(); - const testDir = path.join(projectRoot, 'test-spec-validate-tmp'); - const specsDir = path.join(testDir, 'openspec', 'specs'); - const bin = path.join(projectRoot, 'bin', 'openspec.js'); - - - beforeEach(async () => { - await fs.mkdir(specsDir, { recursive: true }); - const content = `## Purpose\nValid spec for interactive test.\n\n## Requirements\n\n### Requirement: X\nText`; - await fs.mkdir(path.join(specsDir, 's1'), { recursive: true }); - await fs.writeFile(path.join(specsDir, 's1', 'spec.md'), content, 'utf-8'); - }); - - afterEach(async () => { - await fs.rm(testDir, { recursive: true, force: true }); - }); - - it('errors when no arg and non-interactive', () => { - const originalCwd = process.cwd(); - const originalEnv = { ...process.env }; - try { - process.chdir(testDir); - process.env.OPEN_SPEC_INTERACTIVE = '0'; - let err: any; - try { - execFileSync('node', [bin, 'spec', 'validate'], { encoding: 'utf-8' }); - } catch (e) { err = e; } - expect(err).toBeDefined(); - expect(err.status).not.toBe(0); - expect(err.stderr.toString()).toContain('Missing required argument '); - } finally { - process.chdir(originalCwd); - process.env = originalEnv; - } - }); -}); - - diff --git a/test/commands/spec.test.ts b/test/commands/spec.test.ts deleted file mode 100644 index 42426da982..0000000000 --- a/test/commands/spec.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { promises as fs } from 'fs'; -import path from 'path'; -import { execFileSync } 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'); - - - 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', async () => { - const originalCwd = process.cwd(); - try { - process.chdir(testDir); - const output = execFileSync('node', [openspecBin, 'spec', 'show', 'auth'], { - encoding: 'utf-8' - }); - - // Raw passthrough should match spec.md content - const raw = await fs.readFile(path.join(specsDir, 'auth', 'spec.md'), '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 = execFileSync('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 = execFileSync('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 = execFileSync('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 = execFileSync('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 = execFileSync('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 = execFileSync('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 = execFileSync('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 = execFileSync('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 = execFileSync('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 = execFileSync('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 { - execFileSync('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 { - execFileSync('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 = execFileSync('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 = execFileSync('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); - } - }); - }); -}); \ No newline at end of file diff --git a/test/commands/validate.enriched-output.test.ts b/test/commands/validate.enriched-output.test.ts index 5ecb7c4903..2061f21689 100644 --- a/test/commands/validate.enriched-output.test.ts +++ b/test/commands/validate.enriched-output.test.ts @@ -31,7 +31,7 @@ describe('validate command enriched human output', () => { let code = 0; let stderr = ''; try { - execFileSync('node', [bin, 'change', 'validate', changeId], { encoding: 'utf-8', stdio: 'pipe' }); + execFileSync('node', [bin, 'validate', changeId, '--type', 'change'], { encoding: 'utf-8', stdio: 'pipe' }); } catch (e: any) { code = e?.status ?? 1; stderr = e?.stderr?.toString?.() ?? ''; @@ -39,7 +39,9 @@ describe('validate command enriched human output', () => { expect(code).not.toBe(0); expect(stderr).toContain('has issues'); expect(stderr).toContain('Next steps:'); - expect(stderr).toContain('openspec change show'); + expect(stderr).toContain( + `openspec show ${changeId} --type change --json --deltas-only` + ); } finally { process.chdir(originalCwd); } diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index 1137ff94ad..0663c00115 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -147,6 +147,10 @@ describe('command completion registry', () => { assertRegistryParity(program, COMMAND_REGISTRY); }); + it('does not register removed noun-form command groups', () => { + expect(COMMAND_REGISTRY.some((entry) => entry.name === 'change' || entry.name === 'spec')).toBe(false); + }); + it('uses one --store description on every lifecycle command', () => { const expected = COMMON_FLAGS.store.description; const seen: string[] = []; From b435151b6808d3073e874a025c25c40ee445aa87 Mon Sep 17 00:00:00 2001 From: feiniao <2648955710@qq.com> Date: Tue, 11 Aug 2026 13:28:46 +0800 Subject: [PATCH 2/3] refactor(cli): remove unreachable noun command code Co-Authored-By: Claude --- src/cli/index.ts | 2 +- src/commands/change.ts | 197 ------------------ src/commands/show.ts | 2 - src/commands/spec.ts | 162 +------------- src/commands/validate.ts | 1 - src/core/completions/types.ts | 2 +- src/core/root-selection.ts | 4 +- test/commands/show.test.ts | 45 ++++ .../core/commands/change-command.list.test.ts | 136 ------------ ...te.test.ts => change-command.show.test.ts} | 25 +-- 10 files changed, 56 insertions(+), 520 deletions(-) delete mode 100644 test/core/commands/change-command.list.test.ts rename test/core/commands/{change-command.show-validate.test.ts => change-command.show.test.ts} (88%) diff --git a/src/cli/index.ts b/src/cli/index.ts index 033a07d236..071203de03 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -95,7 +95,7 @@ const { version } = require('../../package.json'); /** * Get the full command path for nested commands. - * For example: 'change show' -> 'change:show' + * For example: 'store add' -> 'store:add' */ export function getCommandPath(command: Command): string { const names: string[] = []; diff --git a/src/commands/change.ts b/src/commands/change.ts index 487883c961..ad51421c47 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -1,28 +1,12 @@ 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 { VALIDATION_MESSAGES } from '../core/validation/constants.js'; -import { ChangeParser } from '../core/parsers/change-parser.js'; import { Change } from '../core/schemas/index.js'; import type { RootOutput } from '../core/root-selection.js'; import { isInteractive } from '../utils/interactive.js'; import { getActiveChangeIds } from '../utils/item-discovery.js'; -import { getTaskProgressForChange } from '../utils/task-progress.js'; import { FileSystemUtils } from '../utils/file-system.js'; -/** - * True only when `target` is definitively absent. An EACCES or I/O failure - * means existence cannot be determined, so callers fall through to their - * read-error path rather than claim the file was never written. - */ -async function isDefinitelyMissing(target: string): Promise { - return fs - .access(target) - .then(() => false) - .catch((error: NodeJS.ErrnoException) => error?.code === 'ENOENT'); -} - /** * A change is a directory directly under changes/. Rejecting anything else up * front keeps a traversing name (`../..`) from reading a proposal outside the @@ -36,8 +20,6 @@ export class ChangeCommand { private converter: JsonConverter; private rootPath?: string; - // rootPath is set only by root-aware callers (top-level `show`); the - // deprecated noun-form commands stay cwd-based. constructor(rootPath?: string) { this.converter = new JsonConverter(); this.rootPath = rootPath; @@ -139,187 +121,8 @@ export class ChangeCommand { } } - /** - * 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 { - const changesPath = path.join(process.cwd(), 'openspec', 'changes'); - - // Same directory-based resolution as `openspec list`, the command this - // deprecated alias points users at. Every output path below already - // tolerates a change whose proposal.md is missing or unreadable. - const changes = await getActiveChangeIds(); - - if (options?.json) { - const changeDetails = await Promise.all( - changes.map(async (changeName) => { - const changeDir = path.join(changesPath, changeName); - const proposalPath = path.join(changeDir, 'proposal.md'); - - // Resolve task progress through the shared tracked-tasks helper so - // this deprecated noun-form list cannot re-fork the resolution - // (#1202). Tasks are independent of the proposal: a change can carry - // tasks before, or without, a proposal.md. - const taskStatus = await getTaskProgressForChange(changesPath, changeName, process.cwd()); - - // No proposal yet is an ordinary state (scaffolded change, or a - // schema with no proposal artifact), so name the change rather than - // labelling it Unknown. Unknown stays for a proposal that exists but - // cannot be read or parsed. - if (await isDefinitelyMissing(proposalPath)) { - return { id: changeName, title: changeName, deltaCount: 0, taskStatus }; - } - - try { - FileSystemUtils.assertPathWithin(changeDir, proposalPath); - const content = await fs.readFile(proposalPath, 'utf-8'); - const parser = new ChangeParser(content, changeDir); - const change = await parser.parseChangeWithDeltas(changeName); - - return { - id: changeName, - title: this.extractTitle(content, changeName), - deltaCount: change.deltas.length, - taskStatus, - }; - } catch { - return { id: changeName, title: 'Unknown', deltaCount: 0, taskStatus }; - } - }) - ); - - 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 changeDir = path.join(changesPath, changeName); - const proposalPath = path.join(changeDir, 'proposal.md'); - const { total, completed } = await getTaskProgressForChange(changesPath, changeName, process.cwd()); - const taskStatusText = total > 0 ? ` [tasks ${completed}/${total}]` : ''; - if (await isDefinitelyMissing(proposalPath)) { - console.log(`${changeName}: (no proposal.md yet)${taskStatusText}`); - continue; - } - try { - FileSystemUtils.assertPathWithin(changeDir, proposalPath); - const content = await fs.readFile(proposalPath, 'utf-8'); - const title = this.extractTitle(content, changeName); - const parser = new ChangeParser(content, 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)${taskStatusText}`); - } - } - } - } - - async validate(changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }): Promise { - const changesPath = path.join(process.cwd(), 'openspec', 'changes'); - - if (!changeName) { - const canPrompt = isInteractive(options); - const changes = await getActiveChangeIds(); - if (canPrompt && changes.length > 0) { - const { select } = await import('@inquirer/prompts'); - const selected = await select({ - message: 'Select a change to validate', - choices: changes.map(id => ({ name: id, value: id })), - }); - changeName = selected; - } else { - if (changes.length === 0) { - console.error('No change specified. No active changes found.'); - } else { - console.error(`No change specified. Available IDs: ${changes.join(', ')}`); - } - console.error('Hint: use "openspec list" to view available changes.'); - process.exitCode = 1; - return; - } - } - - const changeDir = path.join(changesPath, changeName); - if (!isChangeDirectoryName(changesPath, changeDir)) { - throw new Error(`Change "${changeName}" not found at ${changeDir}`); - } - try { - await fs.access(changeDir); - } catch { - throw new Error(`Change "${changeName}" not found at ${changeDir}`); - } - - const validator = new Validator(options?.strict || false); - const report = await validator.validateChangeDeltaSpecs(changeDir, { - // Derived from changesPath so the main specs come from the same root the - // change itself was resolved against. - mainSpecsDir: path.join(path.dirname(changesPath), 'specs'), - projectRoot: path.dirname(path.dirname(changesPath)), - }); - - 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 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}`); - }); - // Next steps footer to guide fixing issues - this.printNextSteps(report.issues); - if (!options?.json) { - process.exitCode = 1; - } - } - } - } - private extractTitle(content: string, changeName: string): string { const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im); return match ? match[1].trim() : changeName; } - - private printNextSteps(issues: Array<{ message: string }> = []): void { - const bullets: string[] = []; - // Branch on the exact marker messages: the generic no-deltas guidance - // also mentions skip_specs and must not trigger the marker bullets. - const conflictIssue = issues.some(i => - i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_CONFLICT) - ); - const invalidMarkerIssue = issues.some(i => - i.message.includes(VALIDATION_MESSAGES.CHANGE_SKIP_SPECS_INVALID_METADATA) - ); - if (conflictIssue) { - bullets.push('- This change declares skip_specs (no spec deltas): delete the files under specs/, or remove skip_specs from .openspec.yaml if requirements do change'); - bullets.push('- skip_specs is only honored when .openspec.yaml is valid change metadata (schema: is required)'); - } else if (invalidMarkerIssue) { - bullets.push('- Fix .openspec.yaml so the skip_specs marker can be honored (schema: is required)'); - bullets.push('- Or remove skip_specs from .openspec.yaml and add delta specs instead'); - } else { - 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 show --type change --json --deltas-only'); - } - console.error('Next steps:'); - bullets.forEach(b => console.error(` ${b}`)); - } } diff --git a/src/commands/show.ts b/src/commands/show.ts index b9b64518a5..522969ff2f 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -167,7 +167,6 @@ export class ShowCommand { return; } console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`); - // The noun-form commands are cwd-based and cannot reach a selected store. if (isStoreSelectedRoot(root)) { console.error('Pass --type change|spec.'); } else { @@ -191,7 +190,6 @@ export class ShowCommand { console.error('Nothing to show. Try one of:'); console.error(` ${withStoreFlag(root, 'openspec show ')}`); if (isStoreSelectedRoot(root)) { - // The noun-form commands are cwd-based and cannot reach a selected store. console.error(` ${withStoreFlag(root, 'openspec show --type change')}`); console.error(` ${withStoreFlag(root, 'openspec show --type spec')}`); } else { diff --git a/src/commands/spec.ts b/src/commands/spec.ts index e459342db5..27ba787628 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -1,17 +1,12 @@ -import { program } from 'commander'; import { existsSync, readFileSync } from 'fs'; import path, { 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'; import type { RootOutput } from '../core/root-selection.js'; import { isInteractive } from '../utils/interactive.js'; import { getSpecIds } from '../utils/item-discovery.js'; -import { discoverSpecFiles } from '../utils/spec-discovery.js'; import { FileSystemUtils } from '../utils/file-system.js'; -const SPECS_DIR = 'openspec/specs'; - function assertSpecPath(specsDir: string, specPath: string): void { const relativePath = path.relative(path.resolve(specsDir), path.resolve(specPath)); if ( @@ -55,7 +50,7 @@ function validateRequirementIndex(spec: Spec, requirementOpt?: string): number | if (!Number.isInteger(index) || index < 1 || index > spec.requirements.length) { throw new Error(`Requirement ${requirementOpt} not found`); } - return index - 1; // convert to 0-based + return index - 1; } function filterSpec(spec: Spec, options: ShowOptions): Spec { @@ -92,19 +87,17 @@ function printSpecTextRaw(specsDir: string, specPath: string): void { export class SpecCommand { private specsDir: string; - private rootPath?: string; + private rootPath: string; - // rootPath is set only by root-aware callers (top-level `show`); the - // deprecated noun-form commands stay cwd-based. - constructor(rootPath?: string) { + constructor(rootPath = process.cwd()) { this.rootPath = rootPath; - this.specsDir = rootPath ? join(rootPath, 'openspec', 'specs') : SPECS_DIR; + this.specsDir = join(rootPath, 'openspec', 'specs'); } async show(specId?: string, options: ShowOptions = {}): Promise { if (!specId) { const canPrompt = isInteractive(options); - const specIds = await getSpecIds(this.rootPath ?? process.cwd()); + const specIds = await getSpecIds(this.rootPath); if (canPrompt && specIds.length > 0) { const { select } = await import('@inquirer/prompts'); specId = await select({ @@ -119,10 +112,7 @@ export class SpecCommand { const specPath = join(this.specsDir, specId, 'spec.md'); assertSpecPath(this.specsDir, specPath); if (!existsSync(specPath)) { - // Root-aware callers get the absolute path; the cwd-based noun form - // keeps its historical forward-slash relative message on all platforms. - const displayPath = this.rootPath ? specPath : `openspec/specs/${specId}/spec.md`; - throw new Error(`Spec '${specId}' not found at ${displayPath}`); + throw new Error(`Spec '${specId}' not found at ${specPath}`); } if (options.json) { @@ -146,143 +136,3 @@ export class SpecCommand { printSpecTextRaw(this.specsDir, specPath); } } - -export function registerSpecCommand(rootProgram: typeof program) { - const specCommand = rootProgram - .command('spec') - .description('Manage and view OpenSpec specifications'); - - // 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]') - .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 ', 'JSON only: Show specific requirement by ID (1-based)') - .option('--no-interactive', 'Disable interactive prompts') - .action(async (specId: string | undefined, options: ShowOptions & { noInteractive?: boolean }) => { - try { - 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; - } - }); - - specCommand - .command('list') - .description('List all available specifications') - .option('--json', 'Output as JSON') - .option('--long', 'Show id and title with counts') - .action(async (options: { json?: boolean; long?: boolean }) => { - try { - if (!existsSync(SPECS_DIR)) { - console.log('No items found'); - return; - } - - const discovered = await discoverSpecFiles(SPECS_DIR); - const specs = discovered - .map(({ id, specFile }) => { - try { - assertSpecPath(SPECS_DIR, specFile); - const spec = parseSpecFromFile(SPECS_DIR, specFile, id); - - return { - id, - title: spec.name, - requirementCount: spec.requirements.length - }; - } catch { - return { - id, - title: id, - requirementCount: 0 - }; - } - }) - .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') - .option('--no-interactive', 'Disable interactive prompts') - .action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => { - try { - if (!specId) { - const canPrompt = isInteractive(options); - const specIds = await getSpecIds(); - if (canPrompt && specIds.length > 0) { - const { select } = await import('@inquirer/prompts'); - specId = await select({ - message: 'Select a spec to validate', - choices: specIds.map(id => ({ name: id, value: id })), - }); - } else { - throw new Error('Missing required argument '); - } - } - - const specPath = join(SPECS_DIR, specId, 'spec.md'); - assertSpecPath(SPECS_DIR, specPath); - - if (!existsSync(specPath)) { - throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`); - } - - const validator = new Validator(options.strict); - assertSpecPath(SPECS_DIR, specPath); - 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; -} diff --git a/src/commands/validate.ts b/src/commands/validate.ts index df9c5fa3b5..43a4831abd 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -179,7 +179,6 @@ export class ValidateCommand { return; } console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`); - // The noun-form commands are cwd-based and cannot reach a selected store. if (isStoreSelectedRoot(root)) { console.error('Pass --type change|spec.'); } else { diff --git a/src/core/completions/types.ts b/src/core/completions/types.ts index 90df710b28..4a0a941254 100644 --- a/src/core/completions/types.ts +++ b/src/core/completions/types.ts @@ -78,7 +78,7 @@ export interface CommandDefinition { flags: FlagDefinition[]; /** - * Subcommands (e.g., "change show", "spec validate") + * Subcommands (e.g., "store add", "config profile") */ subcommands?: CommandDefinition[]; diff --git a/src/core/root-selection.ts b/src/core/root-selection.ts index 21108f5967..f84f1b2ea0 100644 --- a/src/core/root-selection.ts +++ b/src/core/root-selection.ts @@ -475,8 +475,8 @@ export function toRootOutput(root: ResolvedOpenSpecRoot): RootOutput { /** * A store-selected root — explicit `--store`, a declared pointer, or the - * global default. Cross-root behavior (absolute paths, --store hints, - * suppressed noun-form suggestions) keys on this, never on `source` directly. + * global default. Cross-root behavior (absolute paths and --store hints) keys + * on this, never on `source` directly. */ export function isStoreSelectedRoot( root: ResolvedOpenSpecRoot diff --git a/test/commands/show.test.ts b/test/commands/show.test.ts index 9fade08ec0..171a8d5010 100644 --- a/test/commands/show.test.ts +++ b/test/commands/show.test.ts @@ -98,6 +98,51 @@ describe('top-level show command', () => { } }); + it('prints spec markdown unchanged in text mode', async () => { + const output = execFileSync('node', [openspecBin, 'show', 'auth', '--type', 'spec'], { + cwd: testDir, + encoding: 'utf-8', + }); + const raw = await fs.readFile(path.join(specsDir, 'auth', 'spec.md'), 'utf-8'); + + expect(output.trim()).toBe(raw.trim()); + }); + + it('excludes spec scenarios with --no-scenarios', () => { + const output = execFileSync( + 'node', + [openspecBin, 'show', 'auth', '--type', 'spec', '--json', '--no-scenarios'], + { cwd: testDir, encoding: 'utf-8' } + ); + const json = JSON.parse(output); + + expect(json.requirements).toHaveLength(1); + expect(json.requirements[0].scenarios).toHaveLength(0); + }); + + it('selects a specific spec requirement with --requirement', () => { + const output = execFileSync( + 'node', + [openspecBin, 'show', 'auth', '--type', 'spec', '--json', '--requirement', '1'], + { cwd: testDir, encoding: 'utf-8' } + ); + const json = JSON.parse(output); + + expect(json.requirements).toHaveLength(1); + expect(json.requirements[0].text).toContain('Text'); + }); + + it('rejects an unknown spec with a not-found error', () => { + const result = spawnSync( + 'node', + [openspecBin, 'show', 'missing-spec', '--type', 'spec'], + { cwd: testDir, encoding: 'utf-8' } + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Spec 'missing-spec' not found"); + }); + it('handles ambiguity and suggests --type', async () => { // create matching spec and change named 'foo' await fs.mkdir(path.join(changesDir, 'foo'), { recursive: true }); diff --git a/test/core/commands/change-command.list.test.ts b/test/core/commands/change-command.list.test.ts deleted file mode 100644 index fdd72c8f18..0000000000 --- a/test/core/commands/change-command.list.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ChangeCommand } from '../../../src/commands/change.js'; -import path from 'path'; -import { promises as fs } from 'fs'; -import os from 'os'; - -describe('ChangeCommand.list', () => { - let cmd: ChangeCommand; - let tempRoot: string; - let originalCwd: string; - - beforeAll(async () => { - cmd = new ChangeCommand(); - originalCwd = process.cwd(); - tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-change-command-list-')); - const changeDir = path.join(tempRoot, 'openspec', 'changes', 'demo'); - await fs.mkdir(changeDir, { recursive: true }); - const proposal = `# Change: Demo\n\n## Why\nTest list.\n\n## What Changes\n- **auth:** Add requirement`; - await fs.writeFile(path.join(changeDir, 'proposal.md'), proposal, 'utf-8'); - await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n- [ ] Task 2\n', 'utf-8'); - process.chdir(tempRoot); - }); - - afterAll(async () => { - process.chdir(originalCwd); - await fs.rm(tempRoot, { recursive: true, force: true }); - }); - - 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; - } - - }); -}); - -describe('ChangeCommand.list with a change that has no proposal.md', () => { - let cmd: ChangeCommand; - let tempRoot: string; - let originalCwd: string; - - const capture = async (run: () => Promise): Promise => { - const logs: string[] = []; - const origLog = console.log; - try { - console.log = (msg?: any, ...args: any[]) => { - logs.push([msg, ...args].filter(Boolean).join(' ')); - }; - await run(); - return logs.join('\n'); - } finally { - console.log = origLog; - } - }; - - beforeAll(async () => { - cmd = new ChangeCommand(); - originalCwd = process.cwd(); - tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-change-list-noproposal-')); - // What `openspec new change` leaves behind, plus tasks: no proposal.md. - const scaffolded = path.join(tempRoot, 'openspec', 'changes', 'scaffolded'); - await fs.mkdir(scaffolded, { recursive: true }); - await fs.writeFile(path.join(scaffolded, '.openspec.yaml'), 'schema: spec-driven\n', 'utf-8'); - await fs.writeFile(path.join(scaffolded, 'tasks.md'), '- [x] Task 1\n- [ ] Task 2\n', 'utf-8'); - process.chdir(tempRoot); - }); - - afterAll(async () => { - process.chdir(originalCwd); - await fs.rm(tempRoot, { recursive: true, force: true }); - }); - - it('lists it, matching what `openspec list` resolves', async () => { - expect(await capture(() => cmd.list({}))).toContain('scaffolded'); - }); - - it('--long reports the missing proposal and keeps task counts', async () => { - const out = await capture(() => cmd.list({ long: true })); - expect(out).toContain('scaffolded: (no proposal.md yet)'); - expect(out).toContain('[tasks 1/2]'); - expect(out).not.toContain('(unable to read)'); - }); - - it('--json names the change instead of "Unknown" and keeps task counts', async () => { - const parsed = JSON.parse(await capture(() => cmd.list({ json: true }))); - expect(parsed).toHaveLength(1); - expect(parsed[0]).toMatchObject({ - id: 'scaffolded', - title: 'scaffolded', - deltaCount: 0, - taskStatus: { total: 2, completed: 1 }, - }); - }); -}); diff --git a/test/core/commands/change-command.show-validate.test.ts b/test/core/commands/change-command.show.test.ts similarity index 88% rename from test/core/commands/change-command.show-validate.test.ts rename to test/core/commands/change-command.show.test.ts index b732067cd0..ce9e90d45d 100644 --- a/test/core/commands/change-command.show-validate.test.ts +++ b/test/core/commands/change-command.show.test.ts @@ -4,7 +4,7 @@ import path from 'path'; import { promises as fs } from 'fs'; import os from 'os'; -describe('ChangeCommand.show/validate', () => { +describe('ChangeCommand.show', () => { let cmd: ChangeCommand; let changeName: string; let tempRoot: string; @@ -165,27 +165,4 @@ describe('ChangeCommand.show/validate', () => { }); }); - it('validate --strict --json returns a report with valid boolean', async () => { - 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; - } - }); - - it('validate rejects a traversing change name', async () => { - await expect(cmd.validate(path.join('..', '..', 'outside'))).rejects.toThrow(/not found at/u); - }); }); From b8bbc40300942d724da84c244e1e59e6df433374 Mon Sep 17 00:00:00 2001 From: feiniao <2648955710@qq.com> Date: Tue, 11 Aug 2026 13:46:22 +0800 Subject: [PATCH 3/3] test(cli): cover canonical spec listing Co-Authored-By: Claude --- .../remove-legacy-noun-command-groups.md | 7 ++ test/commands/list-specs.test.ts | 85 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 .changeset/remove-legacy-noun-command-groups.md create mode 100644 test/commands/list-specs.test.ts diff --git a/.changeset/remove-legacy-noun-command-groups.md b/.changeset/remove-legacy-noun-command-groups.md new file mode 100644 index 0000000000..080583063a --- /dev/null +++ b/.changeset/remove-legacy-noun-command-groups.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": major +--- + +### Breaking Changes + +- Removed the deprecated `openspec change ...` and `openspec spec ...` command groups. Use the canonical verb-first commands instead: `openspec show --type change|spec`, `openspec list --changes|--specs`, and `openspec validate --type change|spec`. diff --git a/test/commands/list-specs.test.ts b/test/commands/list-specs.test.ts new file mode 100644 index 0000000000..11e1a742de --- /dev/null +++ b/test/commands/list-specs.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { runCLI } from '../helpers/run-cli.js'; + +describe('top-level list command for specs', () => { + let projectDir: string; + let specsDir: string; + + beforeEach(async () => { + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-list-specs-')); + specsDir = path.join(projectDir, 'openspec', 'specs'); + + await fs.mkdir(path.join(specsDir, 'auth'), { recursive: true }); + await fs.writeFile( + path.join(specsDir, 'auth', 'spec.md'), + `## Purpose +Authentication behavior. + +## Requirements + +### Requirement: Sign in +The system SHALL allow sign in. + +#### Scenario: Valid credentials +- **WHEN** valid credentials are submitted +- **THEN** access is granted + +### Requirement: Sign out +The system SHALL allow sign out. + +#### Scenario: Active session +- **WHEN** sign out is requested +- **THEN** the session ends +`, + 'utf-8' + ); + + await fs.mkdir(path.join(specsDir, 'payment'), { recursive: true }); + await fs.writeFile( + path.join(specsDir, 'payment', 'spec.md'), + `## Purpose +Payment behavior. + +## Requirements + +### Requirement: Process payment +The system SHALL process payments. + +#### Scenario: Valid payment +- **WHEN** a valid payment is submitted +- **THEN** it is processed +`, + 'utf-8' + ); + }); + + afterEach(async () => { + await fs.rm(projectDir, { recursive: true, force: true }); + }); + + it('lists spec IDs with requirement counts', async () => { + const result = await runCLI(['list', '--specs'], { cwd: projectDir }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Specs:'); + expect(result.stdout).toMatch(/auth\s+requirements 2/); + expect(result.stdout).toMatch(/payment\s+requirements 1/); + expect(result.stdout.indexOf('auth')).toBeLessThan(result.stdout.indexOf('payment')); + }); + + it('outputs the supported spec list JSON envelope', async () => { + const result = await runCLI(['list', '--specs', '--json'], { cwd: projectDir }); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.specs).toEqual([ + { id: 'auth', requirementCount: 2 }, + { id: 'payment', requirementCount: 1 }, + ]); + expect(output.root).toMatchObject({ path: projectDir }); + }); +});