mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 14:38:54 +08:00
fix(archive): make the Purpose carry-over safe and consistent
Three adversarial reviews of the carry-over found the guard added in
651b42c was too narrow and the guidance half-landed. Addressed:
Engine
- Replace the two-rule structural guard with a readability check against
the parser validate/list/archive actually use. A Purpose body holding a
heading or an unterminated code fence used to abort the archive, or
write a spec with a duplicated `## Requirements` that its own validator
rejects. Both now fall back to the placeholder and warn.
- Ignore markdown inside HTML comments when locating the Purpose, so a
commented-out draft cannot beat the real section and an unfilled
template placeholder counts as empty.
- Warn when a carried Purpose is under the strict-mode minimum: the old
placeholder always cleared it, so this was the first way archive could
leave a spec that `validate --strict` fails.
- Warn instead of silently dropping a delta Purpose when the main spec
already exists.
Guidance, which disagreed with itself and with the agent path
- openspec-sync-specs told agents to write TBD, so `/openspec-archive`
undid what the CLI now does. It carries the delta Purpose too.
- The specs artifact template and the instruction's own example had no
`## Purpose` while the prose asked for one.
- Document the section in concepts, writing-specs, their website copies,
openspec-conventions and specs-sync-skill; state the 50-character
threshold and how to change an existing spec's Purpose.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
651b42c6eb
commit
fff5fb2256
@@ -1,5 +1,7 @@
|
||||
---
|
||||
'@fission-ai/openspec': patch
|
||||
"@fission-ai/openspec": patch
|
||||
---
|
||||
|
||||
`openspec archive` now carries a delta spec's `## Purpose` into the main spec it creates for a brand-new capability, instead of always writing the `TBD - created by archiving change <name>. Update Purpose after archive.` placeholder over it. The section body is copied verbatim, fenced code blocks included. The placeholder still appears when the delta has no `## Purpose` header outside a code fence, when the section body is empty, or when carrying the body over would put a requirement header outside `## Requirements` (that last case warns and still completes the archive). The Purpose of an existing main spec is never touched.
|
||||
A delta spec that introduces a brand-new capability can now open with a `## Purpose`, and `openspec archive` uses it as the Purpose of the main spec it creates instead of always writing the `TBD - created by archiving change <name>. Update Purpose after archive.` placeholder over it. The body is copied trimmed but otherwise verbatim, fenced code blocks included. The `specs` artifact instruction, its example, the delta template and the `openspec-sync-specs` skill all now tell authors and agents to write one, so the CLI and agent-driven sync paths produce the same main spec.
|
||||
|
||||
Archive keeps the placeholder, and says why, when the delta has no `## Purpose` outside a code fence or HTML comment, when the section body is empty, or when carrying the body over would leave a spec the main spec parser cannot read. A carried Purpose shorter than the strict-mode minimum is kept but warned about, since `openspec validate --strict` reports it as too brief. The Purpose of an existing main spec is never touched, and archive now warns instead of dropping a delta's Purpose silently in that case.
|
||||
|
||||
@@ -393,6 +393,7 @@ The system MUST expire sessions after 15 minutes of inactivity.
|
||||
| `## ADDED Requirements` | New behavior | Appended to main spec |
|
||||
| `## MODIFIED Requirements` | Changed behavior | Replaces existing requirement |
|
||||
| `## REMOVED Requirements` | Deprecated behavior | Deleted from main spec |
|
||||
| `## Purpose` | What a brand-new capability is for | Seeds the Purpose of the main spec being created; ignored when the spec already exists |
|
||||
|
||||
### Why Deltas Instead of Full Specs
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ A change describes its edits to the specs with three section types. Using the ri
|
||||
|
||||
On archive, ADDED gets appended to the main spec, MODIFIED replaces the old version, and REMOVED is deleted. If you mark a real change as ADDED, you end up with two competing requirements; if you describe new behavior as MODIFIED, there's nothing to replace. When in doubt, open the current spec and see whether the requirement is already there.
|
||||
|
||||
One more section is worth knowing about. When your delta creates a capability that doesn't exist yet, open it with `## Purpose` — a sentence or two on what the capability is for. Archive uses it as the Purpose of the main spec it creates; skip it and you get a `TBD` placeholder to fill in by hand. An existing spec already has a Purpose, so a delta's is ignored there — edit `openspec/specs/<capability>/spec.md` directly to change one.
|
||||
|
||||
## Right-size the change
|
||||
|
||||
The single most common authoring mistake isn't a badly worded requirement — it's a change that's trying to be three changes.
|
||||
|
||||
@@ -93,23 +93,35 @@ Before moving the change to archive, the command SHALL apply delta changes to ma
|
||||
#### Scenario: New main spec inherits the delta's Purpose
|
||||
|
||||
- **WHEN** a delta creates a main spec that does not exist yet
|
||||
- **AND** the delta spec has a `## Purpose` header that is not itself inside a fenced code block
|
||||
- **AND** that section's body is not empty
|
||||
- **THEN** write the section body into the new main spec verbatim, fenced code blocks included
|
||||
- **AND** the delta spec has a line-initial `## Purpose` header that is not inside a fenced code block or an HTML comment
|
||||
- **AND** the section body, ignoring fenced blocks and HTML comments, is not empty
|
||||
- **THEN** write the section body into the new main spec, trimmed but otherwise verbatim, fenced code blocks and comments included
|
||||
- **AND** the section body runs to the next `## ` heading outside a fenced block
|
||||
|
||||
#### Scenario: New main spec without an authored Purpose
|
||||
|
||||
- **WHEN** a delta creates a main spec that does not exist yet
|
||||
- **AND** the delta spec has no `## Purpose` header outside a fenced code block, or the section body is empty
|
||||
- **AND** the delta spec has no such `## Purpose` header, or that section's body is empty once fenced blocks and HTML comments are ignored
|
||||
- **THEN** write the TBD placeholder Purpose naming the change to update after archive
|
||||
|
||||
#### Scenario: Delta Purpose that would invalidate the new main spec
|
||||
#### Scenario: Delta Purpose that would leave the new main spec unreadable
|
||||
|
||||
- **WHEN** a delta creates a main spec that does not exist yet
|
||||
- **AND** carrying its `## Purpose` body over would place a requirement header outside the `## Requirements` section
|
||||
- **AND** carrying its `## Purpose` body over would leave a spec the main spec parser cannot read - a heading or requirement header that truncates a section, or an unterminated code fence that swallows one
|
||||
- **THEN** write the TBD placeholder Purpose instead and warn that the delta Purpose was ignored
|
||||
- **AND** complete the archive rather than aborting it
|
||||
|
||||
#### Scenario: Carried Purpose shorter than the strict-mode minimum
|
||||
|
||||
- **WHEN** a carried Purpose is shorter than the minimum Purpose length strict validation enforces
|
||||
- **THEN** carry it over unchanged and warn that `openspec validate --strict` reports it as too brief
|
||||
|
||||
#### Scenario: Delta Purpose for a capability that already has a main spec
|
||||
|
||||
- **WHEN** a delta carries a `## Purpose` and the target main spec already exists
|
||||
- **THEN** leave the existing Purpose untouched
|
||||
- **AND** warn that the delta Purpose was ignored, naming the main spec to edit directly
|
||||
|
||||
### Requirement: Confirmation Behavior
|
||||
|
||||
The spec update confirmation SHALL provide clear visibility into changes before they are applied.
|
||||
|
||||
@@ -150,10 +150,18 @@ Change proposals SHALL store only the additions, modifications, and removals to
|
||||
The `changes/[name]/specs/` directory SHALL contain:
|
||||
- Delta files showing only what changes
|
||||
- Sections for ADDED, MODIFIED, REMOVED, and RENAMED requirements
|
||||
- An optional `## Purpose` section on deltas that introduce a new capability
|
||||
- Normalized header matching for requirement identification
|
||||
- Complete requirements using the structured format
|
||||
- Clear indication of change type for each requirement
|
||||
|
||||
#### Scenario: Introducing a new capability
|
||||
|
||||
- **WHEN** a delta introduces a capability that has no main spec yet
|
||||
- **THEN** the delta MAY open with a `## Purpose` section describing the capability
|
||||
- **AND** that Purpose SHALL seed the main spec created for it
|
||||
- **AND** a delta for a capability that already has a main spec SHALL NOT carry a `## Purpose`, because the existing Purpose is authoritative
|
||||
|
||||
#### Scenario: Using standard output symbols
|
||||
|
||||
- **WHEN** displaying delta operations in CLI output
|
||||
|
||||
@@ -55,6 +55,8 @@ The agent SHALL reconcile main specs with delta specs using the delta operation
|
||||
#### Scenario: New capability spec
|
||||
- **WHEN** delta spec exists for a capability not in main specs
|
||||
- **THEN** create new main spec file at `openspec/specs/<capability>/spec.md`
|
||||
- **AND** copy the delta's `## Purpose` body into it when the delta has one, matching what `openspec archive` does
|
||||
- **AND** write a brief TBD placeholder Purpose only when the delta has none
|
||||
|
||||
#### Scenario: Merged main spec keeps canonical structure
|
||||
- **WHEN** the agent writes a main spec during sync
|
||||
|
||||
@@ -82,11 +82,14 @@ artifacts:
|
||||
- Every requirement MUST have at least one scenario.
|
||||
|
||||
New capabilities only: start the delta spec with a `## Purpose` section -
|
||||
one or two sentences describing what the capability is for. Archive copies
|
||||
it into the main spec it creates; without it the new main spec is left with
|
||||
a `TBD ... Update Purpose after archive` placeholder to fill in by hand.
|
||||
Do NOT add `## Purpose` to a delta for an existing capability - that spec
|
||||
already has one and the delta's is ignored.
|
||||
one or two sentences (50+ characters, or `openspec validate --strict`
|
||||
reports it as too brief) describing what the capability is for. Archive
|
||||
copies it into the main spec it creates; without it the new main spec is
|
||||
left with a `TBD ... Update Purpose after archive` placeholder to fill in
|
||||
by hand. Do NOT add `## Purpose` to a delta for an existing capability -
|
||||
that spec already has one and the delta's is ignored. To change an
|
||||
existing capability's Purpose - including a leftover `TBD` placeholder -
|
||||
edit `openspec/specs/<capability>/spec.md` directly.
|
||||
|
||||
MODIFIED requirements workflow:
|
||||
1. Locate the existing requirement in openspec/specs/<capability>/spec.md
|
||||
@@ -97,8 +100,12 @@ artifacts:
|
||||
Common pitfall: Using MODIFIED with partial content loses detail at archive time.
|
||||
If adding new concerns without changing existing behavior, use ADDED instead.
|
||||
|
||||
Example:
|
||||
Example (a new capability, so it opens with `## Purpose`):
|
||||
```
|
||||
## Purpose
|
||||
|
||||
Lets users take their data out of the product in a portable format.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: User can export data
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
## Purpose
|
||||
<!-- New capabilities only: one or two sentences (50+ characters) on what this capability is for. Delete this section for an existing capability. -->
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: <!-- requirement name -->
|
||||
|
||||
@@ -78,7 +78,8 @@ This is an **agent-driven** operation - you will read delta specs and directly e
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `<planningHome.root>/openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Purpose section: copy the delta's `## Purpose` body verbatim when it has one
|
||||
(this is what `openspec archive` does); only write a brief TBD placeholder when it does not
|
||||
- Add Requirements section with the ADDED requirements
|
||||
- Follow the **Main Spec Format Reference** below
|
||||
|
||||
|
||||
+69
-14
@@ -16,7 +16,9 @@ import {
|
||||
} from './parsers/requirement-blocks.js';
|
||||
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
|
||||
import { buildCodeFenceMask } from './parsers/code-fence.js';
|
||||
import { MarkdownParser } from './parsers/markdown-parser.js';
|
||||
import { Validator } from './validation/validator.js';
|
||||
import { MIN_PURPOSE_LENGTH } from './validation/constants.js';
|
||||
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -201,10 +203,22 @@ export async function buildUpdatedSpec(
|
||||
}
|
||||
|
||||
// Load or create base target content
|
||||
const deltaPurpose = extractPurposeSection(changeContent);
|
||||
let targetContent: string;
|
||||
let isNewSpec = false;
|
||||
try {
|
||||
targetContent = await fs.readFile(update.target, 'utf-8');
|
||||
// A delta Purpose only seeds a spec that does not exist yet. Say so rather
|
||||
// than dropping it silently - the specs instruction tells authors to write
|
||||
// one for new capabilities, and the delta file looks identical either way.
|
||||
if (deltaPurpose && !options.silent) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ Warning: ${specName} - delta Purpose ignored; ${specName} already has one. ` +
|
||||
`Edit openspec/specs/${specName}/spec.md directly to change it.`
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
|
||||
// REMOVED will be ignored with a warning since there's nothing to remove
|
||||
@@ -222,19 +236,27 @@ export async function buildUpdatedSpec(
|
||||
);
|
||||
}
|
||||
isNewSpec = true;
|
||||
targetContent = buildSpecSkeleton(specName, changeName, extractPurposeSection(changeContent));
|
||||
// A carried Purpose that hides a requirement header would make the new main
|
||||
// spec structurally invalid and abort an archive that succeeded before
|
||||
// #1413. Keep the placeholder rather than turning a warning into a failure.
|
||||
if (findMainSpecStructureIssues(targetContent).length > 0) {
|
||||
targetContent = buildSpecSkeleton(specName, changeName, deltaPurpose);
|
||||
// Keep the placeholder rather than turning this into a failure: these
|
||||
// deltas archived cleanly before the Purpose carry-over existed.
|
||||
if (!isSkeletonReadable(targetContent, specName)) {
|
||||
targetContent = buildSpecSkeleton(specName, changeName);
|
||||
if (!options.silent) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ Warning: ${specName} - delta Purpose ignored (it contains a requirement header); wrote the placeholder Purpose instead.`
|
||||
`⚠️ Warning: ${specName} - delta Purpose ignored (it would leave the new spec unreadable); wrote the placeholder Purpose instead.`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (deltaPurpose && deltaPurpose.length < MIN_PURPOSE_LENGTH && !options.silent) {
|
||||
// The placeholder always cleared this threshold, so a carried Purpose is
|
||||
// the first way archive can leave a spec that `validate --strict` fails.
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ Warning: ${specName} - carried Purpose is under ${MIN_PURPOSE_LENGTH} characters; ` +
|
||||
`openspec validate --strict reports it as too brief.`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,28 +435,61 @@ export async function writeUpdatedSpec(
|
||||
if (counts.renamed) console.log(` → ${counts.renamed} renamed`);
|
||||
}
|
||||
|
||||
/** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
|
||||
function maskHtmlComments(content: string): string {
|
||||
return content.replace(/<!--[\s\S]*?-->/g, comment => comment.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the body of a `## Purpose` section, ignoring fenced code blocks.
|
||||
* Returns undefined when the section is absent or empty.
|
||||
* Read the body of a `## Purpose` section, ignoring markdown that only appears
|
||||
* inside fenced code blocks or HTML comments. Returns undefined when the
|
||||
* section is absent or its body is empty.
|
||||
*/
|
||||
function extractPurposeSection(content: string): string | undefined {
|
||||
const lines = content.replace(/\r\n?/g, '\n').split('\n');
|
||||
const mask = buildCodeFenceMask(lines);
|
||||
const start = lines.findIndex((line, i) => !mask[i] && /^##\s+Purpose\s*$/i.test(line));
|
||||
const normalized = content.replace(/\r\n?/g, '\n');
|
||||
const lines = normalized.split('\n');
|
||||
// Structure is read from the masked copy so a commented-out or fenced
|
||||
// `## Purpose` is not mistaken for the real one; the body is returned from
|
||||
// the original lines so an author's own comments and fences survive intact.
|
||||
const masked = maskHtmlComments(normalized).split('\n');
|
||||
const fenceMask = buildCodeFenceMask(masked);
|
||||
const isStructural = (i: number) => !fenceMask[i];
|
||||
|
||||
const start = masked.findIndex((line, i) => isStructural(i) && /^##\s+Purpose\s*$/i.test(line));
|
||||
if (start === -1) return undefined;
|
||||
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (!mask[i] && /^##\s+/.test(lines[i])) {
|
||||
let end = masked.length;
|
||||
for (let i = start + 1; i < masked.length; i++) {
|
||||
if (isStructural(i) && /^##\s+/.test(masked[i])) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Emptiness is judged on the masked body so a comment-only Purpose (an
|
||||
// unfilled template placeholder) falls back to the TBD placeholder.
|
||||
if (!masked.slice(start + 1, end).join('\n').trim()) return undefined;
|
||||
|
||||
const body = lines.slice(start + 1, end).join('\n').trim();
|
||||
return body || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A carried Purpose must leave the new main spec readable by the same parser
|
||||
* that `validate`, `list` and a later `archive` use. A body containing a
|
||||
* heading, a stray requirement header, or an unterminated code fence silently
|
||||
* swallows or truncates the sections around it, so archive would abort or
|
||||
* write a spec its own validator rejects (#1413).
|
||||
*/
|
||||
function isSkeletonReadable(skeleton: string, specName: string): boolean {
|
||||
if (findMainSpecStructureIssues(skeleton).length > 0) return false;
|
||||
try {
|
||||
return new MarkdownParser(skeleton).parseSpec(specName).overview.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a skeleton spec for new capabilities. When the delta spec authored a
|
||||
* `## Purpose`, carry it over instead of the TBD placeholder (#1413) - archive
|
||||
|
||||
@@ -80,7 +80,8 @@ ${STORE_SELECTION_GUIDANCE}
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create \`<planningHome.root>/openspec/specs/<capability>/spec.md\`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Purpose section: copy the delta's \`## Purpose\` body verbatim when it has one
|
||||
(this is what \`openspec archive\` does); only write a brief TBD placeholder when it does not
|
||||
- Add Requirements section with the ADDED requirements
|
||||
- Follow the **Main Spec Format Reference** below
|
||||
|
||||
@@ -252,7 +253,8 @@ ${STORE_SELECTION_GUIDANCE}
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create \`<planningHome.root>/openspec/specs/<capability>/spec.md\`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Purpose section: copy the delta's \`## Purpose\` body verbatim when it has one
|
||||
(this is what \`openspec archive\` does); only write a brief TBD placeholder when it does not
|
||||
- Add Requirements section with the ADDED requirements
|
||||
- Follow the **Main Spec Format Reference** below
|
||||
|
||||
|
||||
+185
-1
@@ -670,7 +670,7 @@ The system SHALL handle widgets.
|
||||
expect(updatedContent).not.toContain('### Requirement: Stray header');
|
||||
expect(updatedContent).toContain('### Requirement: Real Requirement');
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Warning: widgets - delta Purpose ignored (it contains a requirement header)`)
|
||||
expect.stringContaining('Warning: widgets - delta Purpose ignored (it would leave the new spec unreadable)')
|
||||
);
|
||||
|
||||
// The archive still completed rather than aborting.
|
||||
@@ -678,6 +678,186 @@ The system SHALL handle widgets.
|
||||
expect(archives.some(a => a.includes(changeName))).toBe(true);
|
||||
});
|
||||
|
||||
it('should fall back to the placeholder when the delta Purpose contains a heading (issue #1413)', async () => {
|
||||
const changeName = 'new-spec-with-heading-in-purpose';
|
||||
const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'gadgets');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
// An `#` heading truncates the Purpose section when the spec is read back,
|
||||
// leaving a spec whose own validator rejects it for having no Purpose.
|
||||
const specContent = `## Purpose
|
||||
|
||||
# Not a spec title
|
||||
Some body text that is comfortably longer than the strict-mode minimum length.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Handle Gadget
|
||||
The system SHALL handle gadgets.
|
||||
|
||||
#### Scenario: Gadget handled
|
||||
- **WHEN** a gadget arrives
|
||||
- **THEN** it is handled
|
||||
`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
const updatedContent = await fs.readFile(
|
||||
path.join(tempDir, 'openspec', 'specs', 'gadgets', 'spec.md'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(updatedContent).toContain(
|
||||
`TBD - created by archiving change ${changeName}. Update Purpose after archive.`
|
||||
);
|
||||
expect(updatedContent).not.toContain('# Not a spec title');
|
||||
// The rebuilt spec must still satisfy the validator archive itself runs.
|
||||
const report = await new Validator().validateSpecContent('gadgets', updatedContent);
|
||||
expect(report.issues.filter(i => i.level === 'ERROR')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should fall back to the placeholder when the delta Purpose has an unterminated fence (issue #1413)', async () => {
|
||||
const changeName = 'new-spec-with-unterminated-fence';
|
||||
const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'mesh-config');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
// The open fence masks everything after it, so the Purpose body would
|
||||
// swallow the skeleton's own ## Requirements header.
|
||||
const specContent = `## ADDED Requirements
|
||||
|
||||
### Requirement: Normalize Mesh Config
|
||||
The system SHALL normalize mesh config.
|
||||
|
||||
#### Scenario: Config normalized
|
||||
- **WHEN** config is loaded
|
||||
- **THEN** it is normalized
|
||||
|
||||
## Purpose
|
||||
|
||||
Normalizes configuration for every service in the mesh. Canonical shape:
|
||||
|
||||
\`\`\`yaml
|
||||
retries: 3
|
||||
`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
const updatedContent = await fs.readFile(
|
||||
path.join(tempDir, 'openspec', 'specs', 'mesh-config', 'spec.md'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(updatedContent).toContain(
|
||||
`TBD - created by archiving change ${changeName}. Update Purpose after archive.`
|
||||
);
|
||||
// Exactly one Requirements section, and the requirement is still visible.
|
||||
expect(updatedContent.match(/^## Requirements$/gm)).toHaveLength(1);
|
||||
const report = await new Validator().validateSpecContent('mesh-config', updatedContent);
|
||||
expect(report.issues.filter(i => i.level === 'ERROR')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should ignore a commented-out Purpose in favor of the real one (issue #1413)', async () => {
|
||||
const changeName = 'new-spec-with-commented-purpose';
|
||||
const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'loyalty-v2');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
const specContent = `<!--
|
||||
## Purpose
|
||||
Draft purpose the author commented out while rewriting the section.
|
||||
-->
|
||||
|
||||
## Purpose
|
||||
|
||||
Manages the loyalty program end to end across the storefront and admin console.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Earn Points
|
||||
The system SHALL award loyalty points.
|
||||
|
||||
#### Scenario: Points earned
|
||||
- **WHEN** an order completes
|
||||
- **THEN** points are credited
|
||||
`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
const updatedContent = await fs.readFile(
|
||||
path.join(tempDir, 'openspec', 'specs', 'loyalty-v2', 'spec.md'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(updatedContent).toContain('Manages the loyalty program end to end');
|
||||
expect(updatedContent).not.toContain('Draft purpose the author commented out');
|
||||
expect(updatedContent).not.toContain('-->');
|
||||
});
|
||||
|
||||
it('should keep the placeholder when the delta Purpose is only an HTML comment (issue #1413)', async () => {
|
||||
const changeName = 'new-spec-with-unfilled-template';
|
||||
const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'unfilled');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
// This is the shipped delta template left unfilled.
|
||||
const specContent = `## Purpose
|
||||
<!-- New capabilities only: one or two sentences on what this capability is for. -->
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Do Thing
|
||||
The system SHALL do the thing.
|
||||
|
||||
#### Scenario: Thing done
|
||||
- **WHEN** asked
|
||||
- **THEN** done
|
||||
`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
const updatedContent = await fs.readFile(
|
||||
path.join(tempDir, 'openspec', 'specs', 'unfilled', 'spec.md'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(updatedContent).toContain(
|
||||
`TBD - created by archiving change ${changeName}. Update Purpose after archive.`
|
||||
);
|
||||
expect(updatedContent).not.toContain('New capabilities only');
|
||||
});
|
||||
|
||||
it('should warn when a carried Purpose is under the strict-mode minimum (issue #1413)', async () => {
|
||||
const changeName = 'new-spec-with-brief-purpose';
|
||||
const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'points');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
const specContent = `## Purpose
|
||||
|
||||
Tracks loyalty points.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Track Points
|
||||
The system SHALL track points.
|
||||
|
||||
#### Scenario: Points tracked
|
||||
- **WHEN** an order completes
|
||||
- **THEN** points are tracked
|
||||
`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), specContent);
|
||||
|
||||
await archiveCommand.execute(changeName, { yes: true, noValidate: true });
|
||||
|
||||
const updatedContent = await fs.readFile(
|
||||
path.join(tempDir, 'openspec', 'specs', 'points', 'spec.md'),
|
||||
'utf-8'
|
||||
);
|
||||
// The author's words are kept - the warning exists so the strict-mode
|
||||
// failure is not a surprise later.
|
||||
expect(updatedContent).toContain('Tracks loyalty points.');
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('carried Purpose is under 50 characters')
|
||||
);
|
||||
});
|
||||
|
||||
it('should not overwrite the Purpose of an existing main spec (issue #1413)', async () => {
|
||||
const changeName = 'existing-spec-with-purpose';
|
||||
const changeSpecDir = path.join(tempDir, 'openspec', 'changes', changeName, 'specs', 'billing');
|
||||
@@ -726,6 +906,10 @@ The system SHALL refund the card on file.
|
||||
expect(updatedContent).toContain('The established purpose that must survive archiving.');
|
||||
expect(updatedContent).not.toContain('A purpose written in the delta that must be ignored');
|
||||
expect(updatedContent).toContain('### Requirement: Refund Card');
|
||||
// Dropping it silently would be indistinguishable from it having worked.
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('billing - delta Purpose ignored; billing already has one')
|
||||
);
|
||||
});
|
||||
|
||||
it('should still error on MODIFIED when creating new spec file', async () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
|
||||
getContinueChangeSkillTemplate: '5cc6cf74c055ae67b08373421d934ece65dacbccafbc7452ab5636df3eb9e862',
|
||||
getApplyChangeSkillTemplate: '0f5a15fc7fb9ad6059a5643d0e01365d27642637a4aaebf182f9eabb45348197',
|
||||
getFfChangeSkillTemplate: '097a9ff9533900f227cac0523289eae4e19f06a081e5f355a8374dbecf3ff55d',
|
||||
getSyncSpecsSkillTemplate: '32c3169e1ee0345a174c0bacb8fd16db73477cc006d8cedbedc6077233c5461b',
|
||||
getSyncSpecsSkillTemplate: '7479bfc91d28d86d3e791bf5bec905be1dae58dba3c922e8e74f97c92041d0a7',
|
||||
getOnboardSkillTemplate: 'bc2216b72724b01c3a733e63b8bf4aff457f561c0e9ff7288bdacc39780a37a7',
|
||||
getOpsxExploreCommandTemplate: 'eef1f8b4fd90ade6d70be46f0f8c3e6722f221fed175a6f9cf626287ef504a94',
|
||||
getOpsxNewCommandTemplate: '57c600cce318d16b9b4308a18d0d983ea3c0673034e606a7cceec07b4c705e87',
|
||||
@@ -51,7 +51,7 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
|
||||
getOpsxFfCommandTemplate: '264b514cc4849f91fb4414f639484c4181f1e5850d0d788ef276c851efa92859',
|
||||
getArchiveChangeSkillTemplate: '206a22b6778e97c30da9145ef51fdad449b8c995538f6fc25752ef551a37b675',
|
||||
getBulkArchiveChangeSkillTemplate: '2b74b1f73380ff32e35f580734780d843c6161a2748c39edb07f1e00453771b4',
|
||||
getOpsxSyncCommandTemplate: '68dc44c9be2ec1ef719a4ed59830e5a0bc74c3ba6113070650266e1b0d153071',
|
||||
getOpsxSyncCommandTemplate: '48f2c5171e9b86db1f418723ddfb9215ce7dfb494de34a21ec969eac1fb3de8c',
|
||||
getVerifyChangeSkillTemplate: 'cab4db01b5d2b1243d63d90c53747d8b39e488c60f76eba3fe8b994467f69267',
|
||||
getOpsxArchiveCommandTemplate: '7dea65d0e2e17db366bb666ba6ae5e205ea02707b8c5c7707565200875c78916',
|
||||
getOpsxOnboardCommandTemplate: '9430a0fb6530791ab720e068f4b172bc3dfc4e96a1ae29102bee0b92c2afe7b5',
|
||||
@@ -70,7 +70,7 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
|
||||
'openspec-continue-change': '02ec4de061ad6277866b877497a1e66142ba364e12b83dd7dedb838579ea88db',
|
||||
'openspec-apply-change': '09c0e1cdf5ccc82416d0969d6bd715cc70616bdbc3531358a5c36057f78be55a',
|
||||
'openspec-ff-change': 'ff3bd3eac427a1e50071ad7c70f73b556cffa3db43e90da2726e96849c3fc886',
|
||||
'openspec-sync-specs': 'd1bcd420bf8fb55a13f58a2857e6ebde58eb6f9e721a3bf6876bd9f640a63859',
|
||||
'openspec-sync-specs': 'e08e40eae3bd55bf825adcda301676b690dcc018c56e3786bd62bbd2bfb81342',
|
||||
'openspec-archive-change': '64b1611dd7aee04ca268820d1b193e8bf0a39ff3672ec6ba21fb0a1bcb1786c2',
|
||||
'openspec-bulk-archive-change': '49d410bda408c0411decd584be9c2355335e3b3db760fc6a0adcd82c172a280f',
|
||||
'openspec-verify-change': '57693d22940f06080c6cf8d590ac2f48240d4a5e9ce7074dacd0f8d3c9945afa',
|
||||
|
||||
Reference in New Issue
Block a user