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>
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||
)
|
||||
|
||||
@@ -331,27 +332,20 @@ func checkClaimEnvelope(brainDir, path, oldStr, newStr string) error {
|
||||
// frontmatterBlock returns the raw text between doc's opening and closing
|
||||
// `---` fences, or ok=false if doc has no (properly closed) fence.
|
||||
func frontmatterBlock(doc string) (block string, ok bool) {
|
||||
// Round 4 found a note with CRLF fence lines ("---\r\n") silently
|
||||
// defeats HasPrefix(doc, "---\n") on the precondition read — not an
|
||||
// adversarial old_str/new_str, just an encoding difference — which
|
||||
// makes checkClaimEnvelope no-op entirely. Round 5 found the same
|
||||
// mechanism one layer earlier: a leading UTF-8 BOM (a live, plausible
|
||||
// byte sequence — Notepad's default "UTF-8" save and PowerShell's
|
||||
// pre-v6 Out-File/Set-Content both emit one) defeats the same prefix
|
||||
// check before CRLF normalization even applies. Strip both, here, so
|
||||
// every caller (hadFence/hasFence detection and frontmatterScalars)
|
||||
// agrees regardless of encoding.
|
||||
doc = strings.TrimPrefix(doc, "\xef\xbb\xbf")
|
||||
doc = strings.ReplaceAll(doc, "\r\n", "\n")
|
||||
if !strings.HasPrefix(doc, "---\n") {
|
||||
// hyperguild#138: fence detection itself is frontmatter.Split, shared
|
||||
// with internal/api and internal/frontmatter — the CRLF/BOM guards that
|
||||
// used to live here (added piecemeal across rounds 4 and 5 of #123's
|
||||
// review) are now the shared parser's job, so every caller inherits a
|
||||
// fix instead of needing bug-for-bug parity edits.
|
||||
b, _, ok := frontmatter.Split([]byte(doc))
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rest := doc[len("---\n"):]
|
||||
end := strings.Index(rest, "\n---\n")
|
||||
if end < 0 {
|
||||
return "", false
|
||||
}
|
||||
return rest[:end], true
|
||||
// Split's block keeps the "\n" immediately before the closing fence
|
||||
// (by design — see its own doc comment); this function never included
|
||||
// it. Split guarantees ok=true only when "\n---\n" matched, so this is
|
||||
// always exactly one byte, never ambiguous.
|
||||
return strings.TrimSuffix(string(b), "\n"), true
|
||||
}
|
||||
|
||||
// frontmatterScalars extracts top-level "key: value" scalar pairs from
|
||||
|
||||
Reference in New Issue
Block a user