Skip to content

openspec show --diff feature#980

Open
bsmedberg-xometry wants to merge 4 commits intoFission-AI:mainfrom
bsmedberg-xometry:spec-diffs
Open

openspec show --diff feature#980
bsmedberg-xometry wants to merge 4 commits intoFission-AI:mainfrom
bsmedberg-xometry:spec-diffs

Conversation

@bsmedberg-xometry
Copy link
Copy Markdown

@bsmedberg-xometry bsmedberg-xometry commented Apr 16, 2026

Why

When proposing a change, delta spec files under openspec/changes/<name>/specs/ duplicate large portions of existing specs in openspec/specs/. A MODIFIED requirement must include the entire requirement block (all scenarios), making it hard to see what actually changed versus what was copied verbatim. This friction slows review and increases the risk of errors.

What Changes

Add a --diff flag to openspec show (change type) that renders each delta spec as a unified diff against the corresponding base spec in openspec/specs/. This is the smallest viable improvement: it doesn't change the storage format or workflow, just adds a new way to view the deltas.

Approaches considered

Three approaches were evaluated:

  1. Stop storing delta specs; edit base specs on the branch directly. This would eliminate duplication entirely but conflicts with the spec-driven workflow where changes are proposed, reviewed, and archived as discrete artifacts before the base specs are updated. Deferred — would require rethinking the change lifecycle.

  2. Store deltas as diffs instead of full specs. The specs/<cap>/spec.md files inside a change would contain unified diffs (or a structured delta format) rather than full requirement text. This eliminates duplication at the source but complicates authoring (AI and humans must produce correct diffs), parsing, validation, and the archive/apply step that merges deltas into base specs. Promising for a future change, but high complexity.

  3. Add openspec show --diff to render deltas against base specs. (Chosen.) Leave the storage format unchanged. When displaying a change, compute the diff on the fly by comparing each delta spec file against its matching base spec. This gives reviewers the view they need with minimal code changes and zero workflow disruption.

What this change delivers

  • A --diff flag on openspec show <change> (and openspec change show <change>) that outputs a human-readable unified diff per delta spec
  • In JSON mode (--json --diff), each delta includes a diff field with the unified diff text
  • Text mode shows colorized unified diffs (additions in green, removals in red)
  • New delta specs (no base spec exists) show the full content as all-additions
  • When --diff is used without --json, the output is the diffs only (no proposal text)

Capabilities

New Capabilities

None.

Modified Capabilities

  • cli-show: Add --diff flag support for change display, computing unified diffs of delta specs against their base specs

Non-goals

  • Changing the delta spec storage format (approach 2 above — future work)
  • Changing when or how base specs are updated (approach 1 above — future work)
  • Diffing non-spec artifacts (proposal, design, tasks)
  • Git-aware diffing (this compares files on disk, not git history)

Impact

  • src/commands/show.ts — pass --diff flag through to change display
  • src/commands/change.ts — implement diff rendering in show() for text and JSON modes
  • src/cli/index.ts — register --diff option on the show and change show commands
  • New utility: diff computation between two markdown strings (can use a lightweight diff library or Node built-in)
  • src/commands/spec.ts or src/utils/ — helper to resolve base spec path for a given delta spec

Specifications Changed (diffs)

cli-show

ADDED: Requirement block extraction for diffing

    ### Requirement: Requirement block extraction for diffing
    
    The system SHALL extract raw markdown text for individual requirement blocks from spec files to support per-requirement diffing.
    
    #### Scenario: Extract requirement block by name
    
    - **WHEN** a requirement name is provided and a spec file contains a matching `### Requirement: <name>` header
    - **THEN** the system SHALL return the raw markdown text from the `### Requirement:` header line through all content until the next `###` header at the same or higher level (or end of file)
    - **AND** matching SHALL be case-insensitive and whitespace-insensitive
    
    #### Scenario: Requirement name not found in base spec
    
    - **WHEN** a MODIFIED delta requirement name does not match any `### Requirement:` header in the base spec
    - **THEN** the system SHALL return null for the base block
    - **AND** the caller SHALL display the full MODIFIED requirement text with a warning that no base was found
    
    #### Scenario: Cross-platform path resolution for base specs
    
    - **WHEN** resolving the base spec path for a given capability
    - **THEN** the system SHALL use `path.join()` for filesystem operations to build `openspec/specs/<cap>/spec.md`
    - **AND** display paths SHALL use forward slashes regardless of platform

MODIFIED: Output format options

- **WHEN** showing a change or a spec via the top-level command
 - **THEN** accept common flags such as `--json`
 - **AND** pass through type-specific flags to the corresponding implementation
