Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 8 additions & 5 deletions internal/core/frontmatter/frontmatter.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions internal/core/frontmatter/frontmatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
29 changes: 8 additions & 21 deletions internal/core/lint/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(`\[[^\]]*\]\(([^)]+)\)`)
Expand Down Expand Up @@ -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
}
Expand Down
Loading