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 }