Files
Agent-System/internal/docrevision/diff.go
T
weichenandCursor 268a9bdb56 feat(service): implement docrevision Version/Revision kernel
Add canonicalize, stable block map, diff, risk floor, memory ledger
service, contract fixtures, concurrency tests, and backfill CLI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 06:15:20 +00:00

243 lines
5.7 KiB
Go

package docrevision
import (
"fmt"
"strings"
)
// ComputeDiff produces a line-oriented structured diff between two markdown bodies.
func ComputeDiff(fromMarkdown, toMarkdown string, fromBlocks, toBlocks []BlockNode) DiffResult {
fromLines := splitLines(fromMarkdown)
toLines := splitLines(toMarkdown)
hunks := myersLineDiff(fromLines, toLines, fromBlocks, toBlocks)
// Detect moves on raw delete/insert pairs before collapsing modifies.
hunks = annotateMoves(hunks, fromBlocks, toBlocks)
hunks = collapseAdjacentModify(hunks)
sum := DiffSummary{}
for _, h := range hunks {
switch h.Kind {
case "insert":
sum.Inserts++
case "delete":
sum.Deletes++
case "modify":
sum.Modifies++
case "move":
sum.Moves++
}
}
return DiffResult{Summary: sum, Hunks: hunks}
}
func splitLines(s string) []string {
s = strings.TrimSuffix(s, "\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
func myersLineDiff(a, b []string, fromBlocks, toBlocks []BlockNode) []DiffHunk {
n, m := len(a), len(b)
// Simple LCS DP for correctness on moderate docs.
dp := make([][]int, n+1)
for i := range dp {
dp[i] = make([]int, m+1)
}
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if a[i] == b[j] {
dp[i][j] = dp[i+1][j+1] + 1
} else if dp[i+1][j] >= dp[i][j+1] {
dp[i][j] = dp[i+1][j]
} else {
dp[i][j] = dp[i][j+1]
}
}
}
var hunks []DiffHunk
i, j := 0, 0
idx := 0
for i < n && j < m {
if a[i] == b[j] {
i++
j++
continue
}
if dp[i+1][j] >= dp[i][j+1] {
h := DiffHunk{
ID: fmt.Sprintf("h%d", idx),
Kind: "delete",
BeforeText: a[i],
LineStart: i + 1,
LineEnd: i + 1,
Section: sectionForLine(i, fromBlocks),
}
if blk := blockForLine(i, a, fromBlocks); blk != nil {
h.BlockIDBefore = blk.ID
}
hunks = append(hunks, h)
idx++
i++
} else {
h := DiffHunk{
ID: fmt.Sprintf("h%d", idx),
Kind: "insert",
AfterText: b[j],
LineStart: j + 1,
LineEnd: j + 1,
Section: sectionForLine(j, toBlocks),
}
if blk := blockForLine(j, b, toBlocks); blk != nil {
h.BlockIDAfter = blk.ID
}
hunks = append(hunks, h)
idx++
j++
}
}
for i < n {
h := DiffHunk{ID: fmt.Sprintf("h%d", idx), Kind: "delete", BeforeText: a[i], LineStart: i + 1, LineEnd: i + 1, Section: sectionForLine(i, fromBlocks)}
if blk := blockForLine(i, a, fromBlocks); blk != nil {
h.BlockIDBefore = blk.ID
}
hunks = append(hunks, h)
idx++
i++
}
for j < m {
h := DiffHunk{ID: fmt.Sprintf("h%d", idx), Kind: "insert", AfterText: b[j], LineStart: j + 1, LineEnd: j + 1, Section: sectionForLine(j, toBlocks)}
if blk := blockForLine(j, b, toBlocks); blk != nil {
h.BlockIDAfter = blk.ID
}
hunks = append(hunks, h)
idx++
j++
}
return hunks
}
func collapseAdjacentModify(hunks []DiffHunk) []DiffHunk {
if len(hunks) == 0 {
return hunks
}
out := make([]DiffHunk, 0, len(hunks))
for i := 0; i < len(hunks); i++ {
if i+1 < len(hunks) && hunks[i].Kind == "delete" && hunks[i+1].Kind == "insert" {
out = append(out, DiffHunk{
ID: hunks[i].ID,
Kind: "modify",
BeforeText: hunks[i].BeforeText,
AfterText: hunks[i+1].AfterText,
BlockIDBefore: hunks[i].BlockIDBefore,
BlockIDAfter: hunks[i+1].BlockIDAfter,
Section: firstNonEmpty(hunks[i].Section, hunks[i+1].Section),
LineStart: hunks[i].LineStart,
LineEnd: hunks[i+1].LineEnd,
})
i++
continue
}
out = append(out, hunks[i])
}
return out
}
func annotateMoves(hunks []DiffHunk, fromBlocks, toBlocks []BlockNode) []DiffHunk {
fromByFP := map[string]string{}
for _, b := range fromBlocks {
fromByFP[b.Fingerprint] = b.ID
}
toByFP := map[string]string{}
for _, b := range toBlocks {
toByFP[b.Fingerprint] = b.ID
}
deletedFP := map[string]int{}
insertedFP := map[string]int{}
for i, h := range hunks {
if h.Kind == "delete" {
deletedFP[ContentFingerprint(h.BeforeText)] = i
}
if h.Kind == "insert" {
insertedFP[ContentFingerprint(h.AfterText)] = i
}
}
used := map[int]bool{}
out := make([]DiffHunk, 0, len(hunks))
for i, h := range hunks {
if used[i] {
continue
}
if h.Kind == "delete" {
fp := ContentFingerprint(h.BeforeText)
if j, ok := insertedFP[fp]; ok && !used[j] {
used[i] = true
used[j] = true
mh := DiffHunk{
ID: h.ID,
Kind: "move",
BeforeText: h.BeforeText,
AfterText: hunks[j].AfterText,
BlockIDBefore: h.BlockIDBefore,
BlockIDAfter: hunks[j].BlockIDAfter,
Section: fmt.Sprintf("%s → %s", h.Section, hunks[j].Section),
LineStart: h.LineStart,
LineEnd: hunks[j].LineEnd,
}
out = append(out, mh)
continue
}
}
if h.Kind == "insert" {
fp := ContentFingerprint(h.AfterText)
if _, ok := deletedFP[fp]; ok {
// paired as move above
if used[i] {
continue
}
}
}
out = append(out, h)
}
return out
}
func sectionForLine(lineIdx int, blocks []BlockNode) string {
// Approximate: find block whose order matches line; fallback heading path of nearest prior.
if lineIdx < 0 || len(blocks) == 0 {
return ""
}
best := ""
for _, b := range blocks {
if b.Order <= lineIdx {
if b.HeadingPath != "" {
best = b.HeadingPath
}
}
}
return best
}
func blockForLine(lineIdx int, lines []string, blocks []BlockNode) *BlockNode {
if lineIdx < 0 || lineIdx >= len(lines) {
return nil
}
fp := ContentFingerprint(lines[lineIdx])
for i := range blocks {
if blocks[i].Fingerprint == fp {
return &blocks[i]
}
}
if lineIdx < len(blocks) {
return &blocks[lineIdx]
}
return nil
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}