spec

package
v0.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 1, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package spec is abcd's transport-agnostic native spec store (intent lifecycle, itd-64). It owns the in-memory model of spec records and the disk operations that mint, load, and transition them. Every function takes a structured request and returns a structured result; nothing here writes to stdout or knows about a CLI, MCP, or hook surface — the front doors under internal/surface/* marshal these results for their transport.

A spec record is a markdown file under <repoRoot>/.abcd/development/specs/{open,closed}/spc-N-<slug>.md. The bucket directory IS the status (directory-as-truth: open/ vs closed/, with no status: frontmatter field), mirroring how intents encode their lifecycle by bucket. The load-bearing field is intent: itd-N, the link a spec declares to the intent it realises.

Frontmatter is read by the shared internal/core/frontmatter line scanner, not a YAML parser — the package pulls in zero new dependencies. Ids are validated against strict regexes before any path is built, so a hostile id can never traverse out of the spec store.

Index

Constants

View Source
const (
	StatusOpen   = "open"
	StatusClosed = "closed"
)

Bucket directory names. The directory a spec file lives in is its status.

View Source
const FootprintHeading = "## Footprint"

FootprintHeading is the section a spec declares its footprint under.

View Source
const ImplicitStepTitle = "the whole spec"

ImplicitStepTitle is the title of the one step an unstepped spec is.

View Source
const (
	// SpecsRelDir is the spec-store root, relative to the repo worktree.
	SpecsRelDir = ".abcd/development/specs"
)

Repo-relative store locations.

View Source
const StepsHeading = "## Steps"

StepsHeading is the section a spec lists its steps under (itd-2609212103565953; adr-2609212115255771, decision 4). A step is the unit below a spec: one lane and one pull request. The loop's `implement step` uses the same word for the same thing.

Variables

View Source
var ErrStoreLockBusy = errors.New("spec: could not acquire the spec store's lock")

ErrStoreLockBusy is the spec store's lock not granted within a writer's budget. The three-lock acquisition in the intent package retries on it, with every earlier lock released between attempts.

View Source
var ErrUnclosedSpan = errors.New("unclosed span")

ErrUnclosedSpan is ParseSteps' refusal of a document holding a fenced block or HTML comment that nothing closes. It is its own error so a caller names the right remedy: close the opener, not rewrite the section.

Functions

func BodyIsStub

func BodyIsStub(content string) bool

BodyIsStub reports whether a spec body still carries the minted placeholder — i.e. no human has written the design record yet.

func Discard added in v0.11.1

func Discard(repoRoot string, sp Spec) error

Discard takes back a spec a refused operation minted a moment ago, under the store's lock, so the removal cannot interleave with another writer's read and write of the same file — a repoint writing it back would resurrect it. Only a spec in open/, named as the mint names one, is removed; any other path is refused before anything is touched. A spec already gone is not an error, and a tree with no spec store has none to remove: the lock is taken in storeMustExist mode, so nothing is planted there.

func HasNum added in v0.6.7

func HasNum(v string) bool

HasNum reports whether v is a spec reference carrying a usable number. It is the tolerant counterpart of the ^spc-[0-9]+$ argument grammar, for validating a STORED spec_id, which the record lint lets carry a slug or zero padding.

func SameNum added in v0.6.7

func SameNum(a, b string) bool

SameNum reports whether two spec references name the same spec number — the canonical comparison the record lint makes, so a verb using it can never refuse a spelling the lint accepts (spc-9, spc-9-widget, spc-009 are one spec). A value carrying no usable number (null, "spc-", "spc-abc", an over-int64 N) matches nothing, including another such value.

func Validate

func Validate(s Spec) error

Validate enforces the id regexes and that intent is a well-formed itd-N. It is the fail-closed guard both Load and the minting path run before trusting a record's id in a filesystem path.

func WithStoreLock added in v0.11.1

func WithStoreLock(repoRoot string, fn func() error) error

WithStoreLock runs fn holding the spec store's lock — the one every spec writer takes, not a second one — for a caller outside this package that writes a spec record. A caller that also writes ledger or intent records takes it through intent.WithLedgerThenMintLock, which takes the three in order and never holds an earlier lock while it waits for a later one.

A tree with no spec store runs fn WITHOUT the lock: taking it creates the store, and a verb that writes no spec must not plant an empty one. With no store there is no spec record for fn to race.

func WithStoreLockWithin added in v0.11.1

func WithStoreLockWithin(repoRoot string, timeout time.Duration, fn func() error) error

WithStoreLockWithin is WithStoreLock with its own acquisition budget; a lock not granted within it is ErrStoreLockBusy, and fn has not run.

Types

type Footprint added in v0.12.0

type Footprint struct {
	// Present is false when the spec carries no `## Footprint` section.
	Present bool `json:"present"`
	// Packages are the packages the spec expects to touch, in the order
	// written; empty when the section names none.
	Packages []string `json:"packages"`
	// Tests is the tests text, one line; empty when the section names none.
	Tests string `json:"tests"`
}

Footprint is what a spec's `## Footprint` section declares.

func ReadFootprint added in v0.12.0

func ReadFootprint(content string) Footprint

ReadFootprint reads the first live `## Footprint` section of a spec: a heading inside a fence or a comment is an example, not the section. A key given twice keeps its first value, the one a reader meets first.

type Spec

type Spec struct {
	ID     string `json:"id"`     // spc-N
	Slug   string `json:"slug"`   // kebab-case
	Intent string `json:"intent"` // itd-N, the load-bearing link
	Status string `json:"status"` // open | closed (directory-as-truth)
	Path   string `json:"path"`   // repo-relative markdown path
	// Intents is every intent a bundle's shared spec realises, in the order they
	// were planned, the first being the one `intent:` names; Bundle is the name
	// the members carry in their own `bundle:` field, which the close ships
	// together (itd-34). Both are empty on an ordinary spec, which realises the
	// one intent its `intent:` names.
	Intents []string `json:"intents,omitempty"`
	Bundle  string   `json:"bundle,omitempty"`
}

Spec is one spec record. Status is the bucket it was found in; Path is repo-relative (never an absolute local path).

func Close

func Close(repoRoot, specID string) (Spec, error)

Close moves a spec file open/ -> closed/ via os.Rename (atomic on one filesystem) and returns the updated Spec. It fails closed if the spec is missing or already closed. The linked intent is deliberately left untouched: moving it is a later reconcile concern that consumes Spec.Intent. The read and the rename are one critical section under the store's lock, so a writer holding it — a repoint between its read and its write — finishes before the spec moves. A tree with no spec store holds no spec to close, so the lock is taken in storeMustExist mode: the id is refused as not found and nothing is planted, even when the store is removed a moment before the lock.

func Create

func Create(repoRoot, intentID, slug, productionMode string) (Spec, error)

Create mints a native timestamp-numeric spc id through the shared recordid seam and writes specs/open/spc-N-<slug>.md with the intent link and the origin/production_mode disclosure pair in frontmatter. Both the intent id and the slug are validated before any path is built (the slug becomes a filename), as is the production mode — a spec's text is written directly rather than derived from another record or a reading item, so its arrival path is researcher-authored (the route, not who ran the command) and is derived here rather than asked for. An empty mode takes the vocabulary's default. The write is atomic.

func CreateBundle added in v0.11.1

func CreateBundle(repoRoot string, members []string, bundle, productionMode string) (Spec, error)

CreateBundle mints ONE spec realising every member of a bundle (itd-34): its `intent:` names the first member, its `intents:` list names all of them in order, and its `bundle:` carries the name the members' own `bundle:` fields hold, so the close can ship them together. The bundle name is the spec's slug too. A bundle has at least two members, each named once (canonically), and every id and the name are validated before the mint.

func CreateWithSteps added in v0.11.0

func CreateWithSteps(repoRoot, intentID, slug, productionMode string, steps []Step) (Spec, error)

CreateWithSteps is Create with the minted spec's `## Steps` section seeded from steps — the unlanded steps a remainder carries forward from the spec it splits from (itd-2609212103565953). No steps seeds the empty placeholder.

func (Spec) Members added in v0.11.1

func (s Spec) Members() []string

Members is every intent the spec realises, in order: its `intent:` back-link first, then each `intents:` entry not already listed. A hand-written list that omits `intent:` still has it counted, and a repeat (canonically, as Names compares) counts once. An ordinary spec has the one member.

func (Spec) Names added in v0.11.1

func (s Spec) Names(intentID string) bool

Names reports whether the spec realises intentID: its `intent:` back-link, or any member its `intents:` list carries. The match is canonical (recordid.SameID), for the reason SpecsForIntent states.

type Step added in v0.11.0

type Step struct {
	// Number is the step's 1-based position in document order. The list's own
	// numerals are not trusted for order: a renderer renumbers them anyway.
	Number   int    `json:"number"`
	Title    string `json:"title"`
	Packages string `json:"packages,omitempty"`
	Tests    string `json:"tests,omitempty"`
	Landed   string `json:"landed,omitempty"`
	// Implicit marks the one step of a spec that lists none: the whole spec,
	// built as one step (the intent's decision 2).
	Implicit bool `json:"implicit,omitempty"`
	// contains filtered or unexported fields
}

Step is one entry of a spec's `## Steps` section: an ordered, independently landable piece of the spec, with its own footprint.

The section is a numbered list. Each item's first line is the step's title; the lines indented beneath it are its body, where three keyed bullets are read:

  1. The parser - packages: internal/core/spec - tests: the parser over a stepped and an unstepped spec - landed: #123

`landed:` names what landed the step (a pull request or a commit); a step without one, or with a null value, is not landed. Any other indented line is the author's and is carried verbatim wherever the step is copied.

func ParseSteps added in v0.11.0

func ParseSteps(content string) ([]Step, error)

ParseSteps returns the steps a spec's `## Steps` section lists, in order. An absent section, an empty one, or one holding only italic guidance lists none and returns nil: the caller that wants the build's view asks Steps, which turns that into one implicit step.

It fails closed. Anything in the section that is not a numbered step, a line indented under one, a blank line or leading guidance is an error naming the line — never a guess — because the remainder copy writes from this parse (unrecognized-input-never-writes). A second `## Steps` heading is an error too: which one is the section is undecidable. Which lines are live is mdrecord's answer: a heading inside a fenced block or an HTML comment elsewhere is an example, or parked, and is not the section; a fenced block or comment inside the section is refused, since a step inside one is not a step.

A document holding a fence or comment that nothing closes is refused whole (ErrUnclosedSpan), naming the opener's line. The span runs to end of file, so a `## Steps` heading below it is masked and the section would read as absent — zero steps, no error — which a remainder copy would turn into a silent drop of every unlanded step. The opener is the fault, not the section.

func ReadSteps added in v0.11.0

func ReadSteps(repoRoot string, sp Spec) ([]Step, error)

ReadSteps reads a stored spec's listed steps (ParseSteps over its file), through the store's trust-boundary reader.

func Steps added in v0.11.0

func Steps(content string) ([]Step, error)

Steps is the build's view of a spec: the steps it lists, or — when it lists none — one implicit step, the whole spec. It refuses what ParseSteps refuses.

func Unlanded added in v0.11.0

func Unlanded(steps []Step) []Step

Unlanded returns the steps not marked landed, in order. An implicit step is never returned: it names the spec, not a piece of it.

type Store

type Store struct {
	Specs []Spec `json:"specs"`
}

Store is the in-memory set of spec records discovered under both buckets.

func Load

func Load(repoRoot string) (Store, error)

Load discovers spec files under both buckets, parses their frontmatter, and returns the in-memory Store. A missing specs/ directory yields an empty store (soft, mirroring lint's missing-dir behaviour). A present-but-malformed spec file is a hard, loud error.

func (Store) ByIntent

func (s Store) ByIntent(intentID string) (Spec, bool)

ByIntent returns the FIRST spec linked to the given intent id; ok is false when no spec realises that intent. An intent owns one or more specs (adr-2609151513118583), so a caller that must reason about the whole set — "does this intent still have an open spec?" — asks SpecsForIntent or OpenSpecsForIntent instead. This one answers only "does any spec claim it", which is what the one-sided-link reports need.

The match is canonical (recordid.SameID) for the reason SpecsForIntent states.

func (Store) Lookup

func (s Store) Lookup(specID string) (Spec, bool)

Lookup returns the spec the given reference names; ok is false when absent.

Matching is on the spec NUMBER, not the literal string, because that is the comparison record-lint makes: a spec_id is written bare (spc-9), with its slug (spc-9-widget), and zero-padded (spc-009) across the record, and all three are lint-green. A literal-only compare would let the lifecycle verbs refuse a record the lint accepts. An exact string match still wins when the store holds one, so a caller that names a record precisely gets that record; two specs sharing a number is a record defect the lint's spec_id_unique rule flags (the mint never produces one: it redraws while a candidate names a spec here, and this lookup is the presence check it redraws against).

func (Store) OpenSpecsForIntent added in v0.9.0

func (s Store) OpenSpecsForIntent(intentID string) []Spec

OpenSpecsForIntent is SpecsForIntent narrowed to the open bucket — the question the lifecycle actually asks at a close: does this intent have an open spec left?

func (Store) SpecsForIntent added in v0.9.0

func (s Store) SpecsForIntent(intentID string) []Spec

SpecsForIntent returns every spec whose back-link names intentID, ordered by spec number — which for a native timestamp-numeric id is the order they were minted in, so an intent's specs read as the delivery sequence they are.

The spec's `intent:` field is the source of truth for the link: an intent carries a scalar spec_id naming the spec it was planned with, and nothing on the intent side can carry a SET, so the set of specs realising an intent is derived here from the back-links and nowhere else. Every reader that has to answer "is this intent still being delivered" reads this, so the question has one answer (adr-2609151513118583, invariant 17).

The match is CANONICAL (recordid.SameID), never literal, and that is what makes the one answer one: record-lint resolves an intent handle on its number with its leading zeros trimmed, so a back-link written `itd-007` is green and names itd-7. Comparing the strings here instead made the store and the lint answer the same question differently — the lint saw two specs on one intent while the store saw one, so the intent shipped with the second spec still open, and that spec could then never be closed, because the verb resolving its back-link found no intent of that spelling. One primitive, one answer.

A bundle's shared spec realises every member it lists, so a member other than the first is found through the spec's `intents:` list (Spec.Names).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL