coverage~bigbes/sr-ht-spec64cae3afcore/frontmatter.go

Coverage
97.9% 93/95 statements
Δ
+0.0
Blob
cb32eeb
Uncovered L134-L136L180-L181
1 package core
2
3 import (
4 "fmt"
5 "strings"
6
7 "gopkg.in/yaml.v3"
8 )
9
10 // Status is the authored lifecycle marker of a document.
11 //
12 // The enum is exactly draft/review/superseded. "approved" is deliberately not a
13 // status: a document is approved when it is reachable from the approved ref,
14 // and that is the whole definition. Had "approved" stayed here, either the
15 // approved branch would permanently carry `status: draft`, or the merge would
16 // have to rewrite frontmatter nobody authored — which would also invalidate the
17 // agent's If-Match base on its next read.
18 type Status string
19
20 const (
21 StatusDraft Status = "draft"
22 StatusReview Status = "review"
23 StatusSuperseded Status = "superseded"
24 )
25
26 // DefaultStatuses returns the full status enum, in the order a space's
27 // `.spec.yml` would list it. A fresh slice each call, so a caller cannot mutate
28 // the default out from under everyone else.
29 58 func DefaultStatuses() []Status {
30 58 return []Status{StatusDraft, StatusReview, StatusSuperseded}
31 58 }
32
33 // ParseStatus validates a status string against the enum.
34 52 func ParseStatus(s string) (Status, error) {
35 52 switch Status(s) {
36 38 case StatusDraft, StatusReview, StatusSuperseded:
37 38 return Status(s), nil
38 }
39 14 return "", fmt.Errorf("%w: %q is not one of %s", ErrInvalidStatus, s, joinStatuses(DefaultStatuses()))
40 }
41
42 21 func joinStatuses(ss []Status) string {
43 21 parts := make([]string, len(ss))
44 61 for i, s := range ss {
45 61 parts[i] = string(s)
46 61 }
47 21 return strings.Join(parts, "|")
48 }
49
50 // Frontmatter is the YAML header of a document. The modelled fields are the
51 // ones the service reasons about; anything else an author writes is preserved
52 // in git and reported by Present, but is not interpreted here.
53 type Frontmatter struct {
54 ID string `yaml:"id"`
55 Title string `yaml:"title"`
56 Status Status `yaml:"status"`
57 Supersedes string `yaml:"supersedes"`
58 Owners []string `yaml:"owners"`
59 Tags []string `yaml:"tags"`
60 Type string `yaml:"type"`
61 Summary string `yaml:"summary"`
62
63 // Present is the set of top-level keys that actually appeared in the
64 // source. Required-key validation asks about presence, not emptiness:
65 // `title:` with no value is present and fails for being blank, whereas an
66 // absent `title:` is a different error with a different fix. A zero-valued
67 // struct field cannot tell those apart.
68 Present map[string]bool `yaml:"-"`
69 }
70
71 // Has reports whether key appeared in the parsed source. Nil-safe, so a
72 // hand-constructed Frontmatter reports every key as absent rather than panicking.
73 105 func (f Frontmatter) Has(key string) bool { return f.Present[key] }
74
75 const (
76 frontDelim = "---"
77 // docEndDelim is YAML's explicit end-of-document marker. Editors emit it
78 // occasionally, and treating it as a closing fence costs one comparison.
79 docEndDelim = "..."
80 )
81
82 // SplitFrontmatter separates the YAML frontmatter block from the markdown body.
83 // The document must open with a "---" line and close the block with a "---"
84 // (or "...") line; both delimiters are excluded from the returned slices.
85 //
86 // It is strict on purpose. A document whose frontmatter is unterminated would
87 // otherwise parse as a body-only document with no ID, silently dropping out of
88 // the registry and the index instead of failing at the door.
89 18 func SplitFrontmatter(src []byte) (front, body []byte, err error) {
90 18 s := string(src)
91 18 if strings.HasPrefix(s, "\ufeff") {
92 1 return nil, nil, fmt.Errorf("%w: document starts with a UTF-8 BOM before the %q delimiter",
93 1 ErrMalformedFrontmatter, frontDelim)
94 1 }
95
96 17 first, rest, hasMore := strings.Cut(s, "\n")
97 17 if strings.TrimSuffix(first, "\r") != frontDelim {
98 7 return nil, nil, fmt.Errorf("%w: document does not start with a %q delimiter",
99 7 ErrMalformedFrontmatter, frontDelim)
100 7 }
101 10 if !hasMore {
102 1 return nil, nil, fmt.Errorf("%w: frontmatter block is not terminated by %q",
103 1 ErrMalformedFrontmatter, frontDelim)
104 1 }
105
106 20 for pos := 0; pos <= len(rest); {
107 20 line, tail, ok := strings.Cut(rest[pos:], "\n")
108 20 switch strings.TrimSuffix(line, "\r") {
109 8 case frontDelim, docEndDelim:
110 8 return []byte(rest[:pos]), []byte(tail), nil
111 }
112 12 if !ok {
113 1 break
114 }
115 11 pos = len(rest) - len(tail)
116 }
117 1 return nil, nil, fmt.Errorf("%w: frontmatter block is not terminated by %q",
118 1 ErrMalformedFrontmatter, frontDelim)
119 }
120
121 // ParseFrontmatter decodes a frontmatter block into a Frontmatter, recording
122 // which keys were present.
123 //
124 // Duplicate keys are rejected explicitly rather than last-one-wins: a document
125 // carrying two `id:` lines is exactly the typo that corrupts the global ID
126 // registry, and it is far cheaper to reject at the door than to find weeks later.
127 34 func ParseFrontmatter(front []byte) (Frontmatter, error) {
128 34 var node yaml.Node
129 34 if err := yaml.Unmarshal(front, &node); err != nil {
130 2 return Frontmatter{}, fmt.Errorf("%w: %v", ErrMalformedFrontmatter, err)
131 2 }
132 32 if node.Kind == yaml.DocumentNode {
133 28 if len(node.Content) != 1 {
134 0 return Frontmatter{}, fmt.Errorf("%w: expected exactly one YAML document, got %d",
135 0 ErrMalformedFrontmatter, len(node.Content))
136 0 }
137 28 node = *node.Content[0]
138 }
139
140 // An empty block is well-formed YAML and yields a zero node. It is not a
141 // parse failure; it fails schema validation instead, which names the
142 // missing keys and is the more useful error.
143 32 if node.Kind == 0 {
144 4 return Frontmatter{Present: map[string]bool{}}, nil
145 4 }
146 28 if node.Kind != yaml.MappingNode {
147 2 return Frontmatter{}, fmt.Errorf("%w: frontmatter must be a YAML mapping", ErrMalformedFrontmatter)
148 2 }
149
150 26 present := make(map[string]bool, len(node.Content)/2)
151 77 for i := 0; i+1 < len(node.Content); i += 2 {
152 77 key := node.Content[i]
153 77 if key.Kind != yaml.ScalarNode {
154 1 return Frontmatter{}, fmt.Errorf("%w: non-scalar key at line %d", ErrMalformedFrontmatter, key.Line)
155 1 }
156 76 if present[key.Value] {
157 2 return Frontmatter{}, fmt.Errorf("%w: duplicate key %q at line %d",
158 2 ErrMalformedFrontmatter, key.Value, key.Line)
159 2 }
160 74 present[key.Value] = true
161 }
162
163 23 var fm Frontmatter
164 23 if err := node.Decode(&fm); err != nil {
165 2 return Frontmatter{}, fmt.Errorf("%w: %v", ErrMalformedFrontmatter, err)
166 2 }
167 21 fm.Present = present
168 21 return fm, nil
169 }
170
171 // ParseDocument splits and parses a whole document, returning its frontmatter
172 // and its markdown body.
173 2 func ParseDocument(src []byte) (Frontmatter, []byte, error) {
174 2 front, body, err := SplitFrontmatter(src)
175 2 if err != nil {
176 1 return Frontmatter{}, nil, err
177 1 }
178 1 fm, err := ParseFrontmatter(front)
179 1 if err != nil {
180 0 return Frontmatter{}, nil, err
181 0 }
182 1 return fm, body, nil
183 }
184
185 // Schema is a space's frontmatter contract, as carried by `.spec.yml`. It is
186 // enforced at propose time and at push time — schema validation at the door is
187 // the cheapest available defence against agent slop.
188 type Schema struct {
189 Required []string `yaml:"required"`
190 Status []Status `yaml:"status"`
191 }
192
193 // DefaultSchema is the contract a space gets when its `.spec.yml` says nothing.
194 35 func DefaultSchema() Schema {
195 35 return Schema{
196 35 Required: []string{"id", "title", "status"},
197 35 Status: DefaultStatuses(),
198 35 }
199 35 }
200
201 // Validate reports whether the schema itself is usable. The status list is the
202 // interesting half: a space may narrow the enum (say, forbid `review`) but may
203 // not invent a member, which is what keeps "approved" from creeping back in via
204 // a per-space config file.
205 22 func (s Schema) Validate() error {
206 22 seenKey := make(map[string]bool, len(s.Required))
207 42 for _, key := range s.Required {
208 42 if key == "" {
209 1 return fmt.Errorf("%w: schema.required contains an empty key", ErrInvalidPolicy)
210 1 }
211 41 if seenKey[key] {
212 2 return fmt.Errorf("%w: schema.required lists %q twice", ErrInvalidPolicy, key)
213 2 }
214 39 seenKey[key] = true
215 }
216
217 19 if len(s.Status) == 0 {
218 2 return fmt.Errorf("%w: schema.status must list at least one status", ErrInvalidPolicy)
219 2 }
220 17 seenStatus := make(map[Status]bool, len(s.Status))
221 40 for _, st := range s.Status {
222 40 if _, err := ParseStatus(string(st)); err != nil {
223 5 return fmt.Errorf("%w: schema.status: %v", ErrInvalidPolicy, err)
224 5 }
225 35 if seenStatus[st] {
226 1 return fmt.Errorf("%w: schema.status lists %q twice", ErrInvalidPolicy, st)
227 1 }
228 34 seenStatus[st] = true
229 }
230 11 return nil
231 }
232
233 // AllowsStatus reports whether st is permitted by this schema.
234 15 func (s Schema) AllowsStatus(st Status) bool {
235 25 for _, allowed := range s.Status {
236 25 if st == allowed {
237 11 return true
238 11 }
239 }
240 4 return false
241 }
242
243 // ValidateFrontmatter checks a parsed document header against the schema. It
244 // answers "may this document be proposed into this space", and every write path
245 // (REST, MCP, and the update hook on your own pushes) runs it.
246 18 func (s Schema) ValidateFrontmatter(fm Frontmatter) error {
247 47 for _, key := range s.Required {
248 47 if !fm.Has(key) {
249 4 return fmt.Errorf("%w: %q", ErrMissingField, key)
250 4 }
251 }
252 14 if fm.Has("id") {
253 14 if err := ValidateDocID(fm.ID); err != nil {
254 2 return err
255 2 }
256 }
257 12 if fm.Has("title") && strings.TrimSpace(fm.Title) == "" {
258 1 return fmt.Errorf("%w: %q is present but blank", ErrMissingField, "title")
259 1 }
260 11 if fm.Has("status") && !s.AllowsStatus(fm.Status) {
261 2 return fmt.Errorf("%w: %q is not one of %s", ErrInvalidStatus, fm.Status, joinStatuses(s.Status))
262 2 }
263 // `supersedes:` with no value is a half-finished edit, not an absent key,
264 // so it is rejected rather than ignored.
265 9 if fm.Has("supersedes") {
266 2 if err := ValidateDocID(fm.Supersedes); err != nil {
267 2 return fmt.Errorf("supersedes: %w", err)
268 2 }
269 }
270 7 for _, owner := range fm.Owners {
271 4 if err := ValidateOwner(strings.TrimPrefix(owner, "~")); err != nil {
272 2 return fmt.Errorf("owners: %w", err)
273 2 }
274 }
275 5 return nil
276 }