~bigbes/sr-ht-spec

ref: 5bb0bb134d608263da3197df9fb4f1d8a3fe42db sr-ht-spec/core/comment.go -rw-r--r-- 7.6 KiB
5bb0bb13 — Eugene Blikh graph: serve /query on the anonymous router with a bearer credential 2 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
package core

import "fmt"

// CommentSide is which revision of a changed block a comment hangs off.
//
// A modified block exists twice — once as it was approved, once as proposed —
// and the two have different content, so "this sentence is wrong" has to say
// which sentence. Comments go on the new side, because that is the text under
// review; the old side exists for a block the proposal deletes outright, where
// there is no new text to point at.
type CommentSide string

const (
	// SideNew anchors to the proposed revision of a block.
	SideNew CommentSide = "new"
	// SideOld anchors to the approved revision, for a deleted block.
	SideOld CommentSide = "old"
)

// ParseCommentSide validates a side read back from Postgres or an API request.
func ParseCommentSide(s string) (CommentSide, error) {
	switch CommentSide(s) {
	case SideNew, SideOld:
		return CommentSide(s), nil
	}
	return "", fmt.Errorf("%w: %q is not one of new|old", ErrInvalidCommentSide, s)
}

// CommentAnchor is where a comment is attached: one block of one document,
// named by what it says rather than by where it sits.
//
// Line numbers are useless here. Markdown reflows, so a one-word edit rewraps a
// paragraph and every line number below it moves; that is the same property
// that made a line-oriented differ unusable for prose and it makes a
// line-oriented comment anchor unusable for the same reason.
//
// The tuple is the one prosediff already emits for every block, which is why
// the anchoring model could be validated before it was committed to a schema.
type CommentAnchor struct {
	// DocID is the archive's addressing key for the document — its frontmatter
	// id when it has a unique well-formed one, otherwise its path. Anchoring on
	// the id rather than the path is what makes a comment survive a rename.
	DocID string
	// HeadingPath is the enclosing headings, outermost first.
	//
	// It is deliberately not part of BlockHash: renaming a section would
	// otherwise change the hash of every block beneath it and orphan every
	// comment in that section at once, which is the failure mode most likely to
	// happen on a real editing pass.
	HeadingPath []string
	// Index is the block's position among the blocks sharing its HeadingPath,
	// 0-based — not its position in the document.
	//
	// prosediff numbers blocks document-globally, and that number is what a
	// caller has in hand; it is converted here because a document-global index
	// is destroyed by any insertion above it. Since the fallback exists
	// precisely for the case where the block's content changed, an index that
	// every unrelated edit invalidates would leave the fallback unable to fire
	// exactly when it is needed.
	Index int
	// BlockHash is prosediff's structure-plus-normalized-content hash of the
	// block as it read when the comment was written.
	BlockHash string
	// Side is which revision of the block was commented on.
	Side CommentSide
}

// AnchorBlock is the part of a segmented block that anchoring reads.
//
// It exists so that core does not import prosediff. Dependency direction is
// strictly downward and the comment anchoring rules are domain logic, not diff
// logic — a caller passes prosediff's blocks through this shape.
type AnchorBlock struct {
	HeadingPath []string
	// Index is the block's position among blocks sharing HeadingPath, 0-based.
	// Build a slice of these with [AnchorBlocks] rather than filling it by hand.
	Index int
	Hash  string
}

// AnchorState is how well a comment's anchor still describes the revision being
// looked at. It is derived, never stored: a comment is not outdated in general,
// it is outdated *at a revision*, and a proposal branch moves under it as the
// agent revises.
type AnchorState string

const (
	// AnchorExact means the commented block is still present verbatim.
	AnchorExact AnchorState = "anchored"
	// AnchorEdited means the block at the anchor's position is still there but
	// its text has changed since the comment was written. The comment is shown
	// against it, marked, so the reader can see the critique may no longer fit.
	AnchorEdited AnchorState = "edited"
	// AnchorOutdated means neither the content nor the position matched.
	//
	// The comment is kept and reported as outdated rather than relocated to a
	// best guess. A comment moved to the wrong paragraph is worse than one
	// admitting it lost its place: the reader cannot tell it is wrong.
	AnchorOutdated AnchorState = "outdated"
)

// AnchorBlocks converts a revision's blocks, in document order, into the shape
// [ResolveAnchor] reads — numbering each block within its own heading path.
//
// hashes and paths are parallel slices in document order, which is what a
// caller holds after segmenting: pass block.Hash and block.HeadingPath.
func AnchorBlocks(hashes []string, paths [][]string) []AnchorBlock {
	out := make([]AnchorBlock, len(hashes))
	seen := make(map[string]int, len(hashes))
	for i, h := range hashes {
		var p []string
		if i < len(paths) {
			p = paths[i]
		}
		key := headingKey(p)
		out[i] = AnchorBlock{HeadingPath: p, Index: seen[key], Hash: h}
		seen[key]++
	}
	return out
}

// ResolveAnchor locates a comment's block in a revision, returning the index
// into blocks and how confident that answer is. The index is -1 when the anchor
// did not resolve.
//
// The order is content first, position second, give up third:
//
//  1. an identical block hash means the commented text is still there, wherever
//     it now sits — content is the strongest evidence and survives reflow,
//     renumbering and section renames;
//  2. failing that, the block at the same position under the same headings is
//     taken to be the same block, edited;
//  3. failing both, the anchor is outdated.
//
// Step 1 can match more than once — a document may repeat a paragraph, and
// "TBD" appears verbatim in a dozen places — so the heading path breaks the tie
// and the nearest index breaks what the heading path does not. Without that,
// which duplicate a comment landed on would depend on document order.
func ResolveAnchor(a CommentAnchor, blocks []AnchorBlock) (int, AnchorState) {
	if best := bestHashMatch(a, blocks); best >= 0 {
		return best, AnchorExact
	}
	for i, b := range blocks {
		if b.Index == a.Index && headingKey(b.HeadingPath) == headingKey(a.HeadingPath) {
			return i, AnchorEdited
		}
	}
	return -1, AnchorOutdated
}

// bestHashMatch returns the index of the block whose hash equals the anchor's,
// preferring one under the same headings and then the closest index. It returns
// -1 when no block has that hash.
//
// sameSectionBonus dominates any positional term, so a duplicate under the
// comment's own headings always beats a nearer one somewhere else: a comment on
// "TBD" under "Storage" belongs to Storage's TBD even when another section's
// sits at a closer index.
func bestHashMatch(a CommentAnchor, blocks []AnchorBlock) int {
	const sameSectionBonus = 1 << 20
	if a.BlockHash == "" {
		return -1
	}
	want := headingKey(a.HeadingPath)
	best, bestScore := -1, -1
	for i, b := range blocks {
		if b.Hash != a.BlockHash {
			continue
		}
		score := -abs(b.Index - a.Index) // nearer is better
		if headingKey(b.HeadingPath) == want {
			score += sameSectionBonus
		}
		if score > bestScore {
			best, bestScore = i, score
		}
	}
	return best
}

// headingKey flattens a heading path to a comparable string. "\x00" is the
// separator because it cannot occur in a heading, so no two distinct paths
// collide the way " › " would for a heading containing that sequence.
func headingKey(path []string) string {
	key := ""
	for _, h := range path {
		key += h + "\x00"
	}
	return key
}

func abs(n int) int {
	if n < 0 {
		return -n
	}
	return n
}