Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/wrapped-bullet-retirement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@fission-ai/openspec": patch
---

### Bug Fixes

- **`retire_capabilities` on specs that wrap their bullets** — Archive refused to retire a capability whenever a scenario bullet wrapped onto a second line: the wrapped remainder was counted as content the merge could not account for. Projects that hold their Markdown to a column limit could not retire any capability through the supported path. A line that continues the bullet above it is now accounted for with that bullet, while a heading, fence, block quote, thematic break, table row, or raw HTML written beneath a bullet is still weighed on its own and still blocks the deletion.
- **`retire_capabilities` on specs bulleted with `+`** — Scenario bullets written with the `+` list marker were read as unaccounted content, so a capability written that way could not be retired at all, even though such a spec validates cleanly. Every CommonMark list marker is now recognised.
- **A note mistaken for an ordered list item** — A line opening with a ten-or-more-digit number and a period was read as a list marker, which CommonMark does not allow, so the same authored note was refused when it began with a word and deleted without mention when it began with a long enough number. Ordered markers now stop at nine digits, where CommonMark stops them.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-04
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
## Why

`retire_capabilities: true` — the retirement path added for #1302 — is unusable on
any spec whose scenario bullets wrap onto a second line.

Before deleting a main spec, archive accounts for every non-blank line in the
file: if anything sits outside the parts the merge understands, the retirement is
refused rather than taking authored content with it. That audit reads the file a
line at a time, and a wrapped bullet's remainder is a line that is not a bullet,
not a heading and not a requirement statement — so it counts as content the merge
cannot name, and the retirement is refused.

