Files
hyperguild/ingestion/internal/api/frontmatter.go
T
mathiasandClaude Sonnet 5 695e90257a
CI / Lint / Test / Vet (pull_request) Successful in 41s
CI / Mirror to GitHub (pull_request) Skipped
fix(api): frontmatter parser must not misparse indented list items as keys
hyperguild#128, filed while fixing the same bug class in the sibling
internal/wiki package's parseFrontmatterBlock (hyperguild#126 review
round 3). This package was the original parseFrontmatter/set()/render()
shape that reimplementation was modeled on, and had the same gap: an
indented YAML block-list item containing a colon (e.g. a title with a
subtitle inside an aliases: list) got cut at the colon and treated as a
fake top-level key, since strings.Cut doesn't know a line is a
continuation just because TrimSpace(key) happens to be non-empty.
Re-rendering then stripped the item's indentation, de-indenting it out
of the list it belongs to. This package backs UpdateNote/writeHallNote,
so it's on the live brain_update write path.

Fixed the same way as the wiki package: check indentation before
calling Cut at all -- a line starting with a space or tab is a
continuation, full stop, regardless of what it contains.

Second, related bug found via the fix's own test fixture, not
speculatively: render() wrote "key: " (trailing space) for a key with
an empty value (the same "aliases:" block-list header line) instead of
bare "key:" -- a different byte sequence than the original, breaking
this type's whole documented purpose (a byte-preserving line view, not
a YAML re-marshal). No current caller sets an empty-value key via
set(), so this hadn't shown up in a set()-driven path, but it fires on
every ordinary parse-then-render round-trip of a note that already has
one -- confirmed directly via this fix's own test output before writing
a fix for it.

Flip-tested both independently: disabling either fix turns its
corresponding test red, the other stays green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:10:10 +02:00

117 lines
3.6 KiB
Go

package api
import "strings"
// frontmatter is an ordered, line-preserving view of a note's YAML
// frontmatter block. It deliberately avoids a full YAML round-trip: the
// brain writes flat `key: value` frontmatter by hand, and a yaml.v3
// re-marshal would reorder keys and strip comments. Preserving the
// original lines verbatim keeps brain_update a surgical edit — only the
// keys it manages (updated_at, supersedes, supersede_reason) change.
type frontmatter struct {
lines []fmLine
}
// fmLine is one frontmatter line. For `key: value` lines, key and value
// are populated; for blank lines, comments, or anything that isn't a
// simple scalar pair, key is empty and raw holds the line verbatim.
type fmLine struct {
key string
value string
raw string
}
// parseFrontmatter splits src into its frontmatter block and body. A
// frontmatter block is recognised only when the file opens with a `---`
// fence and a closing `---` fence follows. Otherwise the whole input is
// the body and the returned frontmatter is empty.
func parseFrontmatter(src string) (frontmatter, string) {
var fm frontmatter
if !strings.HasPrefix(src, "---\n") {
return fm, src
}
rest := src[len("---\n"):]
end := strings.Index(rest, "\n---\n")
if end < 0 {
// Opening fence with no closing fence — treat as bodyless content.
return fm, src
}
block := rest[:end]
body := rest[end+len("\n---\n"):]
for _, line := range strings.Split(block, "\n") {
// An indented line is a continuation (a YAML block-list item, a
// wrapped value) never a top-level key, regardless of whether it
// happens to contain a colon — " - 'Chapter 3: The Reckoning'" is
// not a key named "- 'Chapter 3" (hyperguild#128). Checked before
// Cut so a colon inside one never reaches the key/value path.
if line != "" && (line[0] == ' ' || line[0] == '\t') {
fm.lines = append(fm.lines, fmLine{raw: line})
continue
}
key, val, ok := strings.Cut(line, ":")
key = strings.TrimSpace(key)
if !ok || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
fm.lines = append(fm.lines, fmLine{raw: line})
continue
}
fm.lines = append(fm.lines, fmLine{key: key, value: strings.TrimSpace(val)})
}
return fm, body
}
// get returns the value for key, or "" if absent.
func (f *frontmatter) get(key string) string {
for _, l := range f.lines {
if l.key == key {
return l.value
}
}
return ""
}
// set overrides the value for an existing key in place, or appends a new
// `key: value` line when the key is absent.
func (f *frontmatter) set(key, value string) {
for i := range f.lines {
if f.lines[i].key == key {
f.lines[i].value = value
return
}
}
f.lines = append(f.lines, fmLine{key: key, value: value})
}
// render serialises the frontmatter back into a `---`-fenced block. An
// empty frontmatter renders to the empty string so bodies without a
// header stay header-less.
func (f *frontmatter) render() string {
if len(f.lines) == 0 {
return ""
}
var b strings.Builder
b.WriteString("---\n")
for _, l := range f.lines {
switch {
case l.key == "":
b.WriteString(l.raw)
case l.value == "":
// A key with nothing after the colon (the YAML block-list
// shape: "aliases:" followed by indented "- item" lines on
// their own raw lines) must stay bare — "key: " with a
// trailing space is a different byte sequence than the
// original, and this type's whole purpose (see the package
// doc) is a byte-preserving round-trip.
b.WriteString(l.key)
b.WriteByte(':')
default:
b.WriteString(l.key)
b.WriteString(": ")
b.WriteString(l.value)
}
b.WriteByte('\n')
}
b.WriteString("---\n")
return b.String()
}