-  - Change-only flags: `--deltas-only` (alias `--requirements-only` deprecated)
+  - Change-only flags: `--deltas-only` (alias `--requirements-only` deprecated), `--diff`
   - Spec-only flags: `--requirements`, `--no-scenarios`, `-r/--requirement`
 - **AND** ignore irrelevant flags for the detected type with a warning
+
+#### Scenario: Change-specific options
+
+- **WHEN** showing a change with `openspec show <change-name> --deltas-only`
+- **THEN** display only the deltas in JSON format
+- **AND** maintain compatibility with existing change show options
+
+#### Scenario: Spec-specific options
+
+- **WHEN** showing a spec with `openspec show <spec-id> --requirements`
+- **THEN** display only requirements in JSON format
+- **AND** support other spec options (--no-scenarios, -r)
+- **AND** maintain compatibility with existing spec show options
+
+#### Scenario: Text mode change display
+
+- **WHEN** executing `openspec show <change-name>` in text mode (no `--json`)
+- **THEN** display the proposal markdown text
+- **AND** if delta spec files exist under `openspec/changes/<change-name>/specs/`, display each delta spec's full content grouped by capability name
+
+#### Scenario: Diff output in text mode
+
+- **WHEN** executing `openspec show <change-name> --diff` in text mode (no `--json`)
+- **THEN** display the proposal markdown text
+- **AND** for each delta spec file under `openspec/changes/<change-name>/specs/<cap>/spec.md`, iterate parsed deltas grouped by capability
+- **AND** for ADDED requirements, display the full requirement text with a green "ADDED" label
+- **AND** for REMOVED requirements, display the removal notice (Reason/Migration) with a red "REMOVED" label
+- **AND** for RENAMED requirements, display the FROM:/TO: with a cyan "RENAMED" label
+- **AND** for MODIFIED requirements, extract the matching requirement block from the base spec at `openspec/specs/<cap>/spec.md` by `### Requirement:` header name, compute a unified diff of the base block vs the delta block, and display it colorized (green for `+` lines, red for `-` lines, dim for context lines and diff headers)
+- **AND** when a MODIFIED requirement's name matches a RENAMED entry's TO name in the same spec, the system SHALL look up the base block using the RENAMED entry's FROM name instead
+- **AND** if a MODIFIED requirement has no matching base requirement (and no corresponding RENAMED entry), display the full text with a warning
+
+#### Scenario: Diff output in JSON mode
+
+- **WHEN** executing `openspec show <change-name> --json --diff`
+- **THEN** the output SHALL use the same JSON structure as `--json` alone (`{ id, title, deltaCount, deltas }`)
+- **AND** for each MODIFIED delta, the delta object SHALL include an additional `diff` string field containing the unified diff of the base requirement block vs the delta requirement block
+- **AND** when a MODIFIED requirement corresponds to a RENAMED entry, the base block SHALL be looked up using the RENAMED FROM name
+- **AND** ADDED, REMOVED, and RENAMED deltas SHALL NOT have a `diff` field
+- **AND** if a MODIFIED requirement has no matching base requirement, the delta object SHALL include a `warning` string field instead of `diff`
+
+#### Scenario: Diff with no delta specs
+
+- **WHEN** executing `openspec show <change-name> --diff` and the change has no delta spec files
+- **THEN** print a message indicating no delta specs exist for this change
+- **AND** exit with code 0
+
+#### Scenario: Diff flag on non-change item
+
+- **WHEN** executing `openspec show <spec-name> --diff`
+- **THEN** ignore the `--diff` flag with a warning (flag is not applicable to specs)

Summary by CodeRabbit

  • New Features
    • Added --diff to show per-requirement unified diffs in text and JSON modes; text shows colorized diffs and labeled ADDED/REMOVED/RENAMED entries, JSON adds a diff string for MODIFIED items.
  • Documentation
    • Added design/proposal/specification docs describing CLI behavior, output formats, and fallback rules.
  • Tests
    • Added unit and integration tests covering diff extraction, generation, rename handling, and CLI output.
  • Chores
    • Added runtime dependency for diff generation.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 16, 2026

📝 Walkthrough

Walkthrough

Adds a new --diff option to openspec show/change show to render per-requirement unified diffs for MODIFIED delta specs by extracting requirement blocks from base specs and delta specs, computing rename-aware lookups, and emitting colorized text or JSON-embedded diffs; includes utilities, CLI flag registration, command plumbing, dependency, and tests.

Changes

