Documentation
¶
Overview ¶
Package spec is the format engine: the pure-Go, parse/render half of spec-lifecycle's reimplementation of the OpenSpec on-disk format (implementation-plan.md §0.5, §2.3; spec-lifecycle.md §6.1), pinned to the grammar of `@fission-ai/openspec` v1.5.0 (commit 546224e).
Two document shapes share one grammar:
- A living capability spec — openspec/specs/<capability>/spec.md — an ordered set of "### Requirement:" blocks inside a single "## Requirements" section. Parsed by ParseRequirementSet into a *RequirementSet.
- A change's capability delta — openspec/changes/<change>/specs/<capability>/spec.md — the same requirement-block grammar, but grouped under up to four "## ADDED|MODIFIED|REMOVED|RENAMED Requirements" sections. Parsed by ParseDelta into a *Delta.
Byte fidelity. This package never reformats a requirement's interior: a Requirement's Raw field is the exact source bytes of its block (header line through its last scenario), and RequirementSet.Render re-emits those Raw blocks verbatim, joined by the same fixed separators OpenSpec's own fold uses (single blank line between blocks; a single newline between a section header and its body). That is deliberate — it mirrors how OpenSpec's `buildUpdatedSpec` achieves byte-stable folding (it relocates untouched raw blocks rather than re-serializing parsed fields), and it is what makes this package's round-trip properties hold:
- parse(render(x)) == x for any *RequirementSet x built by this package (by the parser, or by NewRequirement + direct struct construction of RequirementSet) — rendering and re-parsing recovers the same value.
- render(parse(b)) converges to a stable canonical form for arbitrary well-formed input bytes b — re-rendering that canonical form is a fixed point (parsing it again and rendering again yields identical bytes), even though b's own incidental whitespace may not survive the first pass unchanged.
Grammar decisions made where the v1.5.0 source was internally ambiguous or inconsistent are called out on the relevant regexp var docs in parse.go and delta.go.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Delta ¶
type Delta struct {
Added []Requirement
Modified []Requirement
// Removed holds requirement names only (no body) — REMOVED entries may
// carry Reason/Migration prose in real fixtures, but the format only
// ever keys the fold off the name (see delta.go's ParseDelta doc for
// the oracle citation this mirrors).
Removed []string
Renamed []Rename
Present DeltaSections
}
Delta is the parsed form of a change's capability delta — openspec/changes/<change>/specs/<capability>/spec.md — the op set grouped exactly as the four H2 sections group it.
func ParseDelta ¶
ParseDelta parses a change's capability delta — openspec/changes/<change>/specs/<capability>/spec.md — into a *Delta: an op set grouped by its "## ADDED|MODIFIED|REMOVED|RENAMED Requirements" H2 sections. It mirrors OpenSpec's requirement-blocks.ts parseDeltaSpec for section/body extraction, plus the load-bearing checks validator.ts's validateChangeDeltaSpecs and specs-apply.ts's buildUpdatedSpec pre-validation enforce before a fold is attempted:
- ADDED/MODIFIED requirements must have body text containing SHALL or MUST, and at least one scenario.
- No duplicate requirement name within a section; no duplicate FROM/TO within RENAMED; every FROM has a matching TO.
- No cross-section conflict: a name can't be ADDED and REMOVED, or MODIFIED and REMOVED, or MODIFIED and ADDED; a RENAMED FROM can't also be MODIFIED (MODIFIED must reference the new name); a RENAMED TO can't collide with an ADDED name.
- At least one recognized delta section must be present.
A section header appearing twice (e.g. two "## ADDED Requirements") is also an error: real OpenSpec's splitTopLevelSections silently lets the second occurrence overwrite the first (a plain JS object-key assignment) — this package refuses instead, per the project's "conflicts detected, not silently dropped" posture (implementation-plan.md §0.5).
REMOVED entries carry only a requirement Name, never a body: real REMOVED blocks may carry human-readable "Reason"/"Migration" prose (see e.g. OpenSpec's cli-diff spec.md, archived 2025-08-19), but specs-apply.ts's buildUpdatedSpec only ever keys REMOVED off the name (`plan.removed: string[]`) — that prose has no fold-time meaning, so this package does not model it.
type DeltaSections ¶
type DeltaSections struct {
Added, Modified, Removed, Renamed bool
}
DeltaSections reports which of the four delta H2 sections were textually present in the source, even one that parsed to zero entries — so a caller (e.g. a future M2 validator) can distinguish "section absent" from "section present but empty" (mirrors OpenSpec's DeltaPlan.sectionPresence).
type Error ¶
Error is a precise, position-anchored parse error: every failure this package raises names the 1-based source Line (0 if not tied to a single line) and, where one exists, the offending Header text, in addition to a human-readable Msg.
type Kind ¶
type Kind string
Kind identifies the class of a parse error, independent of its message — callers (a future validator, tests) can switch on Kind without matching prose.
const ( // KindMissingRequirementName is a "### Requirement:" header with no // name after the colon. KindMissingRequirementName Kind = "missing_requirement_name" // KindMissingScenarioName is a "#### Scenario:" header with no name // after the colon. KindMissingScenarioName Kind = "missing_scenario_name" // KindDuplicateRequirement is two "### Requirement:" headers with the // same name (case-insensitive) in the same section. KindDuplicateRequirement Kind = "duplicate_requirement" // KindDuplicateScenario is two "#### Scenario:" headers with the same // name (case-insensitive) under the same requirement. KindDuplicateScenario Kind = "duplicate_scenario" // KindDeltaHeaderInLivingSpec is a "## ADDED|MODIFIED|REMOVED|RENAMED // Requirements" header found in a living spec, where only a change's // delta spec.md may have one. KindDeltaHeaderInLivingSpec Kind = "delta_header_in_living_spec" // KindRequirementOutsideSection is a "### Requirement:" header found // outside the "## Requirements" section of a living spec. KindRequirementOutsideSection Kind = "requirement_outside_requirements_section" )
Living-spec structural error kinds.
const ( // KindNoDeltaSections is a delta spec.md with none of the four // recognized H2 sections present at all. KindNoDeltaSections Kind = "no_delta_sections" // KindEmptyDeltaSection is a recognized H2 section present with zero // requirement/rename entries parsed from its body. KindEmptyDeltaSection Kind = "empty_delta_section" // KindDuplicateDeltaSection is the same H2 section title appearing // more than once in one delta spec.md. KindDuplicateDeltaSection Kind = "duplicate_delta_section" // KindMissingRequirementBody is an ADDED/MODIFIED requirement whose // header is followed by no body text at all. KindMissingRequirementBody Kind = "missing_requirement_body" // KindMissingRFC2119 is an ADDED/MODIFIED requirement whose body lacks // the load-bearing SHALL/MUST keyword. KindMissingRFC2119 Kind = "missing_rfc2119_keyword" // KindMissingScenarioBlock is an ADDED/MODIFIED requirement with zero // "#### Scenario:" children. KindMissingScenarioBlock Kind = "missing_scenario_block" // KindDanglingRename is a RENAMED FROM with no matching TO (or vice // versa). KindDanglingRename Kind = "dangling_rename" // KindDuplicateRenameFrom is two RENAMED pairs with the same FROM // name. KindDuplicateRenameFrom Kind = "duplicate_rename_from" // KindDuplicateRenameTo is two RENAMED pairs with the same TO name. KindDuplicateRenameTo Kind = "duplicate_rename_to" // KindConflictingOps is a requirement name (or RENAMED pair) claimed // by two conflicting operations in the same delta. KindConflictingOps Kind = "conflicting_delta_ops" )
Delta-grammar error kinds.
const ( // KindFoldRenameSourceMissing is a RENAMED FROM naming a requirement // that does not exist in the capability's current requirement set at // the point RENAMED is applied. KindFoldRenameSourceMissing Kind = "fold_rename_source_missing" // KindFoldRenameTargetExists is a RENAMED TO naming a requirement that // already exists in the capability's current requirement set (other // than the FROM entry being renamed) — folding it would silently // overwrite that requirement. KindFoldRenameTargetExists Kind = "fold_rename_target_exists" // KindFoldRemoveMissing is a REMOVED entry naming a requirement that // does not exist in the capability's current requirement set at the // point REMOVED is applied. KindFoldRemoveMissing Kind = "fold_remove_missing" // KindFoldModifyMissing is a MODIFIED entry naming a requirement that // does not exist in the capability's current requirement set at the // point MODIFIED is applied (after RENAMED/REMOVED have already run — // matches the oracle's own "not found" archive failure). KindFoldModifyMissing Kind = "fold_modify_missing" // KindFoldAddExists is an ADDED entry naming a requirement that // already exists in the capability's current requirement set at the // point ADDED is applied (after RENAMED/REMOVED/MODIFIED have already // run — matches the oracle's own "already exists" archive failure). KindFoldAddExists Kind = "fold_add_exists" )
Fold error kinds — base-spec-aware checks that ParseDelta cannot make on its own (it never sees the living spec it will be applied to). See fold.go's package doc for the divergence-from-oracle table these correspond to.
type Op ¶
type Op string
Op is one of the four delta operations a change's capability delta can carry, matching the H2 section it comes from.
const ( OpRenamed Op = "RENAMED" OpRemoved Op = "REMOVED" OpModified Op = "MODIFIED" OpAdded Op = "ADDED" )
The four delta operations, in the fixed fold order (RENAMED -> REMOVED -> MODIFIED -> ADDED) that implementation-plan.md §0.5/§2.4 pins — recorded here for callers (e.g. a future fold) that need the canonical order; this package's parser and renderer do not themselves depend on it.
type Requirement ¶
type Requirement struct {
Name string
// Raw is the block's exact source text, from the "### Requirement:"
// header line through the end of the block (its own body plus every
// nested scenario), byte-for-byte as written, trimmed only of trailing
// blank lines. Render always re-emits Raw verbatim: OpenSpec's own fold
// relocates untouched raw blocks rather than re-serializing them field
// by field, and this package mirrors that so round-tripping never
// reformats a requirement's interior.
Raw string
// Body and Scenarios are structured views derived from Raw at parse
// time (or computed by NewRequirement), for callers that need
// structured access — a future validator, a fold, a display. They are
// not independently round-tripped: constructing a Requirement literal
// by hand with a Body/Scenarios that disagrees with Raw is a caller
// error (Render always wins with Raw; use NewRequirement to keep them
// consistent).
Body string
Scenarios []Scenario
}
Requirement is a single "### Requirement: <name>" block — the unit both a living spec's Requirements section and a change delta's ADDED/MODIFIED sections are built from.
func NewRequirement ¶
func NewRequirement(name, body string, scenarios []Scenario) Requirement
NewRequirement builds a Requirement from structured fields, deriving a canonical Raw block from them. Use this to synthesize a requirement (e.g. in tests, or a future fold/validate caller) rather than assembling a Requirement literal whose Raw might drift from its Body/Scenarios.
type RequirementSet ¶
type RequirementSet struct {
// Before is everything in the source before the "## Requirements"
// header line, verbatim: the title, the Purpose section, anything else
// a spec.md carries above its Requirements. Use Title/Purpose for
// structured reads; edit Before directly to change them.
Before string
// Preamble is content between the "## Requirements" header line and the
// first requirement block. Normally empty (no real corpus fixture has
// one); preserved verbatim when present.
Preamble string
// Requirements is the ordered set of requirement blocks.
Requirements []Requirement
// After is content following the Requirements section. Normally empty
// (stored as "\n"); preserved verbatim when present.
After string
// HasRequirementsSection reports whether a "## Requirements" header
// should appear in Render's output even if Requirements is currently
// empty. ParseRequirementSet sets this true whenever it found a real
// "## Requirements" header in the source (however many requirements
// were inside it, including zero) and false when it found none at all.
// Render trusts this rather than inferring it from Requirements being
// empty: inventing a header that was never in the source would not be
// a faithful round-trip of a document this package could not otherwise
// recognize as spec-shaped (e.g. one entirely swallowed by an
// unterminated code fence — see render_test.go/fuzz_test.go). A caller
// synthesizing a brand-new, still-empty capability spec should set
// this true explicitly.
HasRequirementsSection bool
}
RequirementSet is the parsed form of a capability's Requirements section — either a living spec's openspec/specs/<capability>/spec.md, or (via Delta.Added/Delta.Modified, which share the Requirement type) an ADDED/MODIFIED delta section. The shape mirrors OpenSpec's own before/header/preamble/blocks/after split (requirement-blocks.ts extractRequirementsSection) exactly, because that is the split whose join rules make fold byte-stable.
func Fold ¶
func Fold(capability, changeName string, base *RequirementSet, d *Delta) (*RequirementSet, error)
Fold applies one change's capability delta to that capability's current requirement set — the Go reimplementation of OpenSpec's specs-apply.ts buildUpdatedSpec (implementation-plan.md §0.5/§2.3 spike 3; spec-lifecycle.md §6.1) — and returns the folded *RequirementSet ready for Render.
base is the capability's living spec.md, already parsed by ParseRequirementSet, or nil if the capability does not exist yet: Fold then synthesizes the oracle's exact new-capability skeleton (buildSpecSkeleton) — "# <capability> Specification" / "## Purpose" / "TBD - created by archiving change <changeName>. Update Purpose after archive." / an empty "## Requirements" section — before folding into it. capability is the bare capability name (e.g. "auth", used verbatim in the skeleton title) and changeName is the change folder name (e.g. "001-add-password-login", baked into the skeleton Purpose sentence).
Ops apply in the fixed order the oracle uses, RENAMED -> REMOVED -> MODIFIED -> ADDED (spec-lifecycle.md §6.1), against a single ordered working set keyed by lower-cased requirement name:
- RENAMED deletes the FROM entry and inserts it under the TO key, regenerating its "### Requirement:" header (via NewRequirement) but preserving its Body/Scenarios verbatim — this is why a rename-only delta moves the requirement to the END of the file (delete+insert on a Go map-like ordered set never rewrites in place), while a RENAMED+MODIFIED of the same requirement keeps the ORIGINAL position (RENAMED's insert already created the key; MODIFIED's subsequent update-in-place does not move it). Locked by fold_test.go against conformance cases 05/06.
- REMOVED deletes the named entry.
- MODIFIED replaces the named entry's value in place (position unchanged) with the delta's authored requirement block.
- ADDED inserts a brand-new entry at the end.
Divergences from the oracle (implementation-plan.md §0.5's "conflicts detected, never silently dropped" posture — see also §12 spike 3 and testdata/conformance/README.md's oracle-quirks section):
| Case | Oracle (as probed/inferred) | This engine |
|---------------------------------------------------|-------------------------------------------------------|------------------------------------------------|
| MODIFIED of a nonexistent requirement | hard error ("... not found"), exit 1, matches ours | KindFoldModifyMissing — MATCHES oracle |
| ADDED of an already-existing name | hard error ("... already exists"), exit 1, matches ours| KindFoldAddExists — MATCHES oracle |
| REMOVED of a nonexistent requirement | not probed; a JS Map.delete() of a missing key is a silent no-op, not a throw — likely silent | KindFoldRemoveMissing — DELIBERATE DIVERGENCE (error, not silent no-op) |
| RENAMED FROM naming a nonexistent requirement | not probed; likely a silent no-op insert of a fresh TO entry, losing the intended rename | KindFoldRenameSourceMissing — DELIBERATE DIVERGENCE (error) |
| RENAMED TO colliding with an untouched existing name | not probed; a JS Map.set() on an existing key silently overwrites its value — the #1246 silent-loss class | KindFoldRenameTargetExists — DELIBERATE DIVERGENCE (error) |
None of the ten conformance-corpus cases exercises a divergent path (all are well-formed deltas against consistent base specs), so no corpus case trips one of these — verified by conformance_test.go.
func ParseRequirementSet ¶
func ParseRequirementSet(content []byte) (*RequirementSet, error)
ParseRequirementSet parses a living capability spec — openspec/specs/<capability>/spec.md — into a *RequirementSet. It mirrors OpenSpec's extractRequirementsSection (requirement-blocks.ts): it looks for a single "## Requirements" H2 header and, if present, splits its body into ordered "### Requirement:" blocks; everything else in the document (title, Purpose section, any other top-level content) is preserved verbatim in RequirementSet.Before/After.
A missing "## Requirements" section is NOT an error: it mirrors the oracle's own "spec doesn't have one yet" fold path (buildSpecSkeleton), producing a RequirementSet with no requirements and HasRequirementsSection=false, that Render still reproduces byte-for-byte and a future fold can still target (by setting HasRequirementsSection and appending Requirements). This package does not enforce that a Purpose section exists either — that is deliberate: the grammar this package pins itself to is the byte-stable fold grammar (extractRequirementsSection), not OpenSpec's separate Zod-schema validation layer, which is a policy concern the plan assigns to M2's internal/validate (implementation-plan.md §2.3, §8 M1 vs M2).
What IS a structural error: a "### Requirement:" header appearing outside the "## Requirements" section, and a delta section header ("## ADDED Requirements" etc.) appearing anywhere in a living spec — both mirror spec-structure.ts's findMainSpecStructureIssues.
func (*RequirementSet) Purpose ¶
func (rs *RequirementSet) Purpose() string
Purpose returns the trimmed content of the first "## Purpose" section found in Before, or "" if none is present. A read view only; edit Before to change it.
func (*RequirementSet) Render ¶
func (rs *RequirementSet) Render() []byte
Render serializes a RequirementSet back to spec.md bytes: the inverse of ParseRequirementSet. Every requirement's Raw block is re-emitted verbatim; only the separators BETWEEN blocks are canonical (a single blank line between requirement blocks and between Before/Preamble and the "## Requirements" header, mirroring OpenSpec buildUpdatedSpec's own join-then-collapse: `[before, headerLine, reqBody, after].join('\n')` followed by collapsing runs of 3+ newlines to exactly one blank line).
When there is no "## Requirements" section to render at all (!HasRequirementsSection, no requirements, and no preamble), Render does NOT invent one: it reproduces Before/After only. Inventing structure that was never in the source would break the render(parse(b)) fixed-point property for a document this package cannot recognize as spec-shaped (e.g. one entirely swallowed by an unterminated code fence).
func (*RequirementSet) Title ¶
func (rs *RequirementSet) Title() string
Title returns the text of the document's first level-1 ("# ") heading found in Before, or "" if none is present. A read view only — OpenSpec's own parser never uses this heading for anything but display, and this package does not require it to be present.