coverage~bigbes/sr-ht-spec64cae3afcore/comment.go

Coverage
97.5% 39/40 statements
Δ
+0.0
Blob
9f06738
Uncovered L163-L164
1 package core
2
3 import "fmt"
4
5 // CommentSide is which revision of a changed block a comment hangs off.
6 //
7 // A modified block exists twice — once as it was approved, once as proposed —
8 // and the two have different content, so "this sentence is wrong" has to say
9 // which sentence. Comments go on the new side, because that is the text under
10 // review; the old side exists for a block the proposal deletes outright, where
11 // there is no new text to point at.
12 type CommentSide string
13
14 const (
15 // SideNew anchors to the proposed revision of a block.
16 SideNew CommentSide = "new"
17 // SideOld anchors to the approved revision, for a deleted block.
18 SideOld CommentSide = "old"
19 )
20
21 // ParseCommentSide validates a side read back from Postgres or an API request.
22 3 func ParseCommentSide(s string) (CommentSide, error) {
23 3 switch CommentSide(s) {
24 2 case SideNew, SideOld:
25 2 return CommentSide(s), nil
26 }
27 1 return "", fmt.Errorf("%w: %q is not one of new|old", ErrInvalidCommentSide, s)
28 }
29
30 // CommentAnchor is where a comment is attached: one block of one document,
31 // named by what it says rather than by where it sits.
32 //
33 // Line numbers are useless here. Markdown reflows, so a one-word edit rewraps a
34 // paragraph and every line number below it moves; that is the same property
35 // that made a line-oriented differ unusable for prose and it makes a
36 // line-oriented comment anchor unusable for the same reason.
37 //
38 // The tuple is the one prosediff already emits for every block, which is why
39 // the anchoring model could be validated before it was committed to a schema.
40 type CommentAnchor struct {
41 // DocID is the archive's addressing key for the document — its frontmatter
42 // id when it has a unique well-formed one, otherwise its path. Anchoring on
43 // the id rather than the path is what makes a comment survive a rename.
44 DocID string
45 // HeadingPath is the enclosing headings, outermost first.
46 //
47 // It is deliberately not part of BlockHash: renaming a section would
48 // otherwise change the hash of every block beneath it and orphan every
49 // comment in that section at once, which is the failure mode most likely to
50 // happen on a real editing pass.
51 HeadingPath []string
52 // Index is the block's position among the blocks sharing its HeadingPath,
53 // 0-based — not its position in the document.
54 //
55 // prosediff numbers blocks document-globally, and that number is what a
56 // caller has in hand; it is converted here because a document-global index
57 // is destroyed by any insertion above it. Since the fallback exists
58 // precisely for the case where the block's content changed, an index that
59 // every unrelated edit invalidates would leave the fallback unable to fire
60 // exactly when it is needed.
61 Index int
62 // BlockHash is prosediff's structure-plus-normalized-content hash of the
63 // block as it read when the comment was written.
64 BlockHash string
65 // Side is which revision of the block was commented on.
66 Side CommentSide
67 }
68
69 // AnchorBlock is the part of a segmented block that anchoring reads.
70 //
71 // It exists so that core does not import prosediff. Dependency direction is
72 // strictly downward and the comment anchoring rules are domain logic, not diff
73 // logic — a caller passes prosediff's blocks through this shape.
74 type AnchorBlock struct {
75 HeadingPath []string
76 // Index is the block's position among blocks sharing HeadingPath, 0-based.
77 // Build a slice of these with [AnchorBlocks] rather than filling it by hand.
78 Index int
79 Hash string
80 }
81
82 // AnchorState is how well a comment's anchor still describes the revision being
83 // looked at. It is derived, never stored: a comment is not outdated in general,
84 // it is outdated *at a revision*, and a proposal branch moves under it as the
85 // agent revises.
86 type AnchorState string
87
88 const (
89 // AnchorExact means the commented block is still present verbatim.
90 AnchorExact AnchorState = "anchored"
91 // AnchorEdited means the block at the anchor's position is still there but
92 // its text has changed since the comment was written. The comment is shown
93 // against it, marked, so the reader can see the critique may no longer fit.
94 AnchorEdited AnchorState = "edited"
95 // AnchorOutdated means neither the content nor the position matched.
96 //
97 // The comment is kept and reported as outdated rather than relocated to a
98 // best guess. A comment moved to the wrong paragraph is worse than one
99 // admitting it lost its place: the reader cannot tell it is wrong.
100 AnchorOutdated AnchorState = "outdated"
101 )
102
103 // AnchorBlocks converts a revision's blocks, in document order, into the shape
104 // [ResolveAnchor] reads — numbering each block within its own heading path.
105 //
106 // hashes and paths are parallel slices in document order, which is what a
107 // caller holds after segmenting: pass block.Hash and block.HeadingPath.
108 9 func AnchorBlocks(hashes []string, paths [][]string) []AnchorBlock {
109 9 out := make([]AnchorBlock, len(hashes))
110 9 seen := make(map[string]int, len(hashes))
111 31 for i, h := range hashes {
112 31 var p []string
113 31 if i < len(paths) {
114 31 p = paths[i]
115 31 }
116 31 key := headingKey(p)
117 31 out[i] = AnchorBlock{HeadingPath: p, Index: seen[key], Hash: h}
118 31 seen[key]++
119 }
120 9 return out
121 }
122
123 // ResolveAnchor locates a comment's block in a revision, returning the index
124 // into blocks and how confident that answer is. The index is -1 when the anchor
125 // did not resolve.
126 //
127 // The order is content first, position second, give up third:
128 //
129 // 1. an identical block hash means the commented text is still there, wherever
130 // it now sits — content is the strongest evidence and survives reflow,
131 // renumbering and section renames;
132 // 2. failing that, the block at the same position under the same headings is
133 // taken to be the same block, edited;
134 // 3. failing both, the anchor is outdated.
135 //
136 // Step 1 can match more than once — a document may repeat a paragraph, and
137 // "TBD" appears verbatim in a dozen places — so the heading path breaks the tie
138 // and the nearest index breaks what the heading path does not. Without that,
139 // which duplicate a comment landed on would depend on document order.
140 10 func ResolveAnchor(a CommentAnchor, blocks []AnchorBlock) (int, AnchorState) {
141 10 if best := bestHashMatch(a, blocks); best >= 0 {
142 7 return best, AnchorExact
143 7 }
144 9 for i, b := range blocks {
145 9 if b.Index == a.Index && headingKey(b.HeadingPath) == headingKey(a.HeadingPath) {
146 2 return i, AnchorEdited
147 2 }
148 }
149 1 return -1, AnchorOutdated
150 }
151
152 // bestHashMatch returns the index of the block whose hash equals the anchor's,
153 // preferring one under the same headings and then the closest index. It returns
154 // -1 when no block has that hash.
155 //
156 // sameSectionBonus dominates any positional term, so a duplicate under the
157 // comment's own headings always beats a nearer one somewhere else: a comment on
158 // "TBD" under "Storage" belongs to Storage's TBD even when another section's
159 // sits at a closer index.
160 10 func bestHashMatch(a CommentAnchor, blocks []AnchorBlock) int {
161 10 const sameSectionBonus = 1 << 20
162 10 if a.BlockHash == "" {
163 0 return -1
164 0 }
165 10 want := headingKey(a.HeadingPath)
166 10 best, bestScore := -1, -1
167 37 for i, b := range blocks {
168 37 if b.Hash != a.BlockHash {
169 26 continue
170 }
171 11 score := -abs(b.Index - a.Index) // nearer is better
172 11 if headingKey(b.HeadingPath) == want {
173 5 score += sameSectionBonus
174 5 }
175 11 if score > bestScore {
176 7 best, bestScore = i, score
177 7 }
178 }
179 10 return best
180 }
181
182 // headingKey flattens a heading path to a comparable string. "\x00" is the
183 // separator because it cannot occur in a heading, so no two distinct paths
184 // collide the way " › " would for a heading containing that sequence.
185 58 func headingKey(path []string) string {
186 58 key := ""
187 58 for _, h := range path {
188 58 key += h + "\x00"
189 58 }
190 58 return key
191 }
192
193 11 func abs(n int) int {
194 11 if n < 0 {
195 2 return -n
196 2 }
197 9 return n
198 }