Skip to content

fix(validate): report scenarios a MODIFIED requirement would drop - #1482

Merged
clay-good merged 7 commits into
mainfrom
fix/validate-archive-parity
Jul 29, 2026
Merged

fix(validate): report scenarios a MODIFIED requirement would drop#1482
clay-good merged 7 commits into
mainfrom
fix/validate-archive-parity

Conversation

@clay-good

@clay-good clay-good commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #1477.

Status: ready. Full suite green (3397 tests), lint and typecheck clean, and verified against this repo's own 54 changes and specs.

What was wrong

A change could pass openspec validate — even with --strict — and still be impossible to archive.

A MODIFIED requirement replaces the whole requirement block, so archive refuses to apply one that omits a scenario the main spec still has (it would silently lose that scenario). Validate never ran that check, so the mistake — usually a renamed or forgotten scenario — survived authoring, implementation and review, and only surfaced days later when someone ran openspec archive.

Reproduced on 1.7.0 and on current main:

$ openspec validate --strict --type change rename-scenario
Change 'rename-scenario' is valid

$ openspec archive rename-scenario --yes
widgets MODIFIED failed for header "### Requirement: Widget state" - current spec
contains scenario(s) not present in the modified block: "Existing scenario".

How it was fixed

openspec validate <change> now runs the same non-mutating check against the main specs and reports each omitted scenario, naming the delta file:

$ openspec validate --type change rename-scenario
Change 'rename-scenario' has issues
✗ [ERROR] widgets/spec.md: MODIFIED "Widget state" omits scenario(s) the current spec
  still has: "Existing scenario". Copy them into the MODIFIED block (a MODIFIED
  requirement replaces the whole block, so archive refuses to drop them).

The comparison itself moved into the parser module, so archive and validate share one implementation and cannot drift. It applies on every validate path: validate <change>, validate --changes/--all, and the deprecated change validate.

