From 27403bdc516ff72964b77e42aa8126501195cda1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:07:20 +0100 Subject: [PATCH 1/3] fix: tolerate trailing whitespace on frontmatter delimiters (iss-69) Fields compared a delimiter line against exactly '---', so a closing '--- ' (trailing space or tab) was not recognised as the close and every body line after it leaked in as a frontmatter field. Trim trailing spaces/tabs (not just CR) before both the opening and closing delimiter comparisons. Assisted-by: Claude:claude-opus-4-8 --- internal/core/frontmatter/frontmatter.go | 7 +++++-- internal/core/frontmatter/frontmatter_test.go | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/internal/core/frontmatter/frontmatter.go b/internal/core/frontmatter/frontmatter.go index 6ff8ed82..9d83d983 100644 --- a/internal/core/frontmatter/frontmatter.go +++ b/internal/core/frontmatter/frontmatter.go @@ -29,12 +29,15 @@ type Field struct { // (or is empty) yields no fields. func Fields(lines []string) map[string]Field { fields := map[string]Field{} - if len(lines) == 0 || strings.TrimRight(lines[0], "\r") != "---" { + // A delimiter line may carry trailing whitespace ("--- "); trim spaces/tabs/CR + // before comparing, so a trailing-space closing delimiter is still seen as the + // close and body lines after it do not leak in as fields. + if len(lines) == 0 || strings.TrimRight(lines[0], " \t\r") != "---" { return fields } for i := 1; i < len(lines); i++ { line := strings.TrimRight(lines[i], "\r") - if line == "---" { + if strings.TrimRight(line, " \t") == "---" { break } m := keyRe.FindStringSubmatch(line) diff --git a/internal/core/frontmatter/frontmatter_test.go b/internal/core/frontmatter/frontmatter_test.go index ea023156..d751c7c9 100644 --- a/internal/core/frontmatter/frontmatter_test.go +++ b/internal/core/frontmatter/frontmatter_test.go @@ -70,3 +70,23 @@ func TestIsNull(t *testing.T) { } } } + +// TestFieldsToleratesDelimiterTrailingSpace (iss-69 C5) proves a delimiter line +// with trailing whitespace ("--- ") is still recognised, at both ends. Otherwise +// a trailing-space closing delimiter is not seen as the close and body lines +// after it leak in as frontmatter fields. +func TestFieldsToleratesDelimiterTrailingSpace(t *testing.T) { + lines := []string{ + "--- ", // opening delimiter with a trailing space + "id: itd-9", + "---\t", // closing delimiter with a trailing tab + "status: draft", // body — must NOT be read as a field + } + fields := Fields(lines) + if got := fields["id"]; got.Value != "itd-9" { + t.Fatalf("id must parse under a trailing-space opening delimiter, got %+v", got) + } + if _, ok := fields["status"]; ok { + t.Fatal("a body line after a trailing-whitespace closing delimiter must not leak in as a field") + } +} From 162440982df6f3151ba8db29dc9591ff90a067be Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:09:09 +0100 Subject: [PATCH 2/3] refactor: route lint frontmatter scan through internal/core/frontmatter (iss-69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lint carried its own copy of the frontmatter line-scanner (frontmatterFields + fmKeyRe) — logic- and regex-identical to internal/core/frontmatter.Fields, including the same trailing-whitespace delimiter bug just fixed. frontmatterFields is now a thin adapter over the canonical scanner (keeping lint's local fmField shape, so no call site changes), so the primitive lives in ONE place and lint inherits the delimiter fix. Behaviour-preserving; lint tests + record-lint green. Memory's parseFrontmatter is deliberately NOT consolidated here: it is a full nested-YAML parser (map[string]any with nested maps like source:), a genuinely different primitive from the flat top-level scanner — folding it in would require widening frontmatter to nested parsing (a new capability, not a refactor). Assisted-by: Claude:claude-opus-4-8 --- CHANGELOG.md | 6 +++++ internal/core/frontmatter/frontmatter.go | 6 ++--- internal/core/lint/lint.go | 29 +++++++----------------- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e35567fc..1f2dc136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,12 @@ called out in a **Breaking** section. positional criteria. The intent template's Audit Notes placeholder is cleared when the first review block lands, so a populated audit carries no stale "Empty" claim (iss-67). +- The frontmatter scanner (`internal/core/frontmatter`, used by `abcd intent`/ + `spec` and record-lint) now tolerates a trailing space or tab on the `---` + delimiters; previously a `--- ` closing delimiter went unrecognised and every + body line after it was misread as a frontmatter field. `record-lint` no longer + keeps a divergent copy of the scanner — it routes through the canonical one and + inherits this fix (iss-69). ### Security diff --git a/internal/core/frontmatter/frontmatter.go b/internal/core/frontmatter/frontmatter.go index 9d83d983..5094e650 100644 --- a/internal/core/frontmatter/frontmatter.go +++ b/internal/core/frontmatter/frontmatter.go @@ -1,9 +1,9 @@ // Package frontmatter is abcd's shared markdown-frontmatter line scanner. It is // deliberately a line scanner, not a YAML parser: it reads only the top-level // keys of the leading `---`…`---` block, first key wins, and pulls in zero -// dependencies. It exists so the native record stores (internal/core/spec, -// internal/core/intent) share ONE copy of this primitive rather than each -// keeping a private replica. +// dependencies. It exists so its consumers (internal/core/spec, +// internal/core/intent, and record-lint's top-level frontmatter checks) share ONE +// copy of this primitive rather than each keeping a private replica. // // It is transport-agnostic: no stdout, no os.Exit, no filesystem access — the // caller supplies the file's lines and decides what the fields mean. diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index b4c26e4f..e50cf410 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -12,6 +12,8 @@ import ( "sort" "strconv" "strings" + + "github.com/REPPL/abcd-cli/internal/core/frontmatter" ) // Finding is one lint violation. File is repo-relative; Line is 1-based (0 when @@ -30,8 +32,6 @@ const ( ) var ( - // Top-level YAML frontmatter key (column 0, no indentation). - fmKeyRe = regexp.MustCompile(`^([A-Za-z0-9_]+):(.*)$`) // Inline markdown link: [text](target). Also matches the link part of an // image (![alt](src)), which resolves the same way. linkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)]+)\)`) @@ -1336,27 +1336,14 @@ type fmField struct { } // frontmatterFields returns the top-level keys of the leading YAML frontmatter -// (the block between the first two `---` lines). It is a line scanner, not a -// YAML parser: nested keys and list items are ignored, which is sufficient for -// the top-level checks here. +// (the block between the first two `---` lines) in lint's local fmField shape. It +// adapts the canonical scanner in internal/core/frontmatter, so the frontmatter +// line-scanner (and its delimiter handling) lives in ONE place, not a divergent +// copy here. func frontmatterFields(lines []string) map[string]fmField { fields := map[string]fmField{} - if len(lines) == 0 || strings.TrimRight(lines[0], "\r") != "---" { - return fields - } - for i := 1; i < len(lines); i++ { - line := strings.TrimRight(lines[i], "\r") - if line == "---" { - break - } - m := fmKeyRe.FindStringSubmatch(line) - if m == nil { - continue - } - key := m[1] - if _, exists := fields[key]; !exists { - fields[key] = fmField{value: strings.TrimSpace(m[2]), line: i + 1} - } + for key, f := range frontmatter.Fields(lines) { + fields[key] = fmField{value: f.Value, line: f.Line} } return fields } From ab95306cc8c0a0b5b6cba09b486c7527eca33914 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:11:55 +0100 Subject: [PATCH 3/3] chore: resolve iss-69 in the ledger Assisted-by: Claude:claude-opus-4-8 --- .../iss-69-frontmatter-canonical-consolidation.md | 1 + 1 file changed, 1 insertion(+) rename .abcd/work/issues/{open => resolved}/iss-69-frontmatter-canonical-consolidation.md (70%) diff --git a/.abcd/work/issues/open/iss-69-frontmatter-canonical-consolidation.md b/.abcd/work/issues/resolved/iss-69-frontmatter-canonical-consolidation.md similarity index 70% rename from .abcd/work/issues/open/iss-69-frontmatter-canonical-consolidation.md rename to .abcd/work/issues/resolved/iss-69-frontmatter-canonical-consolidation.md index c55d74ff..75c9a2e9 100644 --- a/.abcd/work/issues/open/iss-69-frontmatter-canonical-consolidation.md +++ b/.abcd/work/issues/resolved/iss-69-frontmatter-canonical-consolidation.md @@ -7,6 +7,7 @@ category: "tech-debt" source: "agent-finding" found_during: "clean-slate-sweep" found_at: "internal/core/frontmatter/frontmatter.go" +resolution: "frontmatter delimiter trailing-whitespace bug fixed (C5); lint's duplicate scanner consolidated onto internal/core/frontmatter via a thin adapter (inherits the fix). seed1b (memory nested-YAML parser) deferred — a genuinely different primitive, not a behaviour-preserving refactor. Also flagged: intent.go's writer-path scanner is a future consolidation candidate. ruthless SHIP." --- frontmatter canonical consolidation + delimiter bug: internal/core/lint (frontmatterFields) and internal/core/memory (parseFrontmatter/splitFileFrontmatter) each carry their own frontmatter scanner instead of internal/core/frontmatter — migrate both, behaviour-preserving, separate refactor commit (seed1); note memory needs nested-map values (source:) the flat scanner lacks — widen or scope. Bug: the canonical scanner closing-delimiter check compares the CR-trimmed line to exactly triple-dash, so a closing triple-dash with a trailing space/tab is not recognised and body lines leak as fields — TrimRight before both delimiter checks (frontmatter.go:37, C5). Corpus: the two lint/memory copies + the delimiter case. \ No newline at end of file