diff --git a/.changeset/wrapped-bullet-retirement.md b/.changeset/wrapped-bullet-retirement.md new file mode 100644 index 0000000000..451c3238a8 --- /dev/null +++ b/.changeset/wrapped-bullet-retirement.md @@ -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. diff --git a/openspec/changes/fix-wrapped-scenario-bullet-retirement/.openspec.yaml b/openspec/changes/fix-wrapped-scenario-bullet-retirement/.openspec.yaml new file mode 100644 index 0000000000..1d9aeef992 --- /dev/null +++ b/openspec/changes/fix-wrapped-scenario-bullet-retirement/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-04 diff --git a/openspec/changes/fix-wrapped-scenario-bullet-retirement/proposal.md b/openspec/changes/fix-wrapped-scenario-bullet-retirement/proposal.md new file mode 100644 index 0000000000..8c661e5cef --- /dev/null +++ b/openspec/changes/fix-wrapped-scenario-bullet-retirement/proposal.md @@ -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. diff --git a/openspec/changes/fix-wrapped-scenario-bullet-retirement/specs/cli-archive/spec.md b/openspec/changes/fix-wrapped-scenario-bullet-retirement/specs/cli-archive/spec.md new file mode 100644 index 0000000000..58af243895 --- /dev/null +++ b/openspec/changes/fix-wrapped-scenario-bullet-retirement/specs/cli-archive/spec.md @@ -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 diff --git a/openspec/changes/fix-wrapped-scenario-bullet-retirement/tasks.md b/openspec/changes/fix-wrapped-scenario-bullet-retirement/tasks.md new file mode 100644 index 0000000000..10ffbcdd80 --- /dev/null +++ b/openspec/changes/fix-wrapped-scenario-bullet-retirement/tasks.md @@ -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 diff --git a/src/core/specs-apply.ts b/src/core/specs-apply.ts index 821a7b2ca6..680033a266 100644 --- a/src/core/specs-apply.ts +++ b/src/core/specs-apply.ts @@ -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, @@ -704,12 +724,19 @@ 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 @@ -717,22 +744,37 @@ function contentTheMergeCannotName(parts: RequirementsSectionParts): string[] { // 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; + } 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; @@ -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()); diff --git a/test/core/archive.test.ts b/test/core/archive.test.ts index bbdfcf1bed..9de7a89583 100644 --- a/test/core/archive.test.ts +++ b/test/core/archive.test.ts @@ -4230,6 +4230,265 @@ The system SHALL do the thing differently. ).resolves.not.toThrow(); }); + // #1780: a repository that wraps prose at a column limit wraps its + // scenario bullets too, and the wrapped remainder is more of the same + // bullet. Reading each such line as a line of its own made it "content the + // merge cannot account for", which refused the retirement - so on those + // repositories `retire_capabilities` could not retire anything at all. + describe('wrapped scenario bullets (#1780)', () => { + async function retireWith(changeName: string, requirement: string[]): Promise { + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + mainSpec('legacy-layer', requirement.join('\n')) + ); + await archiveCommand.execute(changeName, { yes: true }); + return mainSpecDir; + } + + const WRAPPED_BULLET = [ + '### Requirement: The system SHALL provide a legacy layer', + 'The system SHALL provide a legacy layer to existing consumers.', + '', + '#### Scenario: Layer is available', + '- **WHEN** a consumer imports the layer', + '- **THEN** the outstanding count becomes zero and the completions are recorded rather', + ]; + + it('retires a capability whose scenario bullet wraps with indentation', async () => { + const mainSpecDir = await retireWith('retire-wrapped-indented', [ + ...WRAPPED_BULLET, + ' than the earned total being reduced', + ]); + + expect(process.exitCode).not.toBe(1); + await expect(fs.access(mainSpecDir)).rejects.toThrow(); + }); + + it('retires a capability whose scenario bullet wraps without indentation', async () => { + // CommonMark lazy continuation: the remainder does not have to line up + // under the bullet to belong to it, and editors that hard-wrap rarely + // indent it. + const mainSpecDir = await retireWith('retire-wrapped-lazy', [ + ...WRAPPED_BULLET, + 'than the earned total being reduced', + ]); + + expect(process.exitCode).not.toBe(1); + await expect(fs.access(mainSpecDir)).rejects.toThrow(); + }); + + it('still refuses when a wrapped note follows the last scenario', async () => { + // The note is a bullet of its own, written past the blank line that + // ends the scenario - what the guard exists to catch. Its own wrapped + // remainder must not weaken that: the note is still named, and the + // retirement still refused. + const mainSpecDir = await retireWith('retire-wrapped-note', [ + ...WRAPPED_BULLET, + ' than the earned total being reduced', + '', + '- Operational note: the layer is mirrored nightly to the reporting', + ' warehouse, which nothing else records', + ]); + + expect(process.exitCode).toBe(1); + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).resolves.not.toThrow(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('- Operational note: the layer is mirrored nightly to the reporting') + ); + // The scenario's own wrapped remainder is not one of the lines standing + // in the way, so it is not named among them. + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('"than the earned total being reduced"') + ); + }); + + it.each([ + ['a dash bullet', '-'], + ['a star bullet', '*'], + ['a plus bullet', '+'], + ['an ordered item', '1.'], + ])('retires a capability whose scenario wraps %s', async (_label, marker) => { + // Every CommonMark list marker, not only the `-` the reporter used. A + // spec bulleted with `+` validates like any other and could not be + // retired at all before this - the same defect wearing a different + // marker. + const mainSpecDir = await retireWith(`retire-marker-${marker.replace(/\W/g, 'x')}`, [ + '### Requirement: The system SHALL provide a legacy layer', + 'The system SHALL provide a legacy layer to existing consumers.', + '', + '#### Scenario: Layer is available', + `${marker} **WHEN** a consumer imports the layer and the outstanding count is read`, + `${marker} **THEN** the count becomes zero and the completions are recorded rather`, + ' than the earned total being reduced', + ]); + + expect(process.exitCode).not.toBe(1); + await expect(fs.access(mainSpecDir)).rejects.toThrow(); + }); + + it('still names a plus-bulleted note written below the last scenario', async () => { + // Widening the marker set must not widen what a note may hide behind: + // past the blank line that ends a scenario, `+` is an authored aside + // exactly as `-` is. + const mainSpecDir = await retireWith('retire-plus-note', [ + '### Requirement: The system SHALL provide a legacy layer', + 'The system SHALL provide a legacy layer to existing consumers.', + '', + '#### Scenario: Layer is available', + '+ **WHEN** a consumer imports the layer', + '+ **THEN** the layer is available', + '', + '+ Operational note: mirrored nightly to the reporting warehouse', + ]); + + expect(process.exitCode).toBe(1); + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).resolves.not.toThrow(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('+ Operational note: mirrored nightly to the reporting warehouse') + ); + // The scenario's own `+` bullets are the scenario's, so only the note + // stands in the way. Before the marker set was widened every one of + // them was named here, which buried the line that actually mattered. + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('"+ **WHEN** a consumer imports the layer"') + ); + }); + + it('retires a capability whose bullet wraps onto several lines', async () => { + // The item stays open across every continuation, not just the first. + const mainSpecDir = await retireWith('retire-wrapped-thrice', [ + ...WRAPPED_BULLET, + ' than the earned total being reduced, which would understate the', + ' historical record for every consumer downstream', + ]); + + expect(process.exitCode).not.toBe(1); + await expect(fs.access(mainSpecDir)).rejects.toThrow(); + }); + + it('retires a capability whose wrapped spec is saved with CRLF endings', async () => { + // Windows is in the support matrix and the reporter ran there. The + // block is split on normalised endings, so a `\r` must not survive + // into the line the classifier reads. + const changeName = 'retire-wrapped-crlf'; + await createChange(changeName, 'legacy-layer', REMOVE_ALL); + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'legacy-layer'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + mainSpec( + 'legacy-layer', + [...WRAPPED_BULLET, ' than the earned total being reduced'].join('\n') + ).replace(/\n/g, '\r\n') + ); + + await archiveCommand.execute(changeName, { yes: true }); + + expect(process.exitCode).not.toBe(1); + await expect(fs.access(mainSpecDir)).rejects.toThrow(); + }); + + it('still names a note whose first characters look like a long ordered marker', async () => { + // CommonMark stops an ordered marker at nine digits, so this line opens + // a paragraph, not a list. Read as a marker it was silently deleted, + // while the same note beginning with a word was refused - one line, two + // verdicts, decided by nothing a reader can see. + const mainSpecDir = await retireWith('retire-long-ordered-marker', [ + '### Requirement: The system SHALL provide a legacy layer', + 'The system SHALL provide a legacy layer to existing consumers.', + '', + '#### Scenario: Layer is available', + '1234567890. Migration note: export the mirror table by hand first.', + '- **WHEN** a consumer imports the layer', + '- **THEN** the layer is available', + ]); + + expect(process.exitCode).toBe(1); + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).resolves.not.toThrow(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('1234567890. Migration note: export the mirror table by hand first.') + ); + }); + + it('still retires a capability whose scenario uses a nine-digit ordered marker', async () => { + // The cap is where CommonMark puts it, not one digit lower: a marker it + // accepts must still read as a list item. + const mainSpecDir = await retireWith('retire-nine-digit-marker', [ + '### Requirement: The system SHALL provide a legacy layer', + 'The system SHALL provide a legacy layer to existing consumers.', + '', + '#### Scenario: Layer is available', + '123456789. **WHEN** a consumer imports the layer and the count is read', + '123456789. **THEN** the count becomes zero and the completions are recorded', + ' rather than the earned total being reduced', + ]); + + expect(process.exitCode).not.toBe(1); + await expect(fs.access(mainSpecDir)).rejects.toThrow(); + }); + + it('still refuses a scenario whose bullets are split by a blank line', async () => { + // The shape this repository's own `cli-show` spec uses, and the + // documented limitation: past a blank line, a bullet reads the same as + // a note written below the scenario, and no line-based rule separates + // them. It must keep refusing - the blank line that closes the bullet + // run closes the wrapped item with it. + const mainSpecDir = await retireWith('retire-split-scenario', [ + ...WRAPPED_BULLET, + ' than the earned total being reduced', + '', + '- **WHEN** the second path runs', + '- **THEN** the layer is still available', + ]); + + expect(process.exitCode).toBe(1); + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).resolves.not.toThrow(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('- **WHEN** the second path runs') + ); + }); + + it('reads a line written under a bullet with no blank line as part of it', async () => { + // The deliberate edge of the rule above, pinned so it stays deliberate. + // CommonMark joins this line to the bullet whether or not it is + // indented, so every renderer shows it as part of that bullet - and a + // blank line is all it takes to have it weighed on its own, which the + // wrapped-note case above proves still works. + const mainSpecDir = await retireWith('retire-lazy-aside', [ + ...WRAPPED_BULLET, + ' than the earned total being reduced', + 'Mirrored nightly to the reporting warehouse.', + ]); + + expect(process.exitCode).not.toBe(1); + await expect(fs.access(mainSpecDir)).rejects.toThrow(); + }); + + it.each([ + ['a table row', '| region | mirror |'], + ['a block quote', '> Mirrored nightly to the reporting warehouse.'], + ['raw HTML', '
Mirrored nightly to the reporting warehouse.
'], + ['a heading', '##### Mirroring'], + ])('still refuses %s written directly under a bullet', async (label, line) => { + // These interrupt a paragraph in CommonMark, so they are blocks of + // their own rather than more of the bullet above them - authored + // content the deletion would take unmentioned. + const mainSpecDir = await retireWith( + `retire-block-${label.replace(/\W+/g, '-')}`, + [...WRAPPED_BULLET, ' than the earned total being reduced', line] + ); + + expect(process.exitCode).toBe(1); + await expect(fs.access(path.join(mainSpecDir, 'spec.md'))).resolves.not.toThrow(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('content the merge cannot safely account for') + ); + }); + }); + it('refuses to retire an H1 section written after Purpose', async () => { const changeName = 'retire-h1-after-purpose'; await createChange(changeName, 'legacy-layer', REMOVE_ALL);