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