~bigbes/sr-ht-spec

ref: 6e59bf0ffc7d16f509dad40063d35bf8f6fb98a7 sr-ht-spec/core/frontmatter.go -rw-r--r-- 9.3 KiB
6e59bf0f — Eugene Blikh api: advertise an empty scope list, not null 8 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
package core

import (
	"fmt"
	"strings"

	"gopkg.in/yaml.v3"
)

// Status is the authored lifecycle marker of a document.
//
// The enum is exactly draft/review/superseded. "approved" is deliberately not a
// status: a document is approved when it is reachable from the approved ref,
// and that is the whole definition. Had "approved" stayed here, either the
// approved branch would permanently carry `status: draft`, or the merge would
// have to rewrite frontmatter nobody authored — which would also invalidate the
// agent's If-Match base on its next read.
type Status string

const (
	StatusDraft      Status = "draft"
	StatusReview     Status = "review"
	StatusSuperseded Status = "superseded"
)

// DefaultStatuses returns the full status enum, in the order a space's
// `.spec.yml` would list it. A fresh slice each call, so a caller cannot mutate
// the default out from under everyone else.
func DefaultStatuses() []Status {
	return []Status{StatusDraft, StatusReview, StatusSuperseded}
}

// ParseStatus validates a status string against the enum.
func ParseStatus(s string) (Status, error) {
	switch Status(s) {
	case StatusDraft, StatusReview, StatusSuperseded:
		return Status(s), nil
	}
	return "", fmt.Errorf("%w: %q is not one of %s", ErrInvalidStatus, s, joinStatuses(DefaultStatuses()))
}

func joinStatuses(ss []Status) string {
	parts := make([]string, len(ss))
	for i, s := range ss {
		parts[i] = string(s)
	}
	return strings.Join(parts, "|")
}

// Frontmatter is the YAML header of a document. The modelled fields are the
// ones the service reasons about; anything else an author writes is preserved
// in git and reported by Present, but is not interpreted here.
type Frontmatter struct {
	ID         string   `yaml:"id"`
	Title      string   `yaml:"title"`
	Status     Status   `yaml:"status"`
	Supersedes string   `yaml:"supersedes"`
	Owners     []string `yaml:"owners"`
	Tags       []string `yaml:"tags"`
	Type       string   `yaml:"type"`
	Summary    string   `yaml:"summary"`

	// Present is the set of top-level keys that actually appeared in the
	// source. Required-key validation asks about presence, not emptiness:
	// `title:` with no value is present and fails for being blank, whereas an
	// absent `title:` is a different error with a different fix. A zero-valued
	// struct field cannot tell those apart.
	Present map[string]bool `yaml:"-"`
}

// Has reports whether key appeared in the parsed source. Nil-safe, so a
// hand-constructed Frontmatter reports every key as absent rather than panicking.
func (f Frontmatter) Has(key string) bool { return f.Present[key] }

const (
	frontDelim = "---"
	// docEndDelim is YAML's explicit end-of-document marker. Editors emit it
	// occasionally, and treating it as a closing fence costs one comparison.
	docEndDelim = "..."
)

// SplitFrontmatter separates the YAML frontmatter block from the markdown body.
// The document must open with a "---" line and close the block with a "---"
// (or "...") line; both delimiters are excluded from the returned slices.
//
// It is strict on purpose. A document whose frontmatter is unterminated would
// otherwise parse as a body-only document with no ID, silently dropping out of
// the registry and the index instead of failing at the door.
func SplitFrontmatter(src []byte) (front, body []byte, err error) {
	s := string(src)
	if strings.HasPrefix(s, "\ufeff") {
		return nil, nil, fmt.Errorf("%w: document starts with a UTF-8 BOM before the %q delimiter",
			ErrMalformedFrontmatter, frontDelim)
	}

	first, rest, hasMore := strings.Cut(s, "\n")
	if strings.TrimSuffix(first, "\r") != frontDelim {
		return nil, nil, fmt.Errorf("%w: document does not start with a %q delimiter",
			ErrMalformedFrontmatter, frontDelim)
	}
	if !hasMore {
		return nil, nil, fmt.Errorf("%w: frontmatter block is not terminated by %q",
			ErrMalformedFrontmatter, frontDelim)
	}

	for pos := 0; pos <= len(rest); {
		line, tail, ok := strings.Cut(rest[pos:], "\n")
		switch strings.TrimSuffix(line, "\r") {
		case frontDelim, docEndDelim:
			return []byte(rest[:pos]), []byte(tail), nil
		}
		if !ok {
			break
		}
		pos = len(rest) - len(tail)
	}
	return nil, nil, fmt.Errorf("%w: frontmatter block is not terminated by %q",
		ErrMalformedFrontmatter, frontDelim)
}

