feat(doccontent): extract shared markdown canonicalization and SHA

docgov saves content hashes through the new leaf module so it no longer
imports docrevision. Existing docrevision exports stay as thin wrappers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-15 10:24:24 +00:00
co-authored by Cursor
parent 2771a1ed04
commit 9ddc673eb8
6 changed files with 139 additions and 34 deletions
+3 -1
View File
@@ -114,12 +114,14 @@ imports:
- docrevision
docgov:
- app/uow
- doccontent
- docduty
- docrevision
- fsutil
docrevision:
- app/uow
- doccontent
- fsutil
doccontent: []
freedoc:
- app/uow
- ctxmgr
+45
View File
@@ -0,0 +1,45 @@
package doccontent
import (
"crypto/sha256"
"encoding/hex"
"strings"
"golang.org/x/text/unicode/norm"
)
// CanonicalizeMarkdown applies LF, UTF-8 NFC, and a single trailing newline.
// It must not trim trailing spaces or tabs: two trailing spaces are Markdown hard breaks.
func CanonicalizeMarkdown(raw string) string {
s := strings.ReplaceAll(raw, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = norm.NFC.String(s)
if s != "" && !strings.HasSuffix(s, "\n") {
s += "\n"
}
return s
}
// LegacyDestructiveCanonicalizeMarkdown reproduces the pre-fix canonicalizer that
// stripped per-line trailing spaces/tabs. Used only to classify historical drift.
func LegacyDestructiveCanonicalizeMarkdown(raw string) string {
s := strings.ReplaceAll(raw, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = norm.NFC.String(s)
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimRightFunc(line, func(r rune) bool {
return r == ' ' || r == '\t'
})
}
s = strings.Join(lines, "\n")
if s != "" && !strings.HasSuffix(s, "\n") {
s += "\n"
}
return s
}
func SHA256Hex(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,70 @@
package doccontent_test
import (
"strings"
"testing"
"chat-agent-system/internal/doccontent"
)
func TestCanonicalizeMarkdown_PreservesHardBreakDoubleSpaces(t *testing.T) {
raw := "第一行 \n第二行 \n普通行\n"
got := doccontent.CanonicalizeMarkdown(raw)
if !strings.Contains(got, "第一行 \n") || !strings.Contains(got, "第二行 \n") {
t.Fatalf("hard-break double spaces must be kept: %q", got)
}
if strings.Count(got, " \n") != 2 {
t.Fatalf("want 2 hard-breaks, got %q", got)
}
if got != raw {
t.Fatalf("already-canonical hard-break markdown must be identity: got %q", got)
}
}
func TestCanonicalizeMarkdown_OnlyNormalizesLF_NFC_TrailingNewline(t *testing.T) {
in := "a\r\n b \t\n"
out := doccontent.CanonicalizeMarkdown(in)
if out != "a\n b \t\n" {
t.Fatalf("must keep trailing spaces/tabs and only normalize CRLF: got %q", out)
}
}
func TestLegacyDestructiveCanonicalize_TrimsTrailingSpaces(t *testing.T) {
raw := "第一行 \n第二行 \n"
legacy := doccontent.LegacyDestructiveCanonicalizeMarkdown(raw)
if strings.Contains(legacy, " \n") {
t.Fatalf("legacy destructive canonicalizer must trim hard-breaks: %q", legacy)
}
if legacy != "第一行\n第二行\n" {
t.Fatalf("got %q", legacy)
}
if doccontent.SHA256Hex(legacy) == doccontent.SHA256Hex(doccontent.CanonicalizeMarkdown(raw)) {
t.Fatal("legacy and semantic-preserving hashes must differ for hard-break docs")
}
}
func TestCanonicalizeMarkdown_Project143HardBreakGolden(t *testing.T) {
var b strings.Builder
for i := 0; i < 5; i++ {
b.WriteString("条目")
b.WriteByte('0' + byte(i))
b.WriteString(" \n")
}
b.WriteString("结尾段落。\n")
raw := b.String()
canon := doccontent.CanonicalizeMarkdown(raw)
if canon != raw {
t.Fatalf("project143-shaped hard-breaks must survive: got %q", canon)
}
legacy := doccontent.LegacyDestructiveCanonicalizeMarkdown(raw)
if len(raw)-len(legacy) != 10 {
t.Fatalf("legacy must drop exactly 10 bytes (5×2 spaces), raw=%d legacy=%d", len(raw), len(legacy))
}
}
func TestDocrevisionWrappersShareCanonicalBytes(t *testing.T) {
raw := "第一行 \r\n第二行 \n"
if doccontent.CanonicalizeMarkdown(raw) == "" {
t.Fatal("empty canon")
}
}
+3 -3
View File
@@ -11,7 +11,7 @@ import (
"strings"
"time"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/doccontent"
"chat-agent-system/internal/fsutil"
)
@@ -382,8 +382,8 @@ func (e *GovernanceEngine) SaveDocumentContent(ctx context.Context, req ContentS
}
now := time.Now()
canon := docrevision.CanonicalizeMarkdown(req.MarkdownBody)
shaStr := docrevision.SHA256Hex(canon)
canon := doccontent.CanonicalizeMarkdown(req.MarkdownBody)
shaStr := doccontent.SHA256Hex(canon)
doc.ContentSHA256 = shaStr
doc.IsTemplateOnly = false
doc.AuthorClaimed = true
+4 -30
View File
@@ -1,49 +1,23 @@
package docrevision
import (
"crypto/sha256"
"encoding/hex"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/unicode/norm"
"chat-agent-system/internal/doccontent"
)
// CanonicalizeMarkdown applies LF, UTF-8 NFC, and a single trailing newline.
// It must not trim trailing spaces or tabs: two trailing spaces are Markdown hard breaks.
func CanonicalizeMarkdown(raw string) string {
s := strings.ReplaceAll(raw, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = norm.NFC.String(s)
if s != "" && !strings.HasSuffix(s, "\n") {
s += "\n"
}
return s
return doccontent.CanonicalizeMarkdown(raw)
}
// LegacyDestructiveCanonicalizeMarkdown reproduces the pre-fix canonicalizer that
// stripped per-line trailing spaces/tabs. Used only to classify historical drift.
func LegacyDestructiveCanonicalizeMarkdown(raw string) string {
s := strings.ReplaceAll(raw, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = norm.NFC.String(s)
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimRightFunc(line, func(r rune) bool {
return r == ' ' || r == '\t'
})
}
s = strings.Join(lines, "\n")
if s != "" && !strings.HasSuffix(s, "\n") {
s += "\n"
}
return s
return doccontent.LegacyDestructiveCanonicalizeMarkdown(raw)
}
func SHA256Hex(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
return doccontent.SHA256Hex(content)
}
func ContentFingerprint(s string) string {
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
"chat-agent-system/internal/doccontent"
"chat-agent-system/internal/docrevision"
)
@@ -62,3 +63,16 @@ func TestCanonicalizeMarkdown_Project143HardBreakGolden(t *testing.T) {
t.Fatalf("legacy must drop exactly 10 bytes (5×2 spaces), raw=%d legacy=%d", len(raw), len(legacy))
}
}
func TestCanonicalizeWrappersMatchDoccontent(t *testing.T) {
raw := "第一行 \r\n第二行 \n"
if docrevision.CanonicalizeMarkdown(raw) != doccontent.CanonicalizeMarkdown(raw) {
t.Fatal("CanonicalizeMarkdown wrapper drifted")
}
if docrevision.LegacyDestructiveCanonicalizeMarkdown(raw) != doccontent.LegacyDestructiveCanonicalizeMarkdown(raw) {
t.Fatal("legacy wrapper drifted")
}
if docrevision.SHA256Hex(raw) != doccontent.SHA256Hex(raw) {
t.Fatal("SHA256Hex wrapper drifted")
}
}