internal/store/record.go
1
// Package store reads and writes the annotation records that live in .koment.
2
package store
4
import (
5
"fmt"
6
"strings"
8
"github.com/koment-dev/koment/internal/api"
9
)
11
// APIVersion is matched exactly. A record carrying anything else is refused
12
// rather than guessed at.
13
const APIVersion = api.Version
15
// KindAnnotation is the resource kind of an annotation record.
16
const KindAnnotation = "Annotation"
18
// TitleLimit keeps a title short enough to render beside code without being
19
// shortened, which is the only reason it exists (ADR 0115).
20
const TitleLimit = 72
22
const SchemaURL = api.SchemaBase + "annotation.schema.json"
24
// Type is the category of rationale a record carries.
25
type Type string
27
const (
28
TypeWhy Type = "why"
29
TypeGotcha Type = "gotcha"
30
TypeInvariant Type = "invariant"
31
TypeAntiPattern Type = "anti-pattern"
32
)
34
var Types = []Type{TypeWhy, TypeGotcha, TypeInvariant, TypeAntiPattern}
36
func ParseType(text string) (Type, error) {
37
for _, candidate := range Types {
38
if Type(text) == candidate {
39
return candidate, nil
40
}
41
}
42
return "", fmt.Errorf("unknown type %q, want one of %s", text, joinTypes())
43
}
45
func joinTypes() string {
46
names := make([]string, len(Types))
47
for index, annotationType := range Types {
48
names[index] = string(annotationType)
49
}
50
return strings.Join(names, ", ")
51
}
53
// AnchorStatus is the verdict of resolving an anchor against a file. It is
54
// computed by reading the file, never by trusting a stored value.
55
type AnchorStatus string
57
const (
58
AnchorOK AnchorStatus = "ok"
59
AnchorAmbiguous AnchorStatus = "ambiguous"
60
AnchorDrifted AnchorStatus = "drifted"
61
AnchorOrphaned AnchorStatus = "orphaned"
62
)
64
var AnchorStatuses = []AnchorStatus{AnchorOK, AnchorAmbiguous, AnchorDrifted, AnchorOrphaned}
66
func (s AnchorStatus) IsFailure() bool {
67
return s == AnchorAmbiguous || s == AnchorDrifted || s == AnchorOrphaned
68
}
70
func ParseAnchorStatus(text string) (AnchorStatus, error) {
71
for _, candidate := range AnchorStatuses {
72
if AnchorStatus(text) == candidate {
73
return candidate, nil
74
}
75
}
76
return "", fmt.Errorf("unknown resolution %q", text)
77
}
79
// Annotation is one rationale record on disk.
80
type Annotation struct {
81
APIVersion string `yaml:"apiVersion"`
82
Kind string `yaml:"kind"`
83
Metadata Metadata `yaml:"metadata"`
84
Spec Spec `yaml:"spec"`
85
Status Status `yaml:"status,omitempty"`
86
}
88
// Metadata identifies the record. Kubernetes names this field name; a ULID is
89
// not a DNS-1123 name, so koment diverges deliberately and calls it id. Do not
90
// align it without a migration.
91
type Metadata struct {
92
ID string `yaml:"id"`
93
Created Timestamp `yaml:"created"`
94
}
96
// Target is what the annotation is about. It is a mapping rather than a bare
97
// path so that a function or a member can join the file without reshaping the
98
// record again.
99
type Target struct {
100
File string `yaml:"file"`
101
}
103
// Spec is the authored intent: everything a person or agent decided.
104
type Spec struct {
105
Target Target `yaml:"target"`
106
Type Type `yaml:"type"`
107
Title string `yaml:"title,omitempty"`
108
Body string `yaml:"body"`
109
Anchor Anchor `yaml:"anchor"`
110
Author Author `yaml:"author"`
111
Git *GitContext `yaml:"git,omitempty"`
112
Policy *Policy `yaml:"policy,omitempty"`
113
}
115
// Status is what the last write observed, stamped with the commit it observed
116
// it at. Nothing reads it back as a verdict: a reader resolves the anchor
117
// against the file in front of it, and ResolvedCommit is what lets that reader
118
// see how old the recorded observation is.
119
type Status struct {
120
LastSeenLine int `yaml:"lastSeenLine,omitempty"`
121
Resolution AnchorStatus `yaml:"resolution,omitempty"`
122
ResolvedAt Timestamp `yaml:"resolvedAt,omitempty"`
123
ResolvedCommit string `yaml:"resolvedCommit,omitempty"`
124
}
126
// Observe records a resolution, leaving ResolvedAt alone when the verdict and
127
// the commit are already the ones recorded. ResolvedAt then answers "since
128
// when has this been true" instead of "when did a command last run".
129
func (s *Status) Observe(resolution AnchorStatus, commit string, at Timestamp) {
130
if s.Resolution == resolution && s.ResolvedCommit == commit {
131
return
132
}
133
s.Resolution = resolution
134
s.ResolvedCommit = commit
135
s.ResolvedAt = at
136
}
138
func (s Status) Validate(id string, scope Scope) error {
139
switch scope {
140
case ScopeFile:
141
if s.LastSeenLine != 0 {
142
return fmt.Errorf("annotation %s: a file-scoped record has no line to observe", id)
143
}
144
case ScopeExcerpt:
145
if s.LastSeenLine < 1 {
146
return fmt.Errorf("annotation %s: status.lastSeenLine %d is not a positive line number", id, s.LastSeenLine)
147
}
148
}
149
if s.Resolution != "" {
150
if _, err := ParseAnchorStatus(string(s.Resolution)); err != nil {
151
return fmt.Errorf("annotation %s: %w", id, err)
152
}
153
}
154
if (s.Resolution == "") != s.ResolvedAt.IsZero() {
155
return fmt.Errorf("annotation %s: status.resolution and status.resolvedAt are recorded together or not at all", id)
156
}
157
if s.ResolvedCommit != "" && !fullCommitSHA.MatchString(s.ResolvedCommit) {
158
return fmt.Errorf("annotation %s: status.resolvedCommit %q is not a full SHA", id, s.ResolvedCommit)
159
}
160
return nil
161
}
163
type Policy struct {
164
Exception string `yaml:"exception"`
165
Acknowledged bool `yaml:"acknowledged"`
166
}
168
func (p Policy) Validate(annotation Annotation) error {
169
if p.Exception != "inline-comment" || !p.Acknowledged {
170
return fmt.Errorf("annotation %s: policy must explicitly acknowledge an inline-comment exception", annotation.Metadata.ID)
171
}
172
if annotation.Spec.Type != TypeWhy || annotation.Spec.Anchor.Scope != ScopeExcerpt {
173
return fmt.Errorf("annotation %s: inline-comment policy requires a why annotation with an excerpt anchor", annotation.Metadata.ID)
174
}
175
return nil
176
}
178
// Headline is what a reader sees beside the code. A record written before
179
// titles existed still has to show something, so the first sentence of the body
180
// stands in, shortened at a word boundary. It is never written back: a derived
181
// title in the record would become a second copy of the body that drifts.
182
func (a Annotation) Headline() string {
183
if title := strings.TrimSpace(a.Spec.Title); title != "" {
184
return title
185
}
186
return shorten(firstSentence(a.Spec.Body), TitleLimit)
187
}
189
func firstSentence(body string) string {
190
flattened := strings.Join(strings.Fields(body), " ")
191
for index, character := range flattened {
192
if character != '.' && character != '!' && character != '?' {
193
continue
194
}
195
if index+1 >= len(flattened) || flattened[index+1] == ' ' {
196
return flattened[:index]
197
}
198
}
199
return flattened
200
}
202
func shorten(text string, limit int) string {
203
if len([]rune(text)) <= limit {
204
return text
205
}
206
runes := []rune(text)[:limit]
207
if space := strings.LastIndex(string(runes), " "); space > limit/2 {
208
return strings.TrimRight(string(runes)[:space], " ,;:") + "…"
209
}
210
return strings.TrimRight(string(runes), " ,;:") + "…"
211
}
213
func validTitle(id, title string) error {
214
if title == "" {
215
return nil
216
}
217
if strings.TrimSpace(title) == "" {
218
return fmt.Errorf("annotation %s: blank title", id)
219
}
220
if strings.ContainsAny(title, "\n\r") {
221
return fmt.Errorf("annotation %s: a title is one line", id)
222
}
223
if count := len([]rune(title)); count > TitleLimit {
224
return fmt.Errorf("annotation %s: title is %d characters, the limit is %d so it never needs shortening", id, count, TitleLimit)
225
}
226
return nil
227
}
229
func (a Annotation) Validate() error {
230
if a.APIVersion != APIVersion {
231
return fmt.Errorf("annotation %s has apiVersion %q, want %q", a.Metadata.ID, a.APIVersion, APIVersion)
232
}
233
if a.Kind != KindAnnotation {
234
return fmt.Errorf("annotation %s has kind %q, want %q", a.Metadata.ID, a.Kind, KindAnnotation)
235
}
236
if err := a.Metadata.Validate(); err != nil {
237
return err
238
}
239
if err := a.Spec.Validate(a.Metadata.ID); err != nil {
240
return err
241
}
242
if a.Spec.Policy != nil {
243
if err := a.Spec.Policy.Validate(a); err != nil {
244
return err
245
}
246
}
247
return a.Status.Validate(a.Metadata.ID, a.Spec.Anchor.Scope)
248
}
250
func (m Metadata) Validate() error {
251
if !ValidID(m.ID) {
252
return fmt.Errorf("annotation id %q is not a canonical ULID", m.ID)
253
}
254
if m.Created.IsZero() {
255
return fmt.Errorf("annotation %s: missing metadata.created", m.ID)
256
}
257
return nil
258
}
260
func (s Spec) Validate(id string) error {
261
if _, err := validSourcePath(s.Target.File); err != nil {
262
return fmt.Errorf("annotation %s target.file: %w", id, err)
263
}
264
if _, err := ParseType(string(s.Type)); err != nil {
265
return fmt.Errorf("annotation %s: %w", id, err)
266
}
267
if err := validTitle(id, s.Title); err != nil {
268
return err
269
}
270
if strings.TrimSpace(s.Body) == "" {
271
return fmt.Errorf("annotation %s: empty body", id)
272
}
273
if err := s.Anchor.Validate(id); err != nil {
274
return err
275
}
276
if s.Git != nil {
277
if err := s.Git.Validate(); err != nil {
278
return fmt.Errorf("annotation %s: %w", id, err)
279
}
280
}
281
if err := s.Author.Validate(); err != nil {
282
return fmt.Errorf("annotation %s: %w", id, err)
283
}
284
return nil
285
}