Files
hyperguild/ingestion/internal/frontmatter/frontmatter_test.go
T
mathiasandClaude Sonnet 5 a75fd95ab8
CI / Lint / Test / Vet (pull_request) Successful in 34s
CI / Mirror to GitHub (pull_request) Skipped
fix(frontmatter): consolidate 3 independent fence-parsers into one
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>
2026-09-23 14:57:34 +02:00

226 lines
7.7 KiB
Go

// ingestion/internal/frontmatter/frontmatter_test.go
package frontmatter
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestLint(t *testing.T) {
tests := []struct {
name string
doc string
wantErr bool
}{
{
name: "no frontmatter block is not an error",
doc: "# Just a heading\n\nSome body text.\n",
},
{
name: "quoted title parses",
doc: "---\ntitle: 'Headless dispatch runs: Bash is denied'\n---\n\nbody\n",
},
{
name: "plain title parses",
doc: "---\ntitle: A simple title\nwing: homelab\n---\n\nbody\n",
},
{
name: "unquoted colon in title does not parse",
doc: "---\ntitle: Headless dispatch runs: Bash is denied — use gitea MCP\nwing: homelab\n---\n\nbody\n",
wantErr: true,
},
{
// Known gap, deliberately not fixed here: this parses, but the
// item becomes map[string]any{"Headless dispatch": "denied"}
// rather than a string. It is a silent type corruption, not a
// parse failure, so repairing it would rewrite documents that
// currently load — out of scope for a lint about broken YAML.
name: "unquoted colon in a list item parses as a mapping, not an error",
doc: "---\ntitle: Fine\naliases:\n - Headless dispatch: denied\n---\n\nbody\n",
},
{
name: "unterminated frontmatter is not treated as frontmatter",
doc: "---\ntitle: no closing fence\n\nbody\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Lint([]byte(tt.doc))
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestFix(t *testing.T) {
tests := []struct {
name string
doc string
wantChanged bool
wantKey string
wantValue string
}{
{
name: "quotes an unquoted title containing a colon",
doc: "---\ntitle: Headless dispatch runs: Bash is denied — use gitea MCP\nwing: homelab\n---\n\nbody\n",
wantChanged: true,
wantKey: "title",
wantValue: "Headless dispatch runs: Bash is denied — use gitea MCP",
},
{
// Parses already (see the Lint table), so Fix must leave it alone
// rather than rewrite a document that loads.
name: "leaves a colon-bearing list item alone because it already parses",
doc: "---\ntitle: Fine\naliases:\n - Headless dispatch: denied\n---\n\nbody\n",
wantChanged: false,
wantKey: "title",
wantValue: "Fine",
},
{
name: "escapes an embedded single quote when quoting",
doc: "---\ntitle: Don't do this: it breaks\n---\n\nbody\n",
wantChanged: true,
wantKey: "title",
wantValue: "Don't do this: it breaks",
},
{
// The value opens with a double quote and then keeps going after
// the closing one, so YAML reads it as a quoted scalar followed by
// junk. Treating "starts with a quote" as "already quoted" leaves
// this broken forever.
name: "quotes a value that only looks quoted",
doc: "---\ntitle: \"Evaluation Driven Generation\" — a TDD-style scaffold\ntags: [a, b]\n---\n\nbody\n",
wantChanged: true,
wantKey: "title",
wantValue: "\"Evaluation Driven Generation\" — a TDD-style scaffold",
},
{
name: "leaves an already-valid document untouched",
doc: "---\ntitle: 'Already quoted: fine'\nwing: homelab\n---\n\nbody\n",
wantChanged: false,
wantKey: "title",
wantValue: "Already quoted: fine",
},
{
name: "leaves a document with no frontmatter untouched",
doc: "# Heading\n\nbody: with a colon\n",
wantChanged: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, changed, err := Fix([]byte(tt.doc))
require.NoError(t, err)
assert.Equal(t, tt.wantChanged, changed)
if !tt.wantChanged {
assert.Equal(t, tt.doc, string(got), "unchanged docs must be returned byte-identical")
}
// Whatever came back must parse — that is the whole point.
require.NoError(t, Lint(got), "Fix must not return a document that still fails to parse")
if tt.wantKey == "" {
return
}
block, ok := block(got)
require.True(t, ok)
var m map[string]any
require.NoError(t, yaml.Unmarshal(block, &m))
assert.Equal(t, tt.wantValue, m[tt.wantKey], "the value must survive quoting unchanged")
})
}
}
func TestFix_LeavesStructuredValuesStructured(t *testing.T) {
// Only the broken scalar may be touched. A flow sequence on a neighbouring
// line still has to load as a sequence afterwards, not as a string.
doc := "---\ntitle: Broken: here\ntags: [prompting, llm]\ncount: 3\n---\n\nbody\n"
got, changed, err := Fix([]byte(doc))
require.NoError(t, err)
require.True(t, changed)
b, ok := block(got)
require.True(t, ok)
var m map[string]any
require.NoError(t, yaml.Unmarshal(b, &m))
assert.Equal(t, "Broken: here", m["title"])
assert.Equal(t, []any{"prompting", "llm"}, m["tags"])
assert.Equal(t, 3, m["count"])
}
func TestFix_PreservesBodyExactly(t *testing.T) {
body := "\nThe body has: colons, 'quotes', and\n---\na horizontal rule.\n"
doc := "---\ntitle: Broken: title\n---" + body
got, changed, err := Fix([]byte(doc))
require.NoError(t, err)
require.True(t, changed)
assert.True(t, len(got) > 0)
_, rest, ok := Split([]byte(doc))
require.True(t, ok)
_, gotRest, ok := Split(got)
require.True(t, ok)
assert.Equal(t, string(rest), string(gotRest), "the body must not be touched")
}
func TestFix_RefusesToReturnAnUnparseableResult(t *testing.T) {
// A frontmatter block that quoting cannot rescue: the structure itself is
// broken, not a scalar. Fix must return the original and an error rather
// than a half-repaired document.
doc := "---\ntitle: fine\n\tbad: tab-indented mapping\n - stray list item\n---\n\nbody\n"
got, changed, err := Fix([]byte(doc))
require.Error(t, err)
assert.False(t, changed)
assert.Equal(t, doc, string(got), "an unfixable document must come back byte-identical")
}
// TestSplit_NormalizesCRLFBeforeFenceDetection is hyperguild#138: a note
// whose fence lines use CRLF ("---\r\n") failed strings.HasPrefix(doc,
// "---\n") on the precondition read in internal/mcp's independent
// reimplementation -- encoding alone, no adversarial input, silently
// defeated fence detection there before this consolidation. Split must
// never repeat that.
func TestSplit_NormalizesCRLFBeforeFenceDetection(t *testing.T) {
doc := "---\r\ntitle: 'n'\r\nhalf_life: durable\r\n---\r\n\r\nbody\r\n"
block, rest, ok := Split([]byte(doc))
require.True(t, ok)
assert.Contains(t, string(block), "half_life: durable")
assert.Contains(t, string(rest), "body")
}
// TestSplit_StripsALeadingUTF8BOM is the same mechanism one layer earlier:
// a leading BOM (Notepad's default "UTF-8" save, PowerShell's pre-v6
// Out-File/Set-Content) defeats the same HasPrefix check before CRLF
// normalization even applies.
func TestSplit_StripsALeadingUTF8BOM(t *testing.T) {
doc := "\xef\xbb\xbf---\ntitle: 'n'\n---\n\nbody\n"
block, rest, ok := Split([]byte(doc))
require.True(t, ok)
assert.Contains(t, string(block), "title: 'n'")
assert.Contains(t, string(rest), "body")
}
// TestSplit_RequiresANewlineAfterTheClosingFence closes the looser match
// internal/api's independent reimplementation carried (strings.Index(body,
// "\n"+fence) with no trailing-newline requirement): a document ending in
// "\n---extra" with no newline separating the fence from what follows it is
// not a valid closing fence and must not be treated as one.
func TestSplit_RequiresANewlineAfterTheClosingFence(t *testing.T) {
doc := "---\ntitle: 'n'\n---extra-not-a-real-close"
_, _, ok := Split([]byte(doc))
assert.False(t, ok, "a fence line not followed by a newline is not a valid closing fence")
}