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>
243 lines
5.7 KiB
Go
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
|
|
}
|