Files
hyperguild/ingestion/internal/frontmatter/frontmatter.go
T
mathiasandClaude Opus 5 7c9293f4ed feat(ingestion): lint and repair unparseable note frontmatter (#90)
Hand-authored and promoted notes skip pipeline.BuildPages, so nothing
quotes their scalars. A title carrying an unescaped ": " yields a block
yaml.Unmarshal rejects, and every consumer that reads frontmatter drops
the note silently — 18 files repo-wide today, up from the 8 in #90.

frontmatter.Lint reports it; frontmatter.Fix quotes only what quoting can
rescue and refuses to return a document that still fails to parse, so an
unfixable file comes back byte-identical rather than half-repaired. Scan
walks a tree and reports an unreadable file as a finding instead of
aborting, so one poisoned note cannot hide the rest.

Sequence items are deliberately left alone: "- foo: bar" is valid YAML
that silently becomes a map, which is a type corruption rather than a
parse failure, and repairing it would rewrite notes that currently load.

    task lint:frontmatter          # report
    task lint:frontmatter FIX=1    # repair in place

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-18 00:07:27 +02:00

142 lines
4.5 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 must be quoted to parse as the
// string it plainly is. Only the colon case is handled: it is the one this
// package exists for, and every other special case risks rewriting a value
// that was deliberately structured (a nested mapping, an anchor, a number).
func needsQuoting(value string) bool {
if strings.HasPrefix(value, "'") || strings.HasPrefix(value, `"`) {
return false
}
if strings.HasPrefix(value, "|") || strings.HasPrefix(value, ">") {
return false // block scalar; its content is not parsed as YAML
}
if strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") {
return false // flow collection; quoting would silently make it a string
}
return strings.Contains(value, ": ") || strings.HasSuffix(value, ":")
}
// 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 the frontmatter block and everything from the closing fence
// onward (the closing fence itself excluded, its trailing newline included).
func split(doc []byte) (block, rest []byte, ok bool) {
s := string(doc)
if !strings.HasPrefix(s, fence+"\n") {
return nil, nil, false
}
body := s[len(fence)+1:]
idx := strings.Index(body, "\n"+fence)
if idx < 0 {
return nil, nil, false
}
return []byte(body[:idx+1]), []byte(body[idx+1+len(fence):]), true
}