fix(wiki): stop Merge from silently deleting on-disk content it can't parse
CI / Lint / Test / Vet (pull_request) Successful in 32s
CI / Mirror to GitHub (pull_request) Skipped

Review of PR #103 found a real data-loss bug: parseSections only captured
text between `## ` headings. Content before the first heading — an intro
paragraph, or, if the page had no headings at all, the entire body — was
dropped on the floor, not carried into any section. Merge then had
nothing of `a` to keep there, so the fresh extraction's sections silently
became the whole output wherever that content used to be. Exactly the
failure this file exists to prevent, just re-triggered by a page shape
the parser didn't expect instead of by every re-ingest.

Fixed by treating pre-first-heading content as a section with an empty
heading. Merge's switch never matches "", so it falls through to the
same on-disk-wins default every other unlisted section already gets —
no new merge path, just no longer discarding this one's input.

Second, separate finding from the same review: mergeBullets/appendNew
deduped on the raw trimmed line, so two re-extractions of the same
logical bullet with different internal whitespace (a real possibility —
the extractor is an LLM, not a fixed serializer) produced a duplicate
instead of a no-op, undermining the idempotency this file's own doc
comments claim. Fixed by comparing a whitespace-canonicalized key
(strings.Fields + single-space join) while still emitting the original
line's text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 23:34:11 +02:00
co-authored by Claude Sonnet 5
parent 60a8801a1d
commit 9abe4369f2
2 changed files with 95 additions and 16 deletions
+42 -16
View File
@@ -78,21 +78,26 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
frontmatter = fmt.Sprintf("---\n%s\n---\n", strings.Join(fmLines, "\n"))
}
var cur *section
// Everything before the first `## ` heading — an intro paragraph, or, if
// 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
// with an empty heading, which Merge's switch never matches, so it falls
// through to the same "keep a's version" default every other unlisted
// section gets (hyperguild#103 review: dropping it here meant Merge, one
// caller up, would silently replace the whole on-disk body with the
// fresh extraction whenever a page didn't follow the ## convention —
// exactly the class of bug this whole file exists to prevent).
cur := &section{}
for ; i < len(lines); i++ {
line := lines[i]
if strings.HasPrefix(line, "## ") {
if cur != nil {
sections = append(sections, *cur)
}
sections = append(sections, *cur)
cur = &section{heading: strings.TrimPrefix(line, "## ")}
} else if cur != nil {
} else {
cur.content += line + "\n"
}
}
if cur != nil {
sections = append(sections, *cur)
}
sections = append(sections, *cur)
return
}
@@ -109,6 +114,18 @@ func rebuildContent(frontmatter string, sections []section) string {
sb.WriteString(frontmatter)
for _, sec := range sections {
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)
}
return sb.String()
@@ -131,21 +148,30 @@ func rebuildContent(frontmatter string, sections []section) string {
// these sections — questions, updates, evolving notes — a repeat is noise
// rather than signal, and the failure is conservative: the page stops growing
// 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 {
seen := make(map[string]bool)
for _, line := range strings.Split(a, "\n") {
if t := strings.TrimSpace(line); t != "" {
seen[t] = true
if c := canonicalLine(line); c != "" {
seen[c] = true
}
}
var add []string
for _, line := range strings.Split(b, "\n") {
t := strings.TrimSpace(line)
if t == "" || seen[t] {
c := canonicalLine(line)
if c == "" || seen[c] {
continue
}
seen[t] = true
seen[c] = true
add = append(add, line)
}
if len(add) == 0 {
@@ -158,11 +184,11 @@ func mergeBullets(a, b string) string {
seen := make(map[string]bool)
var lines []string
for _, line := range strings.Split(a+b, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || seen[trimmed] {
c := canonicalLine(line)
if c == "" || seen[c] {
continue
}
seen[trimmed] = true
seen[c] = true
lines = append(lines, line)
}
return strings.Join(lines, "\n") + "\n"