fix(wiki): give the preamble its own field instead of overloading heading==""
CI / Lint / Test / Vet (pull_request) Successful in 31s
CI / Mirror to GitHub (pull_request) Skipped

Second review pass on PR #103 found a third bug in the same file: the
preamble fix from the prior commit represented "content before the first
## heading" as a section with heading=="" — which collides with a literal
degenerate `## ` (marker, no title text; strings.TrimPrefix also yields
"").  Two entries sharing one key in Merge's idx map means one loses
lookup visibility. Reproduced: a has only a preamble, b introduces a
genuinely new degenerate `## ` section — Merge's idx wrongly reports it
as "already exists" (it's really a's unrelated preamble) and never adds
it, so b's section vanishes from the output entirely.

Fixed by giving parseSections a dedicated preamble return value instead
of folding it into the sections slice. It can no longer collide with
anything, degenerate or otherwise, because it's no longer keyed by
heading at all. Merge and rebuildContent updated to thread it through
directly. No other caller of parseSections/rebuildContent in this repo.

Also fixed a doc-comment left misplaced by the prior commit (appendNew's
comment had drifted onto canonicalLine, inserted between them).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 23:40:29 +02:00
co-authored by Claude Sonnet 5
parent 9abe4369f2
commit 09aeaa91f5
2 changed files with 74 additions and 40 deletions
+56 -40
View File
@@ -29,12 +29,16 @@ type section struct {
} }
// Merge combines two Page values with the same path. // Merge combines two Page values with the same path.
// Frontmatter is taken from a. Sections are merged by strategy: // Frontmatter is taken from a. The preamble (content before the first ##
// bullet sections union unique lines, append sections concatenate, // heading) is taken from a — same "on-disk wins" default every unlisted
// all others keep a's version. Sections in b not present in a are appended. // named section gets, kept as a separate field rather than a section with
// an empty heading so it can never collide with one (see parseSections).
// Sections are merged by strategy: bullet sections union unique lines,
// append sections concatenate, all others keep a's version. Sections in b
// not present in a are appended.
func Merge(a, b Page) Page { func Merge(a, b Page) Page {
fmA, secsA := parseSections(a.Content) fmA, preambleA, secsA := parseSections(a.Content)
_, secsB := parseSections(b.Content) _, _, secsB := parseSections(b.Content)
idx := make(map[string]int, len(secsA)) idx := make(map[string]int, len(secsA))
for i, s := range secsA { for i, s := range secsA {
@@ -57,10 +61,20 @@ func Merge(a, b Page) Page {
} }
} }
return Page{Path: a.Path, Content: rebuildContent(fmA, secsA)} return Page{Path: a.Path, Content: rebuildContent(fmA, preambleA, secsA)}
} }
func parseSections(markdown string) (frontmatter string, sections []section) { // parseSections splits markdown into its frontmatter, its preamble (content
// before the first `## ` heading), and its `## `-headed sections.
//
// The preamble is returned separately rather than as a section with an
// empty heading — a section keyed by heading=="" would collide with a
// literal degenerate `## ` (marker, no title text; strings.TrimPrefix also
// yields ""), which is a distinct, addressable section a page can genuinely
// have, not the same thing as "no heading yet." Two entries sharing one key
// in Merge's idx map means one of them silently loses lookup visibility —
// found in review as real, reproducible data loss, not a hypothetical.
func parseSections(markdown string) (frontmatter, preamble string, sections []section) {
lines := strings.Split(markdown, "\n") lines := strings.Split(markdown, "\n")
i := 0 i := 0
@@ -80,24 +94,35 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
// Everything before the first `## ` heading — an intro paragraph, or, if // Everything before the first `## ` heading — an intro paragraph, or, if
// the page has no headings at all, the entire body — is ordinary content, // the page has no headings at all, the entire body — is ordinary content,
// not something this parser is entitled to drop. It becomes a section // not something this parser is entitled to drop (hyperguild#103 review:
// with an empty heading, which Merge's switch never matches, so it falls // dropping it meant Merge, one caller up, would silently replace the
// through to the same "keep a's version" default every other unlisted // whole on-disk body with the fresh extraction whenever a page didn't
// section gets (hyperguild#103 review: dropping it here meant Merge, one // follow the ## convention — exactly the class of bug this file exists
// caller up, would silently replace the whole on-disk body with the // to prevent).
// fresh extraction whenever a page didn't follow the ## convention — var preambleLines []string
// exactly the class of bug this whole file exists to prevent). for ; i < len(lines); i++ {
cur := &section{} if strings.HasPrefix(lines[i], "## ") {
break
}
preambleLines = append(preambleLines, lines[i])
}
preamble = strings.Join(preambleLines, "\n")
var cur *section
for ; i < len(lines); i++ { for ; i < len(lines); i++ {
line := lines[i] line := lines[i]
if strings.HasPrefix(line, "## ") { if strings.HasPrefix(line, "## ") {
sections = append(sections, *cur) if cur != nil {
sections = append(sections, *cur)
}
cur = &section{heading: strings.TrimPrefix(line, "## ")} cur = &section{heading: strings.TrimPrefix(line, "## ")}
} else { } else if cur != nil {
cur.content += line + "\n" cur.content += line + "\n"
} }
} }
sections = append(sections, *cur) if cur != nil {
sections = append(sections, *cur)
}
return return
} }
@@ -109,28 +134,28 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
// by a blank line — invisible in a diff viewer, fatal to byte-stability. Compile-on-ingest // by a blank line — invisible in a diff viewer, fatal to byte-stability. Compile-on-ingest
// merges on every run, so "merging changes nothing when nothing changed" has to // merges on every run, so "merging changes nothing when nothing changed" has to
// hold at the byte level, not just the content level. // hold at the byte level, not just the content level.
func rebuildContent(frontmatter string, sections []section) string { func rebuildContent(frontmatter, preamble string, sections []section) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(frontmatter) sb.WriteString(frontmatter)
if body := strings.Trim(preamble, "\n"); body != "" {
fmt.Fprintf(&sb, "\n%s\n", body)
}
for _, sec := range sections { for _, sec := range sections {
body := strings.Trim(sec.content, "\n") body := strings.Trim(sec.content, "\n")
if sec.heading == "" {
// The preamble pseudo-section: content before the first ##. No
// heading of its own to re-emit, and — unlike a named section —
// it is usually empty (most pages start directly with a ##), so
// an empty one must produce nothing rather than a stray blank
// line ahead of the first real heading.
if body == "" {
continue
}
fmt.Fprintf(&sb, "\n%s\n", body)
continue
}
fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body) fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body)
} }
return sb.String() return sb.String()
} }
// canonicalLine collapses a line to its comparison key: internal whitespace
// runs (a re-extraction's bullet spacing, tabs vs spaces, trailing spaces)
// must not make the same logical line look new. strings.Fields already
// trims and splits on any whitespace run, so joining it back with single
// spaces is exactly "same words, same order, whitespace-insensitive."
func canonicalLine(line string) string {
return strings.Join(strings.Fields(line), " ")
}
// appendNew concatenates the lines of b that a does not already carry. // appendNew concatenates the lines of b that a does not already carry.
// //
// Append sections used to concatenate wholesale, which is correct exactly once. // Append sections used to concatenate wholesale, which is correct exactly once.
@@ -148,15 +173,6 @@ func rebuildContent(frontmatter string, sections []section) string {
// these sections — questions, updates, evolving notes — a repeat is noise // these sections — questions, updates, evolving notes — a repeat is noise
// rather than signal, and the failure is conservative: the page stops growing // rather than signal, and the failure is conservative: the page stops growing
// instead of growing wrongly. // instead of growing wrongly.
// canonicalLine collapses a line to its comparison key: internal whitespace
// runs (a re-extraction's bullet spacing, tabs vs spaces, trailing spaces)
// must not make the same logical line look new. strings.Fields already
// trims and splits on any whitespace run, so joining it back with single
// spaces is exactly "same words, same order, whitespace-insensitive."
func canonicalLine(line string) string {
return strings.Join(strings.Fields(line), " ")
}
func appendNew(a, b string) string { func appendNew(a, b string) string {
seen := make(map[string]bool) seen := make(map[string]bool)
for _, line := range strings.Split(a, "\n") { for _, line := range strings.Split(a, "\n") {
+18
View File
@@ -73,6 +73,24 @@ func TestMerge_PreservesOnDiskContentWithNoSectionHeadings(t *testing.T) {
"on-disk content with no ## headings must survive the merge, not be replaced wholesale by the fresh extraction") "on-disk content with no ## headings must survive the merge, not be replaced wholesale by the fresh extraction")
} }
func TestMerge_DegenerateEmptyHeadingDoesNotCollideWithPreamble(t *testing.T) {
// Second review pass on #103: representing the preamble as a section with
// heading=="" collides with a literal `## ` (marker, no title text) —
// strings.TrimPrefix("## ", "## ") is also "". `a` has only a preamble
// (no degenerate heading of its own); `b` introduces a genuinely new
// degenerate `## ` section. Because both key to "", Merge's idx thinks
// b's section "already exists" in a (it's really just a's unrelated
// preamble) and never adds it — b's section vanishes from the output
// entirely, not merged, not kept, not appended.
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph.\n\n## Definition\n\nA thing.\n"}
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## \n\nA genuinely new degenerate section's body.\n\n## Definition\n\nA thing.\n"}
got := Merge(a, b)
assert.Contains(t, got.Content, "An intro paragraph.", "a's own preamble must survive")
assert.Contains(t, got.Content, "A genuinely new degenerate section's body.",
"b's degenerate section is new content, not a collision with a's unrelated preamble — it must not be silently dropped")
}
func TestMerge_PreservesPreambleBeforeFirstHeading(t *testing.T) { func TestMerge_PreservesPreambleBeforeFirstHeading(t *testing.T) {
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph before the first heading.\n\n## Definition\n\nA thing.\n"} a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph before the first heading.\n\n## Definition\n\nA thing.\n"}
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nA thing.\n"} b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nA thing.\n"}