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>
155 lines
5.3 KiB
Go
155 lines
5.3 KiB
Go
// Package frontmatter lints and repairs the YAML frontmatter block of a
|
|
// markdown note.
|
|
//
|
|
// Notes written directly — hand-authored, or promoted from brain/raw/ — do not
|
|
// pass through pipeline.BuildPages, so nothing quotes their scalars. A title
|
|
// containing an unescaped ": " then produces a block that yaml.Unmarshal
|
|
// rejects with "mapping values are not allowed here", and every consumer that
|
|
// reads frontmatter silently skips the note.
|
|
//
|
|
// Fix is deliberately conservative: it only adds quoting, never reorders or
|
|
// rewrites, and it refuses to return a document that still does not parse.
|
|
package frontmatter
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const fence = "---"
|
|
|
|
// Lint reports whether doc's frontmatter block parses as YAML.
|
|
//
|
|
// A document with no frontmatter block, or with an unterminated one, is not an
|
|
// error: there is nothing claiming to be YAML.
|
|
func Lint(doc []byte) error {
|
|
b, ok := block(doc)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var v any
|
|
if err := yaml.Unmarshal(b, &v); err != nil {
|
|
return fmt.Errorf("frontmatter: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Fix quotes unquoted scalars whose value breaks YAML parsing and returns the
|
|
// repaired document.
|
|
//
|
|
// It returns (doc, false, nil) when the document already parses, and
|
|
// (doc, false, err) when quoting cannot rescue it — an unfixable document is
|
|
// always returned byte-identical, never half-repaired.
|
|
func Fix(doc []byte) ([]byte, bool, error) {
|
|
if err := Lint(doc); err == nil {
|
|
return doc, false, nil
|
|
}
|
|
|
|
b, ok := block(doc)
|
|
if !ok {
|
|
// Lint only fails when there is a block, so this is unreachable in
|
|
// practice; return the original rather than guess.
|
|
return doc, false, nil
|
|
}
|
|
|
|
fixed := quoteScalars(b)
|
|
if bytes.Equal(fixed, b) {
|
|
return doc, false, fmt.Errorf("frontmatter: not repairable by quoting: %w", Lint(doc))
|
|
}
|
|
|
|
var v any
|
|
if err := yaml.Unmarshal(fixed, &v); err != nil {
|
|
return doc, false, fmt.Errorf("frontmatter: quoting did not repair the block: %w", err)
|
|
}
|
|
|
|
_, rest, _ := Split(doc)
|
|
out := []byte(fence + "\n")
|
|
out = append(out, fixed...)
|
|
out = append(out, []byte(fence)...)
|
|
out = append(out, rest...)
|
|
return out, true, nil
|
|
}
|
|
|
|
// mappingLine matches "key: value" at any indentation, capturing the prefix so
|
|
// it can be re-emitted untouched.
|
|
//
|
|
// Sequence items ("- value") are deliberately not handled: "- foo: bar" is
|
|
// valid YAML, so a document containing one does not reach Fix in the first
|
|
// place. See the Lint table for why that gap is left open.
|
|
var mappingLine = regexp.MustCompile(`^(\s*[A-Za-z_][\w.-]*:[ \t]+)(\S.*)$`)
|
|
|
|
// quoteScalars single-quotes every unquoted scalar value in the block that
|
|
// contains a colon. Values that are already quoted, or that carry YAML syntax
|
|
// this package has no business rewriting, are left alone.
|
|
func quoteScalars(block []byte) []byte {
|
|
lines := strings.Split(string(block), "\n")
|
|
for i, line := range lines {
|
|
m := mappingLine.FindStringSubmatch(line)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
prefix, value := m[1], m[2]
|
|
if !needsQuoting(value) {
|
|
continue
|
|
}
|
|
lines[i] = prefix + "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
|
}
|
|
return []byte(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
// needsQuoting reports whether a scalar value has to be quoted to load as the
|
|
// string it plainly is.
|
|
//
|
|
// The test is YAML's own parser rather than a pattern: ask whether the value
|
|
// stands alone as the right-hand side of a mapping. Heuristics get this wrong
|
|
// in both directions — `title: "Foo" — bar` opens with a quote but is not
|
|
// quoted, and a value may contain a colon inside quotes and be perfectly fine.
|
|
//
|
|
// Anything that already parses is left alone, whatever it parses into — a flow
|
|
// collection, a number, a bool. It is not broken, and quoting would change its
|
|
// type.
|
|
func needsQuoting(value string) bool {
|
|
if strings.HasPrefix(value, "|") || strings.HasPrefix(value, ">") {
|
|
return false // block scalar; its content is not parsed as YAML
|
|
}
|
|
var probe map[string]any
|
|
return yaml.Unmarshal([]byte("v: "+value+"\n"), &probe) != nil
|
|
}
|
|
|
|
// block returns the raw YAML between the opening and closing fences.
|
|
func block(doc []byte) ([]byte, bool) {
|
|
b, _, ok := Split(doc)
|
|
return b, ok
|
|
}
|
|
|
|
// Split returns doc's frontmatter block and everything from the closing
|
|
// fence onward (the closing fence itself excluded, its trailing newline
|
|
// included), or ok=false if doc has no properly-fenced frontmatter.
|
|
//
|
|
// The canonical fence detector for this codebase (hyperguild#138): three
|
|
// independent reimplementations of this same logic each grew their own gap
|
|
// through separate bug-fixing before being consolidated here —
|
|
// internal/mcp's needed a CRLF/BOM guard added after both silently defeated
|
|
// its own HasPrefix check, and internal/api's closing-fence match had no
|
|
// trailing-newline requirement, so a document ending in "\n---extra" with no
|
|
// real newline after the fence was treated as validly closed. Both classes
|
|
// are closed here, once, for every caller.
|
|
func Split(doc []byte) (block, rest []byte, ok bool) {
|
|
s := strings.TrimPrefix(string(doc), "\xef\xbb\xbf")
|
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
|
if !strings.HasPrefix(s, fence+"\n") {
|
|
return nil, nil, false
|
|
}
|
|
body := s[len(fence)+1:]
|
|
|
|
idx := strings.Index(body, "\n"+fence+"\n")
|
|
if idx < 0 {
|
|
return nil, nil, false
|
|
}
|
|
return []byte(body[:idx+1]), []byte(body[idx+1+len(fence):]), true
|
|
}
|