Cohort / File(s) Summary
Specs & Design Docs
openspec/changes/spec-diffs/.openspec.yaml, openspec/changes/spec-diffs/design.md, openspec/changes/spec-diffs/proposal.md, openspec/changes/spec-diffs/specs/cli-show/spec.md, openspec/changes/spec-diffs/tasks.md
New metadata, design, proposal, CLI-spec, and task docs describing --diff behavior, extraction rules, output formats (text/JSON), and implementation scope.
Requirement Diff Utilities
src/utils/requirement-diff.ts
New exports: extractRequirementBlock(specContent, requirementName), diffRequirementBlock(baseBlock, deltaBlock, label), and buildRenameMap(renames) to extract requirement markdown blocks, generate unified diffs, and build rename lookups.
CLI Registration
src/cli/index.ts
Registered new --diff flag on openspec show and change show commands.
Commands & Rendering
src/commands/change.ts, src/commands/show.ts
Added options.diff to ChangeCommand.show(), integrated diff enrichment (enrichDeltasWithDiffs()), added text-mode diff rendering (showSpecDiffs(), printDiffText()), and updated CHANGE_FLAG_KEYS to include diff so irrelevant-flag warnings behave correctly.
Dependency
package.json
Added diff dependency (^9.0.0) for patch/patch-hunk generation.
Tests
test/utils/requirement-diff.test.ts, test/commands/show-diff.test.ts
New Vitest suites covering extraction, diff generation, rename-map behavior, and end-to-end CLI --diff behavior for text and JSON outputs, plus edge cases (no base match, no delta specs, RENAMED+MODIFIED).

Sequence Diagram

sequenceDiagram
    actor User
    participant CLI as "CLI Parser"
    participant ShowCmd as "ShowCommand"
    participant ChangeCmd as "ChangeCommand"
    participant Utils as "requirement-diff Utils"
    participant FSys as "File System"

    User->>CLI: openspec show mychange --diff
    CLI->>ShowCmd: execute(item, {diff: true})
    ShowCmd->>ChangeCmd: invoke show for change (diff flag)
    ChangeCmd->>FSys: read delta spec files
    FSys-->>ChangeCmd: return delta contents
    ChangeCmd->>Utils: parse delta, buildRenameMap
    loop per-capability / per-delta
        ChangeCmd->>Utils: extractRequirementBlock(deltaContent, name)
        Utils-->>ChangeCmd: delta block
        ChangeCmd->>FSys: read base spec file
        FSys-->>ChangeCmd: base spec content
        ChangeCmd->>Utils: extractRequirementBlock(baseContent, lookupName)
        Utils-->>ChangeCmd: base block or null
        ChangeCmd->>Utils: diffRequirementBlock(baseBlock, deltaBlock, label)
        Utils-->>ChangeCmd: unified diff text
    end
    alt --json
        ChangeCmd->>User: JSON output with diff fields for MODIFIED deltas
    else text
        ChangeCmd->>User: colorized unified diffs and labeled ADDED/REMOVED/RENAMED
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • TabishB
  • alfred-openspec

Poem

🐰 I hopped through specs to find each line,

