snapshot of c000b74a5b09c433f96a69fa102c5c8f6583dff0 Annotations about the code that implements koment.

internal/store/record.go

1 // Package store reads and writes the annotation records that live in .koment.
2 package store
3
4 import (
5 "fmt"
6 "strings"
7
8 "github.com/koment-dev/koment/internal/api"
9 )
10
11 // APIVersion is matched exactly. A record carrying anything else is refused
12 // rather than guessed at.
13 const APIVersion = api.Version
14
15 // KindAnnotation is the resource kind of an annotation record.
16 const KindAnnotation = "Annotation"
17
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
23
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
27
28 const SchemaURL = api.SchemaBase + "annotation.schema.json"
29
30 // Type is the category of rationale a record carries.
31 type Type string
32
33 const (
34 TypeWhy Type = "why"
35 TypeGotcha Type = "gotcha"
36 TypeInvariant Type = "invariant"
37 TypeAntiPattern Type = "anti-pattern"
38 )
39
40 var Types = []Type{TypeWhy, TypeGotcha, TypeInvariant, TypeAntiPattern}
41
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 }
50
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 }
58
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
62
63 const (
64 AnchorOK AnchorStatus = "ok"
65 AnchorAmbiguous AnchorStatus = "ambiguous"
66 AnchorDrifted AnchorStatus = "drifted"
67 AnchorOrphaned AnchorStatus = "orphaned"
68 )
69
70 var AnchorStatuses = []AnchorStatus{AnchorOK, AnchorAmbiguous, AnchorDrifted, AnchorOrphaned}
71
72 func (s AnchorStatus) IsFailure() bool {
73 return s == AnchorAmbiguous || s == AnchorDrifted || s == AnchorOrphaned
74 }
75
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 }
84
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 }
93
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 }
101
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 }
108
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 }
120
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 }
131
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 }
143
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 }
168
169 type Policy struct {
170 Exception string `yaml:"exception"`
171 Acknowledged bool `yaml:"acknowledged"`
172 }
173
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 }
183
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 }
194
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 }
207
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 }
218
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 }
237
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 }
258
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 }
268
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 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close