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:
@@ -114,12 +114,14 @@ imports:
|
|||||||
- docrevision
|
- docrevision
|
||||||
docgov:
|
docgov:
|
||||||
- app/uow
|
- app/uow
|
||||||
|
- doccontent
|
||||||
- docduty
|
- docduty
|
||||||
- docrevision
|
|
||||||
- fsutil
|
- fsutil
|
||||||
docrevision:
|
docrevision:
|
||||||
- app/uow
|
- app/uow
|
||||||
|
- doccontent
|
||||||
- fsutil
|
- fsutil
|
||||||
|
doccontent: []
|
||||||
freedoc:
|
freedoc:
|
||||||
- app/uow
|
- app/uow
|
||||||
- ctxmgr
|
- ctxmgr
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"chat-agent-system/internal/docrevision"
|
"chat-agent-system/internal/doccontent"
|
||||||
"chat-agent-system/internal/fsutil"
|
"chat-agent-system/internal/fsutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -382,8 +382,8 @@ func (e *GovernanceEngine) SaveDocumentContent(ctx context.Context, req ContentS
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
canon := docrevision.CanonicalizeMarkdown(req.MarkdownBody)
|
canon := doccontent.CanonicalizeMarkdown(req.MarkdownBody)
|
||||||
shaStr := docrevision.SHA256Hex(canon)
|
shaStr := doccontent.SHA256Hex(canon)
|
||||||
doc.ContentSHA256 = shaStr
|
doc.ContentSHA256 = shaStr
|
||||||
doc.IsTemplateOnly = false
|
doc.IsTemplateOnly = false
|
||||||
doc.AuthorClaimed = true
|
doc.AuthorClaimed = true
|
||||||
|
|||||||
@@ -1,49 +1,23 @@
|
|||||||
package docrevision
|
package docrevision
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"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 {
|
func CanonicalizeMarkdown(raw string) string {
|
||||||
s := strings.ReplaceAll(raw, "\r\n", "\n")
|
return doccontent.CanonicalizeMarkdown(raw)
|
||||||
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 {
|
func LegacyDestructiveCanonicalizeMarkdown(raw string) string {
|
||||||
s := strings.ReplaceAll(raw, "\r\n", "\n")
|
return doccontent.LegacyDestructiveCanonicalizeMarkdown(raw)
|
||||||
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 {
|
func SHA256Hex(content string) string {
|
||||||
sum := sha256.Sum256([]byte(content))
|
return doccontent.SHA256Hex(content)
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ContentFingerprint(s string) string {
|
func ContentFingerprint(s string) string {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"chat-agent-system/internal/doccontent"
|
||||||
"chat-agent-system/internal/docrevision"
|
"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))
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user