Matched headers, traced the old to new design,
Patch hunks stitched where modified bits reside,
ADDED blooms green, REMOVED in scarlet stride,
A tiny rabbit cheers the diffs applied.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'openspec show --diff feature' directly and clearly describes the main change: adding a --diff flag feature to the openspec show command.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openspec/changes/spec-diffs/proposal.md`:
- Around line 21-25: Update the proposal to precisely document the JSON contract
for the --diff flag: state that in JSON mode (used by openspec show / openspec
change show with --json --diff) only deltas with type MODIFIED include a "diff"
field containing the unified-diff text; ADDED, REMOVED, and RENAMED deltas do
not include "diff" (they may omit the field or include null), and cases with no
base spec return a "warning" field instead of "diff"; also document that text
mode still shows full content as all-additions for new specs and colorized
unified diffs for modified deltas, and that when --diff is used without --json
the CLI prints only diffs (no proposal text).

In `@openspec/changes/spec-diffs/specs/cli-show/spec.md`:
- Around line 44-46: The spec currently requires printing the proposal markdown
before diffs for the command "openspec show <change-name> --diff" which is
incorrect; update the text in the spec (the WHEN/THEN lines that reference
"openspec show <change-name> --diff") so that WHEN running the CLI with --diff
in text mode the output is diff-only (do not print the proposal markdown first)
and THEN describe that the CLI should iterate parsed deltas grouped by
capability for each delta spec file under
openspec/changes/<change-name>/specs/<cap>/spec.md and render only the diffs in
text form.

In `@src/commands/change.ts`:
- Around line 267-268: The loop that builds REMOVED result entries currently
uses only plan.removed (a list of names) and synthesizes raw as `###
Requirement: ${name}`, losing the original removed body. Fix by attaching the
actual removed delta body to the result.raw instead of the synthesized header:
change plan.removed to carry the full removed entry (e.g., objects like { name,
raw }) or lookup the removed text from whatever store holds original deltas
(e.g., plan.removedDeltas / removedMap) and set results.push({ capability:
capName, operation: 'REMOVED', requirementName: name, raw: originalRaw }). If
plan.removed currently only contains names, update the planner that produces
plan.removed to include bodies so the change.ts loop can use the original
removed content.
- Around line 104-112: The code always reads and prints proposal.md
(proposalPath) before choosing diff vs content, causing --diff to still show the
proposal; move the read+console.log into the non-diff branch or guard it with if
(!options?.diff) so that when options?.diff is true you only call
this.showSpecDiffs(changeName, changesPath) and do not read/print proposalPath;
keep showSpecContent(changeName, changesPath) as the branch that reads/displays
proposal.md.
- Around line 233-235: The early-return when specDirs.length === 0 currently
silences the command; update that branch in src/commands/change.ts to emit an
explicit success message to the user instead of returning silently—locate the
check for specDirs.length === 0 and replace the plain return with a user-facing
output (e.g., via console.log or the project logger) like "No changes detected"
(or the project’s standard success message) and then return; ensure you use the
same output mechanism other commands use so formatting/exit behavior remains
consistent.

In `@src/utils/requirement-diff.ts`:
- Around line 35-40: The function diffRequirementBlock currently calls .trim()
on the unified hunk output, which strips leading whitespace from the first
context line and makes the hunk incorrect; update the return in
diffRequirementBlock (the expression that takes patch.hunks.flatMap(h =>
h.lines).join('\n')) to use .trimEnd() instead of .trim() so trailing newlines
are removed but leading context spaces are preserved.

In `@test/commands/show-diff.test.ts`:
- Around line 130-209: The tests currently assert that proposal text appears
even when invoking run(... --diff), which contradicts the intended contract; for
each test that calls run('show ... --diff') (e.g., the "text mode: shows
proposal then MODIFIED and ADDED full text" test using run('show auth-update
--type change --diff'), the "text mode: shows warning when MODIFIED has no
matching base" test using run('show no-match --type change --diff'), and the
"text mode: shows only proposal when change has no delta specs" test that calls
run('show empty-change --type change --diff'), remove or change the
expect(output).toContain(...) assertions that check for proposal text (e.g.,
'Improve auth.' and 'Test reason.') so the tests assert only diff output
presence (or absence of spec sections) when --diff is provided; keep proposal
assertions only in tests that call run without --diff (like run('show
auth-update --type change')).
- Around line 100-127: The test helpers run and runWithStderr interpolate
openspecBin into shell command strings which breaks if paths contain spaces or
shell metacharacters; replace execSync calls with execFileSync(process.execPath,
[openspecBin, ...args.split(' ')], ...) to avoid shell parsing (preserve
encoding and env, and for runWithStderr set stdio to ['pipe','pipe','pipe'] and
catch the thrown error to extract stdout/stderr as done currently); update the
function signatures/comments to indicate args is a space-separated string that
will be split into an argv array and ensure process.chdir(originalCwd) remains
in the finally block.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2972526d-ce17-4ec5-b106-68b0e47685a1

📥 Commits

Reviewing files that changed from the base of the PR and between f529b25 and b27aa01.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (12)
  • openspec/changes/spec-diffs/.openspec.yaml
  • openspec/changes/spec-diffs/design.md
  • openspec/changes/spec-diffs/proposal.md
  • openspec/changes/spec-diffs/specs/cli-show/spec.md
  • openspec/changes/spec-diffs/tasks.md
  • package.json
  • src/cli/index.ts
  • src/commands/change.ts
  • src/commands/show.ts
  • src/utils/requirement-diff.ts
  • test/commands/show-diff.test.ts
  • test/utils/requirement-diff.test.ts

Comment thread openspec/changes/spec-diffs/proposal.md Outdated
Comment on lines +44 to +46
- **WHEN** executing `openspec show <change-name> --diff` in text mode (no `--json`)
- **THEN** display the proposal markdown text
- **AND** for each delta spec file under `openspec/changes/<change-name>/specs/<cap>/spec.md`, iterate parsed deltas grouped by capability
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The text diff scenario is specifying the wrong output.

This says openspec show <change-name> --diff should still print the proposal markdown first, but the intended behavior for --diff is diff-only text output. If this stays, the spec will lock in the current implementation bug.

🧰 Tools
🪛 LanguageTool

[uncategorized] ~45-~45: Did you mean the formatting language “Markdown” (= proper noun)?
Context: ...-json`) - THEN display the proposal markdown text - AND for each delta spec file...

