Files
bsmedberg-xometry dd7cea3ffe feat(show): diff delta requirements against the main specs (#980)
* Proposed feature: openspec show --diff to see changed requirements more clearly

* Implementation of the --diff feature, which led to some spec changes during implementation.

* Update the proposal to be clearer. Thanks coderabbit

* Fix a bug identified by coderabbit with excessive trimming, and add a testcase for it.

* fix(show): harden --diff for review feedback

Keeps `openspec show <change>` without `--diff` a raw proposal
passthrough, reports when a change has no delta specs instead of
returning silently, preserves the Reason/Migration body of a REMOVED
requirement, and resolves main specs through the command's root so
`--store <id>` diffs against that store.

Text mode and JSON mode now render from one shared collection pass, the
CLI tests drive argv arrays from a mkdtemp project instead of
interpolated shell strings, `--diff` is registered for shell
completions, and the stray package-lock.json is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* build(deps): declare the diff dependency and refresh the flake hash

Adds the `diff` runtime dependency that requirement-diff.ts imports,
updates pnpm-lock.yaml, and regenerates the flake's pnpmDeps hash so
`nix build` matches the new lockfile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(show): diff nested capabilities too

collectSpecDiffs enumerated only top-level directories under the
change's specs/, so a nested capability (specs/<area>/<id>/spec.md) was
skipped: text mode printed nothing for it and its MODIFIED deltas came
back from --json with no diff. It now uses the same discoverSpecFiles()
helper ChangeParser uses, so the capability ids match the `spec` field
of the JSON deltas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(show): report header mismatches instead of hiding them

Two cases where --diff quietly showed something misleading:

A MODIFIED requirement whose capability has no main spec was rendered as
all-additions, which reads like a new capability. It is an authoring
error archive will reject, so it now prints the raw text with a warning
naming the missing spec.

A header that differs from the main spec only in case or interior
spacing found no match at all under exact lookup, or matched under a
lowercase-only comparison that let a real mismatch through silently.
Lookup is now exact first, then the shared foldRequirementName fallback,
and a folded match prints the diff the author meant alongside a warning
that archive matches names exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(show): name the main spec as such in collectSpecDiffs

Comment and locals still called the main spec the "base" spec, and the
no-main-spec comment described the old all-additions behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps-dev): bump the development-dependencies group with 2 updates

Bumps the development-dependencies group with 2 updates: [smol-toml](https://github.com/squirrelchat/smol-toml) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `smol-toml` from 1.7.1 to 1.8.0
- [Release notes](https://github.com/squirrelchat/smol-toml/releases)
- [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.7.1...v1.8.0)

Updates `typescript-eslint` from 8.66.0 to 8.67.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: smol-toml
  dependency-version: 1.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(nix): invalidate pnpm dependency hash

* fix(nix): update pnpm dependency hash

* fix(show): harden requirement diff output

* build(nix): pin combined dependency hash

* test(show): assert proposal precedes diffs

* docs(show): clarify JSON diff diagnostics

* fix(show): retain unified diff hunk headers

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Clay Good <hi@claygood.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-26 20:18:03 +00:00

5.8 KiB

1. Add diff dependency

  • 1.1 Install the diff npm package: pnpm add diff (v9 ships its own types, so no @types/diff)

2. Requirement block extraction

  • 2.1 In src/utils/requirement-diff.ts, add extractRequirementBlock(specContent, requirementName): MatchedRequirementBlock | null. Match exactly first, then report a folded case/whitespace match as inexact, and return raw markdown through the next peer or higher header.
  • 2.2 Add unit tests for extractRequirementBlock: exact match, case-insensitive match, whitespace-insensitive match, no match returns null, last requirement in file (no following header), requirement inside code fence is not matched

3. Per-requirement diff utility

  • 3.1 In src/utils/requirement-diff.ts, add diffRequirementBlock(baseBlock, deltaBlock): string using structuredPatch() from diff, rendering only unified-diff hunks.
  • 3.2 Add unit tests: base exists (expect removals + additions), base is null (all additions), identical blocks (empty/minimal diff)
  • 3.3 Add function buildRenameMap(renames: Array<{ from: string; to: string }>): Map<string, string> that returns a map from normalized TO name → normalized FROM name, for use when looking up base blocks for MODIFIED requirements that were also renamed
  • 3.4 Add unit tests for buildRenameMap: single rename, multiple renames, chained renames, empty list

4. CLI flag registration

  • 4.1 In src/cli/index.ts, add .option('--diff', 'Show per-requirement diffs for delta specs') to the show command and the change show subcommand
  • 4.2 In src/commands/show.ts, add 'diff' to the CHANGE_FLAG_KEYS set so it warns when used with --type spec

5. Text mode diff display

  • 5.1 In src/commands/change.ts show() method, discover files with discoverSpecFiles() and parse them with parseDeltaSpec(). Display ADDED, REMOVED, and RENAMED content directly; for MODIFIED, read the selected root's main spec, extract the matching block, and print a colorized unified diff.
  • 5.2 Build a rename map from the parsed RENAMED entries for the current spec. For MODIFIED requirements whose normalized name matches a RENAMED TO name, look up the base block using the RENAMED FROM name instead of the MODIFIED name
  • 5.3 Handle the no-delta-specs case: print "No delta specs to diff for change ''" and return (exit code 0)
  • 5.4 Handle the MODIFIED-no-base-match case: print the full MODIFIED requirement text with a warning that no matching base requirement was found
  • 5.5 Add integration test: text mode diff with a change that has one MODIFIED and one ADDED requirement
  • 5.6 Add integration test: text mode RENAMED + MODIFIED on the same requirement — shows both the rename label and the body diff, with the base block looked up by the old name
  • 5.7 Add integration test: text mode MODIFIED with no matching base requirement — shows warning and full text

6. JSON mode diff output

  • 6.1 In src/commands/change.ts show() method, when options.diff and options.json are both set: for each MODIFIED delta, compute the diff (using rename map for base lookup) and add a diff string field to the delta object in the JSON output
  • 6.2 Add integration test: JSON mode diff output includes diff field on MODIFIED deltas only (not on ADDED/REMOVED/RENAMED)
  • 6.3 Add integration test: JSON mode RENAMED + MODIFIED — diff field on the MODIFIED delta shows changes relative to the old-name base block

7. Cross-platform and CI verification

  • 7.1 Ensure all path operations in new code use path.join() or path.resolve(); display paths normalize to forward slashes
  • 7.2 Ensure unit tests use path.join() for expected path values, not hardcoded slash strings
  • 7.3 Verify all existing tests pass (pnpm test)
  • 7.4 Verify Windows CI passes (no path-separator issues in requirement matching or file discovery)

8. Review follow-ups

  • 8.1 Keep openspec show <change> without --diff a raw proposal passthrough; --diff is purely additive
  • 8.2 Print the no-delta-specs message instead of returning silently, and cover it with a test
  • 8.3 Keep the authored Reason/Migration body of a REMOVED requirement: parseDeltaSpec now returns removedBlocks alongside removed
  • 8.4 Resolve main specs through the command's root (--store <id>), not process.cwd(), with a store-scoped regression test
  • 8.5 Collect text-mode and JSON-mode diffs in one shared pass so the two surfaces cannot drift
  • 8.6 Drive the CLI in tests with execFileSync/spawnSync argv arrays from a mkdtemp project instead of interpolated shell strings and an in-repo temp directory
  • 8.7 Register --diff in the completion command registry so shell completions offer it
  • 8.8 Drop the stray package-lock.json; the repo is pnpm-only
  • 8.9 Enumerate delta specs with the shared discoverSpecFiles() so nested capabilities (specs/<area>/<id>/spec.md) are diffed, with a regression test
  • 8.10 Warn instead of rendering all-additions when a MODIFIED requirement's capability has no main spec — that combination is an authoring error archive will reject, not a new capability
  • 8.11 Match requirement headers exactly first and fall back to the shared case/whitespace fold, reporting a folded match as inexact so the diff still shows but the mismatch is named
  • 8.12 Preserve both diff and warning in JSON when a folded match provides both diagnostics
  • 8.13 Propagate discovery, delta-read, and non-ENOENT main-read failures instead of returning partial output
  • 8.14 Resolve chained renames back to the original main requirement
  • 8.15 Distinguish a textually empty MODIFIED diff from a missing main block in text and JSON output
  • 8.16 Document --diff in the canonical docs-lab CLI reference and leave the legacy CLI page unchanged