hyperguild#138: internal/mcp/tools_notes.go's frontmatterBlock, internal/api/frontmatter.go's parseFrontmatter, and internal/frontmatter/frontmatter.go's split each independently detected a note's --- fence, converging on the same shape through parallel bug-fixing across today's #123/#126/#128 sagas. Each carried a subtly different gap: internal/frontmatter's closing-fence match had no trailing-newline requirement (a document ending "\n---extra" was treated as validly closed); internal/mcp needed CRLF/BOM guards bolted on across two separate review rounds after both silently defeated its own HasPrefix check; internal/api never gained either fix, so the same encoding-dependent bypass was live there too, just never triggered by a test. internal/frontmatter is the leaf package (imports nothing internal), so it becomes the canonical fence detector: exported Split(doc []byte) (block, rest []byte, ok bool), BOM-stripped and CRLF-normalized, with the stricter trailing-newline-required close. internal/api and internal/mcp now call it instead of re-detecting the fence themselves, keeping only what's genuinely package-specific (api's ordered fmLine round-trip shape, mcp's map + block- scalar-fold logic). Found two real regressions while wiring the callers, caught by each package's own pre-existing byte-exactness tests going red immediately: Split's block/rest boundary sits one byte differently than either caller's prior contract (it keeps the fence-adjacent newline on each side, by design for Fix's own reconstruction) — both callers now TrimSuffix/TrimPrefix exactly that one byte to restore their exact prior semantics. Neither regression would have been visible without those tests already existing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
130 lines
4.5 KiB
Go
130 lines
4.5 KiB
Go
package api
|
|
|
|
import (
|
|
"strings"
|
|
|
|
// Aliased: this package's own frontmatter type (below) would collide
|
|
// with the imported package's name otherwise.
|
|
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
|
)
|
|
|
|
// 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.
|
|
//
|
|
// Fence detection itself is frontmatter.Split (hyperguild#138) — this
|
|
// package's own contribution is purely the ordered, line-preserving fmLine
|
|
// shape below, which a shared parser has no business owning since it's
|
|
// specific to how brain_update does a surgical, byte-round-trip edit.
|
|
func parseFrontmatter(src string) (frontmatter, string) {
|
|
var fm frontmatter
|
|
blockBytes, restBytes, ok := sharedfm.Split([]byte(src))
|
|
if !ok {
|
|
return fm, src
|
|
}
|
|
// sharedfm.Split's block/rest boundary sits one byte earlier than this
|
|
// package's always has: block keeps the "\n" immediately before the
|
|
// closing fence, and rest keeps the "\n" immediately after it (both by
|
|
// design, for Fix's own reconstruction). This package's block/body
|
|
// never included either — TrimSuffix/TrimPrefix restore that exact
|
|
// boundary. Split guarantees ok=true only when "\n---\n" matched, so
|
|
// both trims are always exactly one byte, never ambiguous.
|
|
block := strings.TrimSuffix(string(blockBytes), "\n")
|
|
body := strings.TrimPrefix(string(restBytes), "\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()
|
|
}
|