(MARKDOWN_NNP)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openspec/changes/spec-diffs/specs/cli-show/spec.md` around lines 44 - 46, The
spec currently requires printing the proposal markdown before diffs for the
command "openspec show <change-name> --diff" which is incorrect; update the text
in the spec (the WHEN/THEN lines that reference "openspec show <change-name>
--diff") so that WHEN running the CLI with --diff in text mode the output is
diff-only (do not print the proposal markdown first) and THEN describe that the
CLI should iterate parsed deltas grouped by capability for each delta spec file
under openspec/changes/<change-name>/specs/<cap>/spec.md and render only the
diffs in text form.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, the spec was correct: I want openspec show --diff to print the proposal and then the diffs of the changed requirements... it will display new requirements completely.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bsmedberg-xometry, thanks for the clarification! I was mistaken — the spec is correct as written. The intended behavior for openspec show <change-name> --diff in text mode is to print the proposal markdown first and then render the per-requirement diffs (with new/ADDED requirements shown in full). I'll note this for future reviews.


✏️ Learnings added
Learnt from: bsmedberg-xometry
Repo: Fission-AI/OpenSpec PR: 980
File: openspec/changes/spec-diffs/specs/cli-show/spec.md:44-46
Timestamp: 2026-04-16T19:44:15.889Z
Learning: In the OpenSpec repository (Fission-AI/OpenSpec), `openspec show <change-name> --diff` in text mode intentionally prints the proposal markdown text first, followed by per-requirement unified diffs for each delta spec. ADDED requirements are displayed in full. This is by design — do not flag the proposal-first ordering as a bug.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: lsmonki
Repo: Fission-AI/OpenSpec PR: 660
File: openspec/changes/archive/2026-02-04-hierarchical-specs-support/design.md:96-136
Timestamp: 2026-02-04T17:50:12.189Z
Learning: In OpenSpec, delta specs handle deletions at the requirement level using `## REMOVED Requirements` sections within the delta spec.md file. There is no concept of deleting an entire spec file through deltas.

Learnt from: CR
Repo: Fission-AI/OpenSpec PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T01:08:02.839Z
Learning: Always open `@/openspec/AGENTS.md` when the request mentions planning or proposals (words like proposal, spec, change, plan), introduces new capabilities, breaking changes, architecture shifts, or performance/security work, or sounds ambiguous and needs the authoritative spec before coding

Learnt from: HAHH9527
Repo: Fission-AI/OpenSpec PR: 884
File: docs/i18n/zh-cn/opsx.md:29-39
Timestamp: 2026-03-27T07:15:19.513Z
Learning: In the OpenSpec repository (Fission-AI/OpenSpec), Chinese translation documents under `docs/i18n/zh-cn/` are expected to faithfully mirror the structure and formatting of their English source documents. When a markdown issue (e.g., missing fenced code block language identifiers, MD040) exists in a translated file, the root cause is in the original English source. Do not flag formatting issues in translation files if they originate from the source document — suggest fixing the source instead.

Learnt from: CR
Repo: Fission-AI/OpenSpec PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T01:08:02.839Z
Learning: Use `@/openspec/AGENTS.md` to learn how to create and apply change proposals, spec format and conventions, and project structure and guidelines

Learnt from: fsilvaortiz
Repo: Fission-AI/OpenSpec PR: 761
File: docs/cli.md:442-445
Timestamp: 2026-02-25T20:02:57.093Z
Learning: In the OpenSpec spec-driven schema, `specs` and `design` artifacts are parallel dependencies that both depend only on `proposal`. This means either can appear first in a topological sort, and both orderings are valid depending on the specific sort algorithm used.

Comment thread src/commands/change.ts
Comment thread src/commands/change.ts
Comment on lines +233 to +235
if (specDirs.length === 0) {
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Surface the empty diff case to the user.

This returns silently when a change has no delta specs, but the feature contract calls for an explicit success message. Right now the command looks like it did nothing.

Suggested fix
     if (specDirs.length === 0) {
+      console.log(`No delta specs found for change "${changeName}"`);
       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (specDirs.length === 0) {
return;
}
if (specDirs.length === 0) {
console.log(`No delta specs found for change "${changeName}"`);
return;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/change.ts` around lines 233 - 235, The early-return when
specDirs.length === 0 currently silences the command; update that branch in
src/commands/change.ts to emit an explicit success message to the user instead
of returning silently—locate the check for specDirs.length === 0 and replace the
plain return with a user-facing output (e.g., via console.log or the project
logger) like "No changes detected" (or the project’s standard success message)
and then return; ensure you use the same output mechanism other commands use so
formatting/exit behavior remains consistent.

Comment thread src/commands/change.ts
Comment on lines +267 to +268
for (const name of plan.removed) {
results.push({ capability: capName, operation: 'REMOVED', requirementName: name, raw: `### Requirement: ${name}` });
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve removed delta bodies instead of synthesizing a header.

Only the requirement name survives here, so the renderer can never show the removal notice/body for REMOVED entries. That drops user-authored context from diff mode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/change.ts` around lines 267 - 268, The loop that builds REMOVED
result entries currently uses only plan.removed (a list of names) and
synthesizes raw as `### Requirement: ${name}`, losing the original removed body.
Fix by attaching the actual removed delta body to the result.raw instead of the
synthesized header: change plan.removed to carry the full removed entry (e.g.,
objects like { name, raw }) or lookup the removed text from whatever store holds
original deltas (e.g., plan.removedDeltas / removedMap) and set results.push({
capability: capName, operation: 'REMOVED', requirementName: name, raw:
originalRaw }). If plan.removed currently only contains names, update the
planner that produces plan.removed to include bodies so the change.ts loop can
use the original removed content.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intention was to keep displaying removed requirements as they are today: just with the name, not with the full body. So the spec and the implementation match, but I'm open to human maintainer feedback that it might be useful to display the full body of the removed requirement.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bsmedberg-xometry, thanks for the clarification! That makes sense — showing only the name for REMOVED entries is a reasonable design choice (you're already conveying what was removed without re-displaying content that will no longer exist), and the spec and implementation are aligned on this. I'll withdraw the concern.

If a future maintainer does want the full removed body shown in diff mode, the path forward would be to change plan.removed from a string[] of names to an array of { name, raw } objects in parseDeltaSpec, then surface raw in the DiffResult renderer.


✏️ Learnings added
Learnt from: bsmedberg-xometry
Repo: Fission-AI/OpenSpec PR: 980
File: src/commands/change.ts:267-268
Timestamp: 2026-04-16T19:47:10.854Z
Learning: In OpenSpec (Fission-AI/OpenSpec), `showSpecDiffs()` in `src/commands/change.ts` intentionally displays REMOVED requirements with only the requirement name (synthesized header `### Requirement: ${name}`), not the full body. This matches the cli-show spec and is by design — do not flag the absence of the removed body as a bug.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: bsmedberg-xometry
Repo: Fission-AI/OpenSpec PR: 980
File: openspec/changes/spec-diffs/specs/cli-show/spec.md:44-46
Timestamp: 2026-04-16T19:44:15.889Z
Learning: In the OpenSpec repository (Fission-AI/OpenSpec), `openspec show <change-name> --diff` in text mode intentionally prints the proposal markdown text first, followed by per-requirement unified diffs for each delta spec. ADDED requirements are displayed in full. This is by design — do not flag the proposal-first ordering as a bug.

Learnt from: lsmonki
Repo: Fission-AI/OpenSpec PR: 660
File: openspec/changes/archive/2026-02-04-hierarchical-specs-support/design.md:96-136
Timestamp: 2026-02-04T17:50:12.189Z
Learning: In OpenSpec, delta specs handle deletions at the requirement level using `## REMOVED Requirements` sections within the delta spec.md file. There is no concept of deleting an entire spec file through deltas.

Comment thread src/utils/requirement-diff.ts Outdated
Comment on lines +100 to +127
function run(args: string): string {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
return execSync(`node ${openspecBin} ${args}`, {
encoding: 'utf-8',
env: { ...process.env, NO_COLOR: '1' },
});
} finally {
process.chdir(originalCwd);
}
}

function runWithStderr(args: string): { stdout: string; stderr: string } {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
const stdout = execSync(`node ${openspecBin} ${args}`, {
encoding: 'utf-8',
env: { ...process.env, NO_COLOR: '1' },
stdio: ['pipe', 'pipe', 'pipe'],
});
return { stdout, stderr: '' };
} catch (e: any) {
return { stdout: e.stdout?.toString() ?? '', stderr: e.stderr?.toString() ?? '' };
} finally {
process.chdir(originalCwd);
}
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's check the exact code at the specified lines
cat -n test/commands/show-diff.test.ts | sed -n '100,127p'

Repository: Fission-AI/OpenSpec

Length of output: 1127


🏁 Script executed:

# Also check the imports and broader context of these helper functions
cat -n test/commands/show-diff.test.ts | sed -n '1,50p'

Repository: Fission-AI/OpenSpec

Length of output: 2077


🏁 Script executed:

# Check how these functions are called to understand arg handling
rg "run\(|runWithStderr\(" test/commands/show-diff.test.ts -A 2 -B 1

Repository: Fission-AI/OpenSpec

Length of output: 1668


🏁 Script executed:

# Verify if there are any array-style arg usages or string-style arg usages in calls
rg "run\(" test/commands/show-diff.test.ts | head -20

Repository: Fission-AI/OpenSpec

Length of output: 728


🏁 Script executed:

# Find the full test bodies related to text-mode diff assertions
rg "text mode.*diff" test/commands/show-diff.test.ts -A 10

Repository: Fission-AI/OpenSpec

Length of output: 1246


🏁 Script executed:

# Check for test expectations about proposal appearing/not appearing with --diff
rg "Proposal|proposal" test/commands/show-diff.test.ts -B 2 -A 2

Repository: Fission-AI/OpenSpec

Length of output: 2182


🏁 Script executed:

# Check if there's any documentation about the --diff feature behavior
fd -e md | xargs grep -l "diff" | head -5

Repository: Fission-AI/OpenSpec

Length of output: 187


🏁 Script executed:

# Verify if this change to array args would break existing callers
wc -l test/commands/show-diff.test.ts

Repository: Fission-AI/OpenSpec

Length of output: 98


🏁 Script executed:

# Check if there are any callers of run() that would break if signature changes to array
rg "run\(" test/commands/show-diff.test.ts | wc -l

Repository: Fission-AI/OpenSpec

Length of output: 65


🏁 Script executed:

# Verify the PR description or any related context about --diff behavior
git log --oneline -20 -- test/commands/show-diff.test.ts

Repository: Fission-AI/OpenSpec

Length of output: 162


🏁 Script executed:

# Check if the proposal text appearing with --diff is intended or a bug
rg "should.*diff.*omit|omit.*proposal|proposal.*omit" . -i

Repository: Fission-AI/OpenSpec

Length of output: 45


Use execFileSync to avoid shell parsing vulnerabilities in test helpers.

Lines 104 and 117 interpolate openspecBin into shell command strings, creating failures when the repository path contains spaces or shell metacharacters. Replace with execFileSync(process.execPath, [openspecBin, ...args.split(' ')]) to safely handle paths and split arguments without shell interpretation.

Note: All 10 callers of run() and runWithStderr() currently pass space-separated argument strings; updating the helper signatures from args: string to args: string[] would require refactoring all call sites.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/commands/show-diff.test.ts` around lines 100 - 127, The test helpers run
and runWithStderr interpolate openspecBin into shell command strings which
breaks if paths contain spaces or shell metacharacters; replace execSync calls
with execFileSync(process.execPath, [openspecBin, ...args.split(' ')], ...) to
avoid shell parsing (preserve encoding and env, and for runWithStderr set stdio
to ['pipe','pipe','pipe'] and catch the thrown error to extract stdout/stderr as
done currently); update the function signatures/comments to indicate args is a
space-separated string that will be split into an argv array and ensure
process.chdir(originalCwd) remains in the finally block.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a common pattern across the test suite (used in spec.test.ts, show.test.ts, and our show-diff.test.ts). Since this is not new to the tests in this PR, I'm going to decline this recommendation for now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bsmedberg-xometry, understood — since this execSync with interpolated args pattern is pre-existing and consistent across the test suite, it makes sense to keep it as-is to maintain consistency. I'll note this for future reviews.


✏️ Learnings added
Learnt from: bsmedberg-xometry
Repo: Fission-AI/OpenSpec PR: 980
File: test/commands/show-diff.test.ts:100-127
Timestamp: 2026-04-16T19:52:43.181Z
Learning: In the Fission-AI/OpenSpec test suite (e.g., spec.test.ts, show.test.ts, show-diff.test.ts), using `execSync` with interpolated `openspecBin` shell command strings (rather than `execFileSync`) is an established, intentional pattern shared across all test helpers. Do not flag this as a shell injection/path-safety issue in this repository.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: bsmedberg-xometry
Repo: Fission-AI/OpenSpec PR: 980
File: openspec/changes/spec-diffs/specs/cli-show/spec.md:44-46
Timestamp: 2026-04-16T19:44:15.889Z
Learning: In the OpenSpec repository (Fission-AI/OpenSpec), `openspec show <change-name> --diff` in text mode intentionally prints the proposal markdown text first, followed by per-requirement unified diffs for each delta spec. ADDED requirements are displayed in full. This is by design — do not flag the proposal-first ordering as a bug.

Learnt from: bsmedberg-xometry
Repo: Fission-AI/OpenSpec PR: 980
File: src/commands/change.ts:267-268
Timestamp: 2026-04-16T19:47:10.854Z
Learning: In OpenSpec (Fission-AI/OpenSpec), `showSpecDiffs()` in `src/commands/change.ts` intentionally displays REMOVED requirements with only the requirement name (synthesized header `### Requirement: ${name}`), not the full body. This matches the cli-show spec and is by design — do not flag the absence of the removed body as a bug.

Learnt from: lsmonki
Repo: Fission-AI/OpenSpec PR: 660
File: openspec/changes/archive/2026-02-04-hierarchical-specs-support/design.md:96-136
Timestamp: 2026-02-04T17:50:12.189Z
Learning: In OpenSpec, delta specs handle deletions at the requirement level using `## REMOVED Requirements` sections within the delta spec.md file. There is no concept of deleting an entire spec file through deltas.

Learnt from: askpatrickw
Repo: Fission-AI/OpenSpec PR: 753
File: test/core/global-init.test.ts:104-111
Timestamp: 2026-02-24T22:25:19.145Z
Learning: In tests, enforce and verify the global root override behavior: OPENSPEC_GLOBAL_ROOT should unconditionally redirect global writes to the specified directory for testing/CI, regardless of adapter support. Ensure the null-adapter exclusion path is validated by testing that CommandAdapterRegistry.getGlobalAdapters() filters adapters by non-null getGlobalRoot() before resolveGlobalRoot() is invoked. Include a test scenario where a user explicitly specifies a tool (e.g., --tools cursor) with OPENSPEC_GLOBAL_ROOT set, and confirm this is treated as an intentional opt-in.

Comment thread test/commands/show-diff.test.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
openspec/changes/spec-diffs/proposal.md (1)

19-25: Document the change-only constraint for --diff.

The PR objectives state that "--diff is a change-only option; using it on a non-change item emits a warning and is ignored," but this behavior isn't documented in the proposal. Readers should know that the flag only applies when showing change objects.

📝 Suggested addition to clarify scope

Consider adding a note after line 21 or in the non-goals section:

 - A `--diff` flag on `openspec show <change>` (and `openspec change show <change>`) that outputs a human-readable unified diff per delta spec
+
+Note: `--diff` only applies to change objects. Using it when showing a non-change spec will emit a warning and be ignored.

Alternatively, add to the non-goals section after line 41:

 - Diffing non-spec artifacts (proposal, design, tasks)
 - Git-aware diffing (this compares files on disk, not git history)
+- Using `--diff` on non-change items (flag is ignored with a warning)

Optional: Clarify JSON mode behavior when the base spec file doesn't exist.

Line 23 documents that when a base spec exists but a requirement block isn't found, JSON mode includes a "warning" field. It doesn't explicitly cover the edge case where the entire base spec file doesn't exist (new capability). Based on line 25's text-mode description and the PR objectives ("New delta specs...shown as all-additions"), these should presumably get a "diff" field with all-additions content rather than a warning. This is a minor edge case (new capabilities typically have only ADDED requirements, not MODIFIED), but documenting it would be thorough.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openspec/changes/spec-diffs/proposal.md` around lines 19 - 25, Add a short
note to the proposal explaining that the --diff flag is change-only: when used
on non-change items it emits a warning and is ignored (place this after the
"What this change delivers" paragraph or in non-goals), and explicitly document
JSON mode's edge-case behavior when the entire base spec file is missing by
stating that a MODIFIED delta with no base spec file should include a "diff"
field containing the all-additions unified-diff (not a "warning"), mirroring the
text-mode description; reference the `--diff` flag, "JSON mode"/"Text mode", and
the `"diff"`/`"warning"` fields when adding these clarifications.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@openspec/changes/spec-diffs/proposal.md`:
- Around line 19-25: Add a short note to the proposal explaining that the --diff
flag is change-only: when used on non-change items it emits a warning and is
ignored (place this after the "What this change delivers" paragraph or in
non-goals), and explicitly document JSON mode's edge-case behavior when the
entire base spec file is missing by stating that a MODIFIED delta with no base
spec file should include a "diff" field containing the all-additions
unified-diff (not a "warning"), mirroring the text-mode description; reference
the `--diff` flag, "JSON mode"/"Text mode", and the `"diff"`/`"warning"` fields
when adding these clarifications.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3f05aaf5-ef65-4015-993a-8d06b5cf713c

📥 Commits

Reviewing files that changed from the base of the PR and between b27aa01 and 3fb6795.

📒 Files selected for processing (3)
  • openspec/changes/spec-diffs/proposal.md
  • src/utils/requirement-diff.ts
  • test/utils/requirement-diff.test.ts
✅ Files skipped from review due to trivial changes (2)
  • src/utils/requirement-diff.ts
  • test/utils/requirement-diff.test.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant