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>
226 lines
7.7 KiB
Go
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")
|
|
}
|