| 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 |
|
} |