Artifact a4b3ca8ea681463794cb2c7ed61e3730cfa731719e4e8dc9292ebd8bdb67b665:
- File
internal/semdiff/semdiff.go
— part of check-in
[b9d8e3692c]
at
2026-08-14 20:40:45
on branch trunk
— semdiff core + fixture corpus tooling
Canonical DOM/JSON/timestamp diff (0001 §11); synthetic fixture via fossil serve-and-clone; make fixtures / fixtures-real. (user: dmestas, size: 4357) [annotate] [blame] [check-ins using]
// Package semdiff is the comparison core of the disposability test // (0001 §11): snapshots taken before and after a wipe-and-reingest must be // *semantically* equal. HTML is compared as canonicalized DOM, JSON with // sorted keys, timestamps normalized to UTC RFC3339 — so hostage bytes fail // the build while harmless ordering and formatting drift does not. package semdiff import ( "bytes" "encoding/json" "fmt" "regexp" "sort" "strings" "time" "golang.org/x/net/html" ) // Kind selects the canonicalizer. type Kind int const ( JSON Kind = iota HTML ) // Diff canonicalizes both inputs and returns "" when semantically equal, // or a first-divergence report suitable for a test failure message. func Diff(kind Kind, a, b []byte) (string, error) { canon := CanonJSON if kind == HTML { canon = CanonHTML } ca, err := canon(a) if err != nil { return "", fmt.Errorf("semdiff: left: %w", err) } cb, err := canon(b) if err != nil { return "", fmt.Errorf("semdiff: right: %w", err) } if bytes.Equal(ca, cb) { return "", nil } return firstDivergence(ca, cb), nil } func firstDivergence(a, b []byte) string { al := strings.Split(string(a), "\n") bl := strings.Split(string(b), "\n") for i := 0; i < len(al) || i < len(bl); i++ { var la, lb string if i < len(al) { la = al[i] } if i < len(bl) { lb = bl[i] } if la != lb { return fmt.Sprintf("line %d:\n- %s\n+ %s", i+1, la, lb) } } return "inputs differ (no line divergence?)" } // CanonJSON reparses, normalizes timestamp-shaped string leaves to UTC // RFC3339, and re-marshals with indentation. encoding/json already emits // map keys sorted, which gives the sorted-keys guarantee. func CanonJSON(in []byte) ([]byte, error) { var v any if err := json.Unmarshal(in, &v); err != nil { return nil, err } v = normalizeJSON(v) return json.MarshalIndent(v, "", " ") } func normalizeJSON(v any) any { switch t := v.(type) { case map[string]any: for k, e := range t { t[k] = normalizeJSON(e) } case []any: for i, e := range t { t[i] = normalizeJSON(e) } case string: return normalizeTimestamp(t) } return v } // timestampLayouts are the representations quarry's surfaces may emit. // Extend as snapshot formats appear; a layout here means "this is a // timestamp, unify it", never a guess over free text. var timestampLayouts = []string{ time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05Z07:00", "2006-01-02 15:04:05", } // timestampShape gates parsing so ordinary strings ("1.2.3", "2026 was a // year") are never touched: full-string date-time with optional zone. var timestampShape = regexp.MustCompile( `^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$`) func normalizeTimestamp(s string) string { if !timestampShape.MatchString(s) { return s } for _, layout := range timestampLayouts { if ts, err := time.Parse(layout, s); err == nil { return ts.UTC().Format(time.RFC3339) } } return s } // CanonHTML parses to a DOM and renders it back with attributes sorted, // inter-tag whitespace collapsed, and timestamp-shaped text normalized. // <pre>, <script>, <style>, and <textarea> subtrees keep their bytes. func CanonHTML(in []byte) ([]byte, error) { doc, err := html.Parse(bytes.NewReader(in)) if err != nil { return nil, err } canonNode(doc, false) var out bytes.Buffer if err := html.Render(&out, doc); err != nil { return nil, err } return out.Bytes(), nil } var verbatimTags = map[string]bool{ "pre": true, "script": true, "style": true, "textarea": true, } func canonNode(n *html.Node, verbatim bool) { if n.Type == html.ElementNode { sort.Slice(n.Attr, func(i, j int) bool { return n.Attr[i].Key < n.Attr[j].Key }) verbatim = verbatim || verbatimTags[n.Data] } if n.Type == html.TextNode && !verbatim { n.Data = collapseSpace(n.Data) } for c := n.FirstChild; c != nil; c = c.NextSibling { canonNode(c, verbatim) } } // collapseSpace unifies runs of whitespace and normalizes any // timestamp-shaped token in place (T-form RFC3339 is one token; the // space-separated layouts only appear in JSON surfaces). func collapseSpace(s string) string { fields := strings.Fields(s) if len(fields) == 0 { return "" } for i, f := range fields { fields[i] = normalizeTimestamp(f) } return " " + strings.Join(fields, " ") + " " }