Wrapping prose at a column limit is the norm in a repository that lints for it.
[#1780](https://github.com/Fission-AI/OpenSpec/issues/1780) reports a project held
to 100 columns, where effectively every scenario bullet longer than a short
sentence wraps, and therefore no capability in the project can be retired through
the supported path. The reporter hand-deleted the spec directory instead.

The same classification also decided whether the abort could name the marker at
all. That half is already fixed on `main` (#1696 / PR #1699): a spec with
unaccounted content now gets a hint that names the blocking lines instead of a
bare `Spec must have at least one requirement`. What is left is the
classification itself.

## What Changes

- A line inside a requirement block that continues the list item above it is
accounted for as part of that item, not as a line of its own. Nothing may have
closed the item first — no blank line, heading, fence, or new bullet — which is
CommonMark's rule for a paragraph running on inside a list item, and covers both
an indented remainder and a lazy (unindented) one.
- Lines that open a block of their own are excluded, so they are still weighed
individually: a heading, a fence, a block quote, a thematic break, a table row,
or raw HTML written directly beneath a bullet.
- Every CommonMark list marker counts as a list item. The audit named only `-`,
`*` and ordered items, so a spec bulleted with `+` — which OpenSpec's own
validator accepts without complaint — had *every* scenario bullet reported as
unaccounted content and could not be retired at all. Found while hardening the
wrapping fix; it is the same defect wearing a different marker.
- Only those markers count. CommonMark stops an ordered marker at nine digits,
so `1234567890. Migration note...` opens a paragraph, not a list — and reading
it as a marker meant the same authored note was refused when it began with a
word and silently deleted when it began with a long enough number.
- Nothing else changes. The audit still fails safe — a line it cannot classify
still refuses the retirement — and a note written past the blank line that ends
a scenario is still named and still blocks, wrapped or not.
- Not breaking: the only behavior that moves is which specs `retire_capabilities`
accepts, and it moves strictly toward the specs it was always meant to accept.
No spec that retired before stops retiring, and no content is deleted that was
not already deletable.

## Capabilities

### Modified Capabilities

- `cli-archive`: states that a line continuing a wrapped list item is accounted
for with that item, and that a line opening a block of its own is not.

## Impact

- **Affected behavior**: `openspec archive` and `openspec specs apply` on a change
declaring `retire_capabilities: true`, for specs whose bullets wrap. The
refusal, and the hint that names the blocking lines, are unchanged for every
other shape.
- **Unaffected**: writing, validating and merging specs. `unaccountedContent` is
read only by the retirement decision and the messages that explain it, so no
spec content is parsed differently anywhere else.
- **Docs**: none required — no documented rule changes; a shape that the
documented rule always implied now behaves that way.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
## MODIFIED Requirements

### Requirement: Capability Retirement

A delta whose REMOVED entries cover every requirement a capability has SHALL retire that capability instead of writing a main spec with no requirements, which can never pass validation.

#### Scenario: Deciding that a rebuilt spec cannot be written

- **WHEN** applying a delta leaves the rebuilt spec with no requirement blocks, and every other nonblank line in the whole file is accounted for as the title, Purpose, Requirements header, or a canonical requirement's statement, scenarios, or fenced examples
- **THEN** put that rebuilt spec to the spec validator
- **AND** treat it as retirable only when its sole validation error is that the spec has no requirements
- **AND** otherwise write or reject it exactly as any other rebuilt spec, so a spec the validator still accepts, one broken in some further way, and one still holding a `###` heading are all left alone

#### Scenario: Validation was skipped

- **WHEN** the archive runs with validation disabled
- **THEN** retire nothing, because no verdict was produced to justify a deletion
- **AND** write the rebuilt spec exactly as an archive without this behavior would

#### Scenario: Retirement is not declared

- **WHEN** a rebuilt spec is retirable but the change does not declare `retire_capabilities: true` in its metadata, or declares it in metadata that cannot be honored
- **THEN** write the spec as any other, so the archive aborts on it exactly as it did before this behavior existed
- **AND** name the marker as the fix in that abort, and say when a marker that is present cannot be honored
- **AND** say nothing about the marker when retiring would not have made the spec writable anyway

#### Scenario: Delta removes the capability's last requirement

- **WHEN** a retirable rebuilt spec belongs to a capability whose main spec exists
- **AND** at least one requirement was actually removed by this run
- **AND** the change declares `retire_capabilities: true`
- **THEN** delete the capability's `spec.md` instead of writing it
- **AND** refuse to delete when the target resolves outside the real specs root
- **AND** delete any in-root directory the deletion leaves empty, and never the specs root itself
- **AND** count every operation the delta applied in the archive totals
- **AND** record the retirement in the archive warnings, naming what the deleted file held and giving a pasteable Git recovery command only when the spec lived in the caller's checkout

#### Scenario: Retirement is deferred until every spec is written

- **WHEN** an archive both retires one capability and updates another
- **THEN** settle the archive destination before touching any spec, so a name collision cannot strand a retirement
- **AND** perform the deletion only after every spec write has succeeded
- **AND** report a destination claimed while the merge ran as the same collision, rather than as a raw filesystem error

#### Scenario: Capability directory holds other files

- **WHEN** retiring a capability whose directory still holds other files after `spec.md` is deleted
- **THEN** leave that directory in place

#### Scenario: Removal was already synced

- **WHEN** a retirable rebuilt spec removed nothing this run and its main spec exists
- **THEN** leave the file untouched
- **AND** abort the archive with the validation error, as for any other unwritable spec, unless validation was skipped

#### Scenario: Content the merge cannot account for

- **WHEN** the spec holds any non-blank line the merge cannot name - anywhere in the file, including above the requirements section and inside a requirement block, where content the parser did not read as a new header rides along
- **THEN** refuse the retirement, because deleting the file would take that content with it
- **AND** say which lines stood in the way when the change declared the marker, rather than aborting on the bare validation error

#### Scenario: A bullet that wraps onto further lines

- **WHEN** a line inside a requirement block continues the list item above it - no blank line, heading, fence or new bullet has closed that item, and the line does not open a block of its own
- **THEN** account for it as part of that item rather than as a line in its own right, so a scenario bullet wrapped to a column limit does not refuse the retirement
- **AND** still weigh a table row, block quote, raw HTML, thematic break, fence or heading written directly beneath a bullet on its own, because each opens a block a reader sees as separate content
- **AND** read every CommonMark list marker as a list item, and only those - `-`, `*`, `+` and ordered markers of at most nine digits - so a capability is neither unretirable for the marker its author chose nor deleted because a note happened to open with a long number

#### Scenario: Main spec is already gone

- **WHEN** a REMOVED-only delta targets a capability that has no main spec, and the change declares `retire_capabilities: true`
- **THEN** complete the archive without creating or retiring one
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
## 1. Reproduce

- [x] 1.1 Reproduce #1780 against the current build: the reporter's spec retires
with its `THEN` bullet on one line and is refused with the same bullet
wrapped, naming the wrapped remainder as the blocking content
- [x] 1.2 Confirm the second half of the report — the hint being suppressed — is
already fixed on `main` by #1699, so this change is scoped to the
classification alone

## 2. Fix the classification

- [x] 2.1 Track, inside each requirement block, whether the previous line was a
list item or the continuation of one, and reset it on every construct that
closes an item: a blank line, a fenced line, a setext underline, a scenario
header, and any line that opens a block of its own
- [x] 2.2 Account for a continuation line exactly as the item above it was
accounted for, so a wrapped scenario bullet is silent and a wrapped note
below the last scenario is still named by its own first line
- [x] 2.3 Add `opensOwnBlock` for the constructs that interrupt a paragraph in
CommonMark — ATX heading, fence, block quote, thematic break, table row,
raw HTML — so none of them is swallowed by the bullet above it

## 3. Close the same hole for every list marker

- [x] 3.1 Confirm the gap first: a spec whose scenarios use `+` bullets passes
`openspec validate --specs` and is refused retirement with every one of its
bullets named as unaccounted content
- [x] 3.2 Read `+` as a list marker alongside `-`, `*` and ordered items, and
confirm a `+` note written past the blank line that ends a scenario is
still named and still blocks
- [x] 3.3 Cap an ordered marker at nine digits, where CommonMark caps it, after
review found the same note refused when it opened with a word and deleted
when it opened with a ten-digit number

## 4. Tests

- [x] 4.1 Retire a capability whose scenario bullet wraps with indentation — the
reporter's exact shape
- [x] 4.2 Retire one whose bullet wraps without indentation (CommonMark lazy
continuation), which is what an editor that hard-wraps produces
- [x] 4.3 Retire one wrapped under each list marker — `-`, `*`, `+` and an
ordered item
- [x] 4.4 Retire one whose bullet wraps onto three lines, so the item is known to
stay open past the first continuation
- [x] 4.5 Retire one whose spec is saved with CRLF endings, since the reporter
ran on Windows and Windows is in the CI matrix
- [x] 4.6 Keep refusing when a wrapped note follows the last scenario, and assert
the scenario's own wrapped remainder is not named among the blocking lines
- [x] 4.7 Keep refusing a `+` note written past the blank line, and assert the
scenario's own `+` bullets are no longer named alongside it
- [x] 4.8 Keep refusing a table row, block quote, raw HTML, and heading written
directly beneath a bullet
- [x] 4.9 Keep refusing a scenario whose bullets are split by a blank line - the
shape this repository's own `cli-show` spec uses
- [x] 4.10 Name a note opening with a ten-digit ordered marker, and keep
retiring one whose scenario uses a nine-digit marker, so the cap is held
from both sides
- [x] 4.11 Pin the deliberate edge: a line written under a bullet with no blank
line above it counts as part of that bullet

## 5. Verify

- [x] 5.1 Mutation-check every new guard against `main` and by reverting each
guard in turn: 11 of the 15 cases fail on `main`; neutering
`opensOwnBlock` kills the four block cases; dropping `+` from the marker
set kills the plus case; closing the item after the first continuation
kills the three-line wrap and the lazy-aside case; loosening the digit cap
kills the long-marker case and tightening it kills the nine-digit case
- [x] 5.2 Run the full suite and confirm no existing test changes behavior
- [x] 5.3 Run lint, typecheck and the build
- [x] 5.4 Run `openspec validate --specs --strict` on this repo
- [x] 5.5 Sweep all 36 of this repository's own specs through a simulated
retirement on both `main` and this branch: identical verdicts (30
retirable, 6 blocked), so no real spec changes classification
- [x] 5.6 Add a `.changeset/` entry
61 changes: 59 additions & 2 deletions src/core/specs-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,26 @@ function firstForeignTail(raw: string): { heading: string; raw: string } | undef
return undefined;
}

/**
* Whether a line inside a requirement block starts a block of its own rather
* than continuing the list item above it.
*
* CommonMark lets a paragraph inside a list item run over several lines, and
* only a handful of constructs interrupt it. Everything listed here is one, so
* a table, a quotation, raw HTML, a rule, a fence or a heading written directly
* under a bullet is still weighed on its own instead of riding along with the
* bullet into a deletion.
*/
function opensOwnBlock(line: string): boolean {
return (
/^ {0,3}#{1,6}(?:[ \t]|$)/.test(line) || // ATX heading
/^ {0,3}(?:```|~~~)/.test(line) || // fence
/^ {0,3}>/.test(line) || // block quote
/^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/.test(line) || // thematic break
/^\s*[|<]/.test(line) // table row or raw HTML
);
}

/**
* The non-blank lines of a spec that are not part of what a retirement is able
* to name: the title, the `## Purpose` section, the `## Requirements` header,
Expand Down Expand Up @@ -704,35 +724,57 @@ function contentTheMergeCannotName(parts: RequirementsSectionParts): string[] {
// operational note below the last scenario be deleted unmentioned.
let inScenarioBullets = false;
let bulletsSeen = false;
// Whether the previous line was a list item, or the continuation of one. A
// bullet too long for the file's column limit wraps, and the wrapped
// remainder is the same item - not a line of its own (#1780). Reading it as
// its own line made every scenario bullet longer than one line unaccounted
// content, which refused the retirement of any spec that wraps its bullets.
let inListItem = false;
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
if (!line.trim()) {
// Only a blank that follows actual bullets closes the run, so a blank
// between a scenario header and its first bullet is not a boundary.
if (bulletsSeen) inScenarioBullets = false;
inListItem = false;
continue;
}
if (index === 0) continue; // the `### Requirement:` header itself
// Fenced lines render as a code block inside the requirement, so they are
// its own content however they are spelled - a `### Requirement:` in an
// example is not a heading to any reader. Flagging them made a spec that
// merely documents a command unretirable.
if (mask[index]) continue;
if (mask[index]) {
inListItem = false;
continue;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (
index > 1 &&
/^ {0,3}(?:=+|-+)\s*$/.test(line) &&
lines[index - 1].trim()
) {
leftovers.push(lines[index - 1].trim());
inListItem = false;
continue;
}
if (/^ {0,3}####\s+Scenario:/i.test(line)) {
seenScenario = true;
inScenarioBullets = true;
bulletsSeen = false;
inListItem = false;
continue;
}
if (/^\s*(?:[-*]|\d+[.)])\s/.test(line)) {
// Every CommonMark list marker, and only those. A spec bulleted with `+`
// validates like any other, and its scenario bullets were read as
// unaccounted prose - the same defect as a wrapped line, wearing a
// different marker: a capability written that way could not be retired at
// all. The digit cap is the other half of "only those": CommonMark stops
// an ordered marker at nine digits, so `1234567890.` opens a paragraph,
// not a list. Reading it as a marker meant an authored note was refused
// when it began with a word and deleted when it began with a long enough
// number - the same line, two verdicts.
if (/^\s*(?:[-*+]|\d{1,9}[.)])\s/.test(line)) {
inListItem = true;
if (inScenarioBullets) {
bulletsSeen = true;
continue;
Expand All @@ -743,6 +785,21 @@ function contentTheMergeCannotName(parts: RequirementsSectionParts): string[] {
leftovers.push(line.trim());
continue;
}
// The rest of a bullet that wrapped. Nothing has closed the item - no
// blank line, no heading, no new bullet - so this line is more of the
// same item and is accounted for exactly as the item was: reported
// already if the item was reported, and silent if it was not. A line
// that opens a block of its own is excluded, so this never swallows a
// table, a quote or raw HTML written under a bullet.
//
// Indentation is not required, because CommonMark does not require it:
// a wrapped line joins the item above whether or not it lines up under
// it, and this repository's own specs hold both spellings. That does
// mean an aside written with no blank line above it counts as part of
// the bullet - which is what it is to every reader and every renderer,
// and a blank line is all it takes to be weighed on its own.
if (inListItem && !opensOwnBlock(line)) continue;
inListItem = false;
// Free prose above the first scenario is the requirement statement.
if (!seenScenario && !/^\s*[|<]/.test(line)) continue;
leftovers.push(line.trim());
Expand Down
Loading
Loading