The check stays silent when the main spec file or the requirement header is absent — a MODIFIED written against a sister change still in flight is a separate condition that archive gates (#1112) — so validate can only report what archive already refuses. openspec archive is unchanged; it still enforces this itself, with the same message and JSON error code as before.

Proof it works

  • 11 new tests in test/core/validation.scenario-loss.test.ts. Each one asserts validate's verdict and what archive does with the same fixture, so the parity claim is tested, not assumed.
  • No false positives on real data: across this repo's 18 active changes, 4 newly fail validate — and an archive dry-run rejects all 4 for exactly the scenarios reported. Every change that archives cleanly still validates.
  • An adversarial pass over 26 hand-built fixtures (case and whitespace variants, duplicate requirement and scenario names, backtick/tilde/unterminated fences, HTML comments, BOM, CRLF, nested capability layouts, rename + modify combinations, store-selected roots, missing or structurally invalid main specs, concurrent --all) found no case where validate reports a change archive would accept.

Notes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • openspec validate now errors when a change’s MODIFIED requirement omits scenarios that still exist in the replaced main spec (including repeated scenarios and rename scenarios), matching archive application rejection.
    • No scenario-loss error is emitted when the main spec file or referenced requirement header is missing; unreadable main specs still surface errors.
  • Documentation
    • Updated CLI docs, troubleshooting, and sync-skill guidance to clarify the required “carry forward all surviving scenarios” behavior for MODIFIED.
  • Tests
    • Added unit and CLI end-to-end coverage for scenario-loss, rename handling, bulk validation, and relevant edge cases.

clay-good and others added 3 commits July 29, 2026 09:48
`openspec validate <change>` accepted a MODIFIED requirement that omits a
scenario the main spec still has, even with --strict. Archive refuses to
apply that block (a MODIFIED replaces the whole requirement, so the omitted
scenario would be lost), so the change could pass validation, be implemented
and reviewed, and fail only days later at archive time (#1477).

Validate now runs the same non-mutating check against the main specs and
reports each omitted scenario, naming the delta file. The comparison itself
moved to the parser module so archive and validate share one implementation
and cannot drift.

The check is silent when the main spec file or the requirement header is
absent — a MODIFIED written against a sister change still in flight is a
separate condition archive gates — so validate can only report what archive
already refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keep the moved scenario parser module-private, derive change validate's
main specs root from the changes root it already resolved, replace the
rename re-keying with a lookup fallback, and say at archive's call site
why it does not opt in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A delta that renames A to B and then B to C leaves C holding A's block at
archive time. Walk the rename map instead of looking it up once, so the
chained case reports the same loss archive refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@clay-good
clay-good requested a review from a team as a code owner July 29, 2026 15:09
@clay-good
clay-good requested review from alfred-openspec and removed request for a team July 29, 2026 15:09
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1474decf-6717-43ea-be42-716c810a70ed

📥 Commits

Reviewing files that changed from the base of the PR and between 2dbd839 and 89bd416.

📒 Files selected for processing (1)
  • docs/troubleshooting.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/troubleshooting.md

📝 Walkthrough

Walkthrough

openspec validate now checks MODIFIED requirements for omitted scenarios in the current main specification. Scenario parsing is shared with archive application, supports rename chains and duplicate scenarios, and is covered by focused validation and parity tests.

Changes

Scenario-loss validation

Layer / File(s) Summary
Shared scenario comparison
src/core/parsers/requirement-blocks.ts, src/core/specs-apply.ts
Scenario parsing and multiplicity-aware missing-scenario detection are centralized and reused by spec application.
Validator scenario-loss enforcement
src/core/validation/validator.ts
Validation optionally reads main specs, resolves renamed requirements, and reports omitted scenarios from MODIFIED blocks.
Validation entry points and specification contract
src/commands/change.ts, src/commands/validate.ts, src/core/archive.ts, openspec/specs/cli-validate/spec.md, docs/..., skills/..., src/core/templates/workflows/sync-specs.ts, .changeset/...
CLI validation paths provide mainSpecsDir; specifications, documentation, changesets, and sync templates describe retained-scenario rules.
Scenario-loss validation coverage
test/core/validation.scenario-loss.test.ts, test/cli-e2e/validate-scenario-loss.test.ts, test/core/templates/skill-templates-parity.test.ts
Tests cover omissions, duplicates, fenced code, missing specs, nested paths, rename chains, CRLF input, archive parity, CLI behavior, and template hashes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: validation now reports scenarios a MODIFIED requirement would drop.
Linked Issues check ✅ Passed The PR implements #1477 by making validate fail on scenario loss in MODIFIED blocks, including rename cases and the required error reporting.
Out of Scope Changes check ✅ Passed The additional docs, templates, tests, and refactors all support the scenario-loss validation change and don't appear unrelated.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/validate-archive-parity

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/core/validation.scenario-loss.test.ts`:
- Line 196: Update the nested-path assertion in the validation scenario test to
normalize issue?.path before comparison and construct the expected
platform/session/spec.md path using the platform-native path utility. Preserve
the existing assertion semantics while avoiding hard-coded path separators.
🪄 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 Plus

Run ID: 595ab1d3-a9e3-4261-ae47-d292e07065e3

📥 Commits

Reviewing files that changed from the base of the PR and between 4e16790 and 96b0c84.

📒 Files selected for processing (9)
  • .changeset/validate-scenario-loss.md
  • openspec/specs/cli-validate/spec.md
  • src/commands/change.ts
  • src/commands/validate.ts
  • src/core/archive.ts
  • src/core/parsers/requirement-blocks.ts
  • src/core/specs-apply.ts
  • src/core/validation/validator.ts
  • test/core/validation.scenario-loss.test.ts


expect(report.valid).toBe(false);
const issue = report.issues.find((i) => i.message.includes('omits scenario(s)'));
expect(issue?.path).toBe('platform/session/spec.md');

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Make the nested-path assertion platform-neutral.

Line 196 hard-codes /, so this can fail when issue.path uses Windows separators. Normalize the actual value and build the expected path natively.

Proposed fix
-    expect(issue?.path).toBe('platform/session/spec.md');
+    expect(path.normalize(issue?.path ?? '')).toBe(path.join('platform', 'session', 'spec.md'));
📝 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
expect(issue?.path).toBe('platform/session/spec.md');
expect(path.normalize(issue?.path ?? '')).toBe(path.join('platform', 'session', 'spec.md'));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/validation.scenario-loss.test.ts` at line 196, Update the
nested-path assertion in the validation scenario test to normalize issue?.path
before comparison and construct the expected platform/session/spec.md path using
the platform-native path utility. Preserve the existing assertion semantics
while avoiding hard-coded path separators.

Source: Coding guidelines

Code:
- An unreadable main spec was swallowed, so a change archive aborts on
  validated clean. Only ENOENT/ENOTDIR mean "no main spec" now; anything
  else is reported.
- A MODIFIED naming a header the same delta renames away no longer names
  scenarios from the block it would not land on. That contradiction is
  already reported on its own, and the scenario list pointed at the wrong
  requirement.

Guidance: the sync-specs skill told agents a MODIFIED block may carry only
the changed scenario, and its format reference showed one. Both validate and
archive reject that shape, so the template, the generated skill, and the
golden hashes are updated to match the schema's own rule.

Tests: the CLI wiring had no coverage at all — removing the argument that
turns the check on broke nothing. Adds end-to-end coverage of every entry
point and exit code, plus the non-strict default, a fenced scenario in the
delta, an unreadable main spec, the rename-away case, and a rename cycle.
Loose assertions now pin the scenario-loss issue itself.

Docs: a troubleshooting entry for the new message, and the changeset says
that a stale change will newly fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying openspec-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 65c0f14
Status: ✅  Deploy successful!
Preview URL: https://f4fe2c4e.openspec-docs.pages.dev
Branch Preview URL: https://fix-validate-archive-parity.openspec-docs.pages.dev

View logs

@clay-good

Copy link
Copy Markdown
Collaborator Author

Ran five adversarial reviews over this branch (differential fuzzing, API/compat, robustness, spec-and-docs consistency, and a mutation-testing pass on the tests). Pushed 9b49084 with the fixes.

The property held. ~5,200 randomized main-spec/delta pairs (~7,900 comparisons) plus 26 hand-built edge cases and 100 CLI runs found no case where validate reports a change archive would apply cleanly. Positive controls confirm the harness bites: validate fired 548 times in one 1,200-case run and agreed with the archive oracle 537/537.

Fixed

  • Unreadable main spec was swallowed. chmod 000 or a directory named spec.md made validate say valid while archive aborts. Now only ENOENT/ENOTDIR mean "no main spec"; anything else is reported.
  • Red-herring message. When a delta renamed a header away and MODIFIED the old name, validate named scenarios from a block the MODIFIED would never land on. That contradiction is already reported on its own, so the loss check now skips it.
  • Our own guidance taught the shape this rejects. openspec-sync-specs said a MODIFIED block need not copy existing scenarios, and its format reference showed a MODIFIED block with no requirement body and only the new scenario — which validate has always rejected and archive has always refused. Template, generated SKILL.md, and golden hashes updated to match schemas/spec-driven/schema.yaml ("MUST include full updated content").
  • The CLI wiring had zero coverage. Deleting the argument that enables the whole check broke none of the 484 command tests. Added test/cli-e2e/validate-scenario-loss.test.ts covering every entry point and its exit code.
  • Test gaps: non-strict default (the shipped mode), a fenced scenario inside the delta, an unreadable main spec, the rename-away case, and a rename cycle (a hang there would block the event loop, so no test timeout could catch it). Loose join(messages) assertions now pin the scenario-loss issue itself.
  • Spec and docs: the requirement now leads with its SHALL per openspec-conventions, covers the rename-chain behavior, and states that validation run inside archive does not report these. Added a troubleshooting entry, and the changeset now says a stale change will newly fail.

Not changed, with reasons

  • archive --skip-specs still succeeds where validate fails. Deltas are never applied there, so nothing is lost — and this is the pre-existing shape of --skip-specs (it bypasses every delta error validate reports), not something this PR introduces.
  • @coderabbitai's suggestion to normalize the nested-path assertion: declining. Issue paths go through FileSystemUtils.toPosixPath (src/utils/file-system.ts:86), so issue.path is always forward-slashed; path.join('platform','session','spec.md') would produce backslashes and fail on Windows. The Windows CI job covers this.
  • The four stale changes in this repo's own openspec/changes/ are true positives (archive refuses all four). Refreshing them belongs to their authors.

Verification after the fixes: 3407 tests across 117 files green, lint and typecheck clean, both template parity tests green after regeneration, validate --specs 36/36, and the repo cross-tab still reports 4 newly-failing changes with 0 false positives.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/utils/file-system.ts --items all --match 'toPosixPath|FileSystemUtils'
printf '\n--- referenced implementation ---\n'
nl -ba src/utils/file-system.ts | sed -n '70,96p'
printf '\n--- issue.path normalization call sites ---\n'
rg -n -C 2 'toPosixPath\s*\(|issue\.path' src test

Length of output: 1026


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- FileSystemUtils.toPosixPath implementation ---'
sed -n '78,102p' src/utils/file-system.ts

printf '%s\n' '--- production usages involving normalized issue paths ---'
grep -RIn -E 'toPosixPath[[:space:]]*\(|issue\.path' src 2>/dev/null | head -n 160

printf '%s\n' '--- relevant test assertions ---'
grep -RIn -E 'issue\.path|platform/session/spec\.md' test 2>/dev/null | head -n 120

Length of output: 2985


@clay-good Confirmed. FileSystemUtils.toPosixPath() explicitly replaces backslashes with /, and the validator normalizes the relative spec path before reporting it. The nested-path test’s 'platform/session/spec.md' assertion is therefore the correct cross-platform form; the prior suggestion remains withdrawn.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/troubleshooting.md`:
- Around line 99-106: Add the text language identifier to the fenced code
example in the troubleshooting documentation, changing the bare fence around the
MODIFIED requirement message to a text-labeled fence while preserving the
example content.
🪄 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 Plus

Run ID: 1ced4b7f-2aac-42ef-b461-f96daa30adaf

📥 Commits

Reviewing files that changed from the base of the PR and between 96b0c84 and 9b49084.

📒 Files selected for processing (10)
  • .changeset/validate-scenario-loss.md
  • docs/cli.md
  • docs/troubleshooting.md
  • openspec/specs/cli-validate/spec.md
  • skills/openspec-sync-specs/SKILL.md
  • src/core/templates/workflows/sync-specs.ts
  • src/core/validation/validator.ts
  • test/cli-e2e/validate-scenario-loss.test.ts
  • test/core/templates/skill-templates-parity.test.ts
  • test/core/validation.scenario-loss.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • .changeset/validate-scenario-loss.md
  • openspec/specs/cli-validate/spec.md
  • src/core/validation/validator.ts

Comment thread docs/troubleshooting.md
The unreadable-main-spec report added in the last commit fired for any
errno that was not ENOENT/ENOTDIR, which includes resource errors like
EMFILE that say nothing about the file. `validate --all` reads six changes
at once, so a busy process could have failed a change that is fine.

Reported now only for the codes that mean the file itself is unusable and
will be just as unusable when archive reads it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Validation now catches the same scenario loss archive refuses, across every CLI entry point, while preserving rename, fenced-content, missing-base, and transient read-error behavior. The exact head passed the hosted CI/security matrix, CodeRabbit, an isolated build, and 22 focused core and real-CLI tests.

Every other fence in the file names its language.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator Author

Fixed in 89bd416: added the text info string to the troubleshooting fence (MD040) — every other fence in that file names its language, so thanks @coderabbitai.

Still declining the earlier suggestion on test/core/validation.scenario-loss.test.ts:196: issue paths go through FileSystemUtils.toPosixPath (src/utils/file-system.ts:86), so issue.path is always forward-slashed, and path.join('platform','session','spec.md') would produce backslashes and fail on Windows. The windows-pwsh job passes on the current assertion.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@clay-good Thanks for fixing the MD040 fence in 89bd4161.

Agreed on the test assertion: since issue.path is explicitly normalized through FileSystemUtils.toPosixPath, the literal 'platform/session/spec.md' is the correct platform-independent expected value. path.join() would make that assertion host-dependent, so the earlier suggestion does not apply.

🐇 ✅


✏️ Learnings added
Learnt from: clay-good
URL: https://github.com/Fission-AI/OpenSpec/pull/1482

Timestamp: 2026-07-29T16:22:39.908Z
Learning: In this TypeScript codebase, paths exposed as validation `issue.path` values are normalized by `FileSystemUtils.toPosixPath` in `src/utils/file-system.ts`. Tests asserting these issue paths must use forward-slash POSIX literals (for example, `platform/session/spec.md`) rather than `path.join(...)`, which is host-platform dependent.

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.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The only change since the previously verified implementation is the MD040 text fence label. Exact head 89bd416 remains clean, with the full hosted CI/security matrix green and the scenario-loss behavior already validated by the isolated focused suite.

@clay-good
clay-good added this pull request to the merge queue Jul 29, 2026
Merged via the queue into main with commit 84ebc57 Jul 29, 2026
17 checks passed
@clay-good
clay-good deleted the fix/validate-archive-parity branch July 29, 2026 22:47
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.

validate: omitted scenarios in a MODIFIED requirement are only rejected by archive

2 participants