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>
186 lines
4.4 KiB
Go
186 lines
4.4 KiB
Go
package docrevision
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
headingRe = regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
|
|
reqIDRe = regexp.MustCompile(`\b(REQ-[A-Z0-9-]+)\b`)
|
|
listItemRe = regexp.MustCompile(`^(\s*[-*+]|\s*\d+\.)\s+`)
|
|
tableRowRe = regexp.MustCompile(`^\|`)
|
|
fenceRe = regexp.MustCompile("^```")
|
|
mermaidRe = regexp.MustCompile(`(?i)^mermaid\b`)
|
|
)
|
|
|
|
// BuildBlockMap parses markdown into stable block nodes.
|
|
// When previous is provided, nodes are aligned by business key → heading path → fingerprint → order.
|
|
func BuildBlockMap(markdown string, previous []BlockNode) []BlockNode {
|
|
lines := strings.Split(strings.TrimSuffix(markdown, "\n"), "\n")
|
|
var nodes []BlockNode
|
|
offset := 0
|
|
headingPath := []string{}
|
|
inFence := false
|
|
fenceLang := ""
|
|
var fenceBuf []string
|
|
fenceStart := 0
|
|
order := 0
|
|
|
|
flushFence := func() {
|
|
if !inFence {
|
|
return
|
|
}
|
|
body := strings.Join(fenceBuf, "\n")
|
|
typ := "code"
|
|
if mermaidRe.MatchString(fenceLang) {
|
|
typ = "mermaid"
|
|
}
|
|
nodes = append(nodes, makeNode(typ, fenceStart, offset, strings.Join(headingPath, " / "), "", body, order, previous))
|
|
order++
|
|
inFence = false
|
|
fenceBuf = nil
|
|
fenceLang = ""
|
|
}
|
|
|
|
for i, line := range lines {
|
|
lineLen := len(line)
|
|
if i < len(lines)-1 || strings.HasSuffix(markdown, "\n") {
|
|
lineLen++ // account for newline except possibly last without NL; we trimSuffix above so always +1 except last
|
|
}
|
|
// recompute offset carefully
|
|
_ = lineLen
|
|
}
|
|
|
|
offset = 0
|
|
for i, line := range lines {
|
|
start := offset
|
|
end := start + len(line)
|
|
if i < len(lines)-1 {
|
|
end++ // newline
|
|
} else if strings.HasSuffix(markdown, "\n") {
|
|
end++
|
|
}
|
|
|
|
if fenceRe.MatchString(line) {
|
|
if !inFence {
|
|
inFence = true
|
|
fenceLang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
|
|
fenceStart = start
|
|
fenceBuf = nil
|
|
} else {
|
|
flushFence()
|
|
}
|
|
offset = end
|
|
continue
|
|
}
|
|
if inFence {
|
|
fenceBuf = append(fenceBuf, line)
|
|
offset = end
|
|
continue
|
|
}
|
|
|
|
if m := headingRe.FindStringSubmatch(line); m != nil {
|
|
level := len(m[1])
|
|
title := strings.TrimSpace(m[2])
|
|
if level <= len(headingPath) {
|
|
headingPath = headingPath[:level-1]
|
|
}
|
|
headingPath = append(headingPath, title)
|
|
path := strings.Join(headingPath, " / ")
|
|
bk := ""
|
|
if id := reqIDRe.FindString(title); id != "" {
|
|
bk = id
|
|
}
|
|
nodes = append(nodes, makeNode("heading", start, end, path, bk, line, order, previous))
|
|
order++
|
|
offset = end
|
|
continue
|
|
}
|
|
|
|
path := strings.Join(headingPath, " / ")
|
|
switch {
|
|
case tableRowRe.MatchString(line):
|
|
bk := ""
|
|
if id := reqIDRe.FindString(line); id != "" {
|
|
bk = id
|
|
}
|
|
nodes = append(nodes, makeNode("table_row", start, end, path, bk, line, order, previous))
|
|
case listItemRe.MatchString(line):
|
|
bk := ""
|
|
if id := reqIDRe.FindString(line); id != "" {
|
|
bk = id
|
|
}
|
|
nodes = append(nodes, makeNode("list_item", start, end, path, bk, line, order, previous))
|
|
case strings.TrimSpace(line) == "":
|
|
// skip empty
|
|
default:
|
|
bk := ""
|
|
if id := reqIDRe.FindString(line); id != "" {
|
|
bk = id
|
|
}
|
|
nodes = append(nodes, makeNode("paragraph", start, end, path, bk, line, order, previous))
|
|
}
|
|
order++
|
|
offset = end
|
|
}
|
|
flushFence()
|
|
return nodes
|
|
}
|
|
|
|
func makeNode(typ string, start, end int, path, businessKey, text string, order int, previous []BlockNode) BlockNode {
|
|
fp := ContentFingerprint(text)
|
|
id := matchPreviousID(typ, path, businessKey, fp, order, previous)
|
|
if id == "" {
|
|
id = newBlockID()
|
|
}
|
|
return BlockNode{
|
|
ID: id,
|
|
Type: typ,
|
|
StartOffset: start,
|
|
EndOffset: end,
|
|
HeadingPath: path,
|
|
BusinessKey: businessKey,
|
|
Fingerprint: fp,
|
|
Order: order,
|
|
}
|
|
}
|
|
|
|
func matchPreviousID(typ, path, businessKey, fp string, order int, previous []BlockNode) string {
|
|
if len(previous) == 0 {
|
|
return ""
|
|
}
|
|
if businessKey != "" {
|
|
for _, p := range previous {
|
|
if p.BusinessKey == businessKey && p.Type == typ {
|
|
return p.ID
|
|
}
|
|
}
|
|
}
|
|
for _, p := range previous {
|
|
if p.Type == typ && p.HeadingPath == path && p.Fingerprint == fp {
|
|
return p.ID
|
|
}
|
|
}
|
|
for _, p := range previous {
|
|
if p.Type == typ && p.Fingerprint == fp {
|
|
return p.ID
|
|
}
|
|
}
|
|
for _, p := range previous {
|
|
if p.Order == order && p.Type == typ {
|
|
return p.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func newBlockID() string {
|
|
var b [8]byte
|
|
_, _ = rand.Read(b[:])
|
|
return fmt.Sprintf("blk_%s", hex.EncodeToString(b[:]))
|
|
}
|