// ParseFrontmatter decodes a frontmatter block into a Frontmatter, recording
// which keys were present.
//
// Duplicate keys are rejected explicitly rather than last-one-wins: a document
// carrying two `id:` lines is exactly the typo that corrupts the global ID
// registry, and it is far cheaper to reject at the door than to find weeks later.
func ParseFrontmatter(front []byte) (Frontmatter, error) {
	var node yaml.Node
	if err := yaml.Unmarshal(front, &node); err != nil {
		return Frontmatter{}, fmt.Errorf("%w: %v", ErrMalformedFrontmatter, err)
	}
	if node.Kind == yaml.DocumentNode {
		if len(node.Content) != 1 {
			return Frontmatter{}, fmt.Errorf("%w: expected exactly one YAML document, got %d",
				ErrMalformedFrontmatter, len(node.Content))
		}
		node = *node.Content[0]
	}

	// An empty block is well-formed YAML and yields a zero node. It is not a
	// parse failure; it fails schema validation instead, which names the
	// missing keys and is the more useful error.
	if node.Kind == 0 {
		return Frontmatter{Present: map[string]bool{}}, nil
	}
	if node.Kind != yaml.MappingNode {
		return Frontmatter{}, fmt.Errorf("%w: frontmatter must be a YAML mapping", ErrMalformedFrontmatter)
	}

	present := make(map[string]bool, len(node.Content)/2)
	for i := 0; i+1 < len(node.Content); i += 2 {
		key := node.Content[i]
		if key.Kind != yaml.ScalarNode {
			return Frontmatter{}, fmt.Errorf("%w: non-scalar key at line %d", ErrMalformedFrontmatter, key.Line)
		}
		if present[key.Value] {
			return Frontmatter{}, fmt.Errorf("%w: duplicate key %q at line %d",
				ErrMalformedFrontmatter, key.Value, key.Line)
		}
		present[key.Value] = true
	}

	var fm Frontmatter
	if err := node.Decode(&fm); err != nil {
		return Frontmatter{}, fmt.Errorf("%w: %v", ErrMalformedFrontmatter, err)
	}
	fm.Present = present
	return fm, nil
}

// ParseDocument splits and parses a whole document, returning its frontmatter
// and its markdown body.
func ParseDocument(src []byte) (Frontmatter, []byte, error) {
	front, body, err := SplitFrontmatter(src)
	if err != nil {
		return Frontmatter{}, nil, err
	}
	fm, err := ParseFrontmatter(front)
	if err != nil {
		return Frontmatter{}, nil, err
	}
	return fm, body, nil
}

// Schema is a space's frontmatter contract, as carried by `.spec.yml`. It is
// enforced at propose time and at push time — schema validation at the door is
// the cheapest available defence against agent slop.
type Schema struct {
	Required []string `yaml:"required"`
	Status   []Status `yaml:"status"`
}

// DefaultSchema is the contract a space gets when its `.spec.yml` says nothing.
func DefaultSchema() Schema {
	return Schema{
		Required: []string{"id", "title", "status"},
		Status:   DefaultStatuses(),
	}
}

// Validate reports whether the schema itself is usable. The status list is the
// interesting half: a space may narrow the enum (say, forbid `review`) but may
// not invent a member, which is what keeps "approved" from creeping back in via
// a per-space config file.
func (s Schema) Validate() error {
	seenKey := make(map[string]bool, len(s.Required))
	for _, key := range s.Required {
		if key == "" {
			return fmt.Errorf("%w: schema.required contains an empty key", ErrInvalidPolicy)
		}
		if seenKey[key] {
			return fmt.Errorf("%w: schema.required lists %q twice", ErrInvalidPolicy, key)
		}
		seenKey[key] = true
	}

	if len(s.Status) == 0 {
		return fmt.Errorf("%w: schema.status must list at least one status", ErrInvalidPolicy)
	}
	seenStatus := make(map[Status]bool, len(s.Status))
	for _, st := range s.Status {
		if _, err := ParseStatus(string(st)); err != nil {
			return fmt.Errorf("%w: schema.status: %v", ErrInvalidPolicy, err)
		}
		if seenStatus[st] {
			return fmt.Errorf("%w: schema.status lists %q twice", ErrInvalidPolicy, st)
		}
		seenStatus[st] = true
	}
	return nil
}

// AllowsStatus reports whether st is permitted by this schema.
func (s Schema) AllowsStatus(st Status) bool {
	for _, allowed := range s.Status {
		if st == allowed {
			return true
		}
	}
	return false
}

// ValidateFrontmatter checks a parsed document header against the schema. It
// answers "may this document be proposed into this space", and every write path
// (REST, MCP, and the update hook on your own pushes) runs it.
func (s Schema) ValidateFrontmatter(fm Frontmatter) error {
	for _, key := range s.Required {
		if !fm.Has(key) {
			return fmt.Errorf("%w: %q", ErrMissingField, key)
		}
	}
	if fm.Has("id") {
		if err := ValidateDocID(fm.ID); err != nil {
			return err
		}
	}
	if fm.Has("title") && strings.TrimSpace(fm.Title) == "" {
		return fmt.Errorf("%w: %q is present but blank", ErrMissingField, "title")
	}
	if fm.Has("status") && !s.AllowsStatus(fm.Status) {
		return fmt.Errorf("%w: %q is not one of %s", ErrInvalidStatus, fm.Status, joinStatuses(s.Status))
	}
	// `supersedes:` with no value is a half-finished edit, not an absent key,
	// so it is rejected rather than ignored.
	if fm.Has("supersedes") {
		if err := ValidateDocID(fm.Supersedes); err != nil {
			return fmt.Errorf("supersedes: %w", err)
		}
	}
	for _, owner := range fm.Owners {
		if err := ValidateOwner(strings.TrimPrefix(owner, "~")); err != nil {
			return fmt.Errorf("owners: %w", err)
		}
	}
	return nil
}