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
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
// ingestion/internal/frontmatter/scan_test.go
|
||||
package frontmatter
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func writeTree(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for name, content := range files {
|
||||
path := filepath.Join(root, name)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const (
|
||||
brokenDoc = "---\ntitle: Broken: title here\nwing: homelab\n---\n\nbody\n"
|
||||
validDoc = "---\ntitle: 'Fine: quoted'\n---\n\nbody\n"
|
||||
)
|
||||
|
||||
func TestScan_ReportsOnlyBrokenFiles(t *testing.T) {
|
||||
root := writeTree(t, map[string]string{
|
||||
"wiki/a/broken.md": brokenDoc,
|
||||
"wiki/a/valid.md": validDoc,
|
||||
"wiki/b/plain.md": "# no frontmatter\n\nbody: with colon\n",
|
||||
"wiki/a/notes.txt": brokenDoc, // not markdown — must be ignored
|
||||
})
|
||||
|
||||
found, err := Scan(root, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, found, 1)
|
||||
assert.Equal(t, "wiki/a/broken.md", found[0].Path)
|
||||
assert.False(t, found[0].Repaired)
|
||||
assert.Error(t, found[0].Err)
|
||||
|
||||
// Dry run must not touch the file.
|
||||
got, err := os.ReadFile(filepath.Join(root, "wiki/a/broken.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, brokenDoc, string(got))
|
||||
}
|
||||
|
||||
func TestScan_FixWritesRepairedFilesAndLeavesTheRestAlone(t *testing.T) {
|
||||
root := writeTree(t, map[string]string{
|
||||
"wiki/a/broken.md": brokenDoc,
|
||||
"wiki/a/valid.md": validDoc,
|
||||
})
|
||||
|
||||
found, err := Scan(root, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, found, 1)
|
||||
assert.True(t, found[0].Repaired)
|
||||
assert.NoError(t, found[0].Err)
|
||||
|
||||
repaired, err := os.ReadFile(filepath.Join(root, "wiki/a/broken.md"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, Lint(repaired), "the repaired file must parse")
|
||||
assert.Contains(t, string(repaired), "title: 'Broken: title here'")
|
||||
assert.Contains(t, string(repaired), "wing: homelab", "untouched keys must survive")
|
||||
|
||||
untouched, err := os.ReadFile(filepath.Join(root, "wiki/a/valid.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, validDoc, string(untouched))
|
||||
|
||||
// Second pass finds nothing — the repair is idempotent and complete.
|
||||
again, err := Scan(root, true)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, again)
|
||||
}
|
||||
|
||||
func TestScan_ReportsUnfixableWithoutWriting(t *testing.T) {
|
||||
unfixable := "---\ntitle: fine\n\tbad: tab-indented\n - stray\n---\n\nbody\n"
|
||||
root := writeTree(t, map[string]string{"wiki/x.md": unfixable})
|
||||
|
||||
found, err := Scan(root, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, found, 1)
|
||||
assert.False(t, found[0].Repaired)
|
||||
assert.Error(t, found[0].Err)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(root, "wiki/x.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unfixable, string(got), "an unfixable file must be left exactly as it was")
|
||||
}
|
||||
|
||||
func TestScan_ReportsUndecodableFilesInsteadOfFailingTheRun(t *testing.T) {
|
||||
root := writeTree(t, map[string]string{"wiki/ok.md": validDoc})
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "wiki/bad-bytes.md"),
|
||||
[]byte{'-', '-', '-', '\n', 0xff, 0xfe, '\n', '-', '-', '-', '\n'}, 0o644))
|
||||
|
||||
found, err := Scan(root, false)
|
||||
require.NoError(t, err, "one unreadable file must not abort the whole scan")
|
||||
require.Len(t, found, 1)
|
||||
assert.Equal(t, "wiki/bad-bytes.md", found[0].Path)
|
||||
}
|
||||
Reference in New Issue
Block a user