From b1091c645d23377d6c59f3b809c24d3d79e32792 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 11 Oct 2026 23:50:05 +0000 Subject: [PATCH] feat(acceptance): one Go reading of a body's acceptance criteria (PLAN-3598 slice (a)) `pad item context` (PLAN-3598) returns a governing spec's acceptance criteria as data. Until now the only readings were prose: the verify and decompose playbooks tell the agent to find the section and skip skeleton lines. internal/acceptance.Parse implements that same rule: the first "Acceptance criteria" heading at any level, up to the next heading of the same or higher level; "- AC-N: text" bullets, with indented continuation lines joined; angle-bracket or bare-ellipsis text reported as a placeholder, not a criterion; headings and AC lines inside code fences ignored. The spec template's own skeleton is pinned to read as two placeholders and no criteria, so a fresh spec never claims real ones. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS --- internal/acceptance/acceptance.go | 135 +++++++++++++++++++ internal/acceptance/acceptance_test.go | 115 ++++++++++++++++ internal/collections/spec_acceptance_test.go | 24 ++++ 3 files changed, 274 insertions(+) create mode 100644 internal/acceptance/acceptance.go create mode 100644 internal/acceptance/acceptance_test.go create mode 100644 internal/collections/spec_acceptance_test.go diff --git a/internal/acceptance/acceptance.go b/internal/acceptance/acceptance.go new file mode 100644 index 000000000..dd076b1f8 --- /dev/null +++ b/internal/acceptance/acceptance.go @@ -0,0 +1,135 @@ +// Package acceptance reads the "Acceptance criteria" section of an item body +// (PLAN-3598 slice (a)). It is the one Go reading of the rule the spec +// template writes and the verify and decompose playbooks describe in prose: +// a heading named "Acceptance criteria", bullet lines of the form +// "- AC-N: text", and skeleton lines (angle-bracket instructions or a bare +// ellipsis) that are placeholders, not criteria. +package acceptance + +import ( + "regexp" + "strings" +) + +// Criterion is one "AC-N: text" line. +type Criterion struct { + ID string `json:"id"` + Text string `json:"text"` +} + +// Section is what Parse found. Found is false when the body has no +// acceptance-criteria heading; a heading with no real criteria under it is +// Found with an empty Criteria. +type Section struct { + Found bool `json:"found"` + // Raw is the section's text between its heading and the next heading of + // the same or a higher level, trimmed. + Raw string `json:"raw"` + Criteria []Criterion `json:"criteria"` + // Placeholders are the IDs of AC lines still holding skeleton text. + Placeholders []string `json:"placeholders"` +} + +var ( + headingRe = regexp.MustCompile(`^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$`) + criterionRe = regexp.MustCompile(`^[ \t]{0,3}[-*+][ \t]+(AC-[0-9]+)[ \t]*:[ \t]*(.*)$`) + fenceRe = regexp.MustCompile("^[ \t]{0,3}(```|~~~)") +) + +// Parse returns the first acceptance-criteria section in body. +func Parse(body string) Section { + lines := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") + var out Section + level := 0 + start := -1 + end := len(lines) + fence := "" + for i, line := range lines { + if m := fenceRe.FindStringSubmatch(line); m != nil { + if fence == "" { + fence = m[1] + } else if m[1] == fence { + fence = "" + } + continue + } + if fence != "" { + continue + } + m := headingRe.FindStringSubmatch(line) + if m == nil { + continue + } + if start < 0 { + if strings.EqualFold(strings.TrimSpace(m[2]), "acceptance criteria") { + level = len(m[1]) + start = i + 1 + } + continue + } + if len(m[1]) <= level { + end = i + break + } + } + if start < 0 { + return out + } + out.Found = true + section := lines[start:end] + out.Raw = strings.TrimSpace(strings.Join(section, "\n")) + out.Criteria = []Criterion{} + out.Placeholders = []string{} + + var cur *Criterion + flush := func() { + if cur == nil { + return + } + cur.Text = strings.TrimSpace(cur.Text) + if isPlaceholder(cur.Text) { + out.Placeholders = append(out.Placeholders, cur.ID) + } else { + out.Criteria = append(out.Criteria, *cur) + } + cur = nil + } + fence = "" + for _, line := range section { + if m := fenceRe.FindStringSubmatch(line); m != nil { + if fence == "" { + fence = m[1] + } else if m[1] == fence { + fence = "" + } + flush() + continue + } + if fence != "" { + continue + } + if m := criterionRe.FindStringSubmatch(line); m != nil { + flush() + cur = &Criterion{ID: m[1], Text: m[2]} + continue + } + // An indented line continues the criterion above it; anything + // else (a blank line, another bullet, prose) ends it. + if cur != nil && strings.TrimSpace(line) != "" && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { + cur.Text += " " + strings.TrimSpace(line) + continue + } + flush() + } + flush() + return out +} + +// isPlaceholder is the playbooks' rule: unedited skeleton text, either +// angle-bracket instructions or a bare ellipsis, is not a criterion. +func isPlaceholder(text string) bool { + if text == "" || text == "..." || text == "…" { + return true + } + return strings.HasPrefix(text, "<") && strings.HasSuffix(text, ">") +} diff --git a/internal/acceptance/acceptance_test.go b/internal/acceptance/acceptance_test.go new file mode 100644 index 000000000..5ed9a0721 --- /dev/null +++ b/internal/acceptance/acceptance_test.go @@ -0,0 +1,115 @@ +package acceptance + +import ( + "reflect" + "testing" +) + +func TestParse(t *testing.T) { + cases := []struct { + name string + body string + found bool + criteria []Criterion + placeholders []string + raw string + }{ + { + name: "no heading", + body: "## Goals\n\n- AC-1: not under the heading\n", + found: false, + }, + { + name: "real criteria, section ends at the next same-level heading", + body: "## Context\n\nx\n\n## Acceptance criteria\n\n- AC-1: the copy is refused\n- AC-2: the key is released\n\n## Open questions\n\n- AC-3: not a criterion\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "the copy is refused"}, + {ID: "AC-2", Text: "the key is released"}, + }, + placeholders: []string{}, + raw: "- AC-1: the copy is refused\n- AC-2: the key is released", + }, + { + name: "a deeper heading stays inside the section", + body: "## Acceptance criteria\n\n### Server\n\n- AC-1: a\n\n# Next\n- AC-2: b\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "a"}, + }, + placeholders: []string{}, + raw: "### Server\n\n- AC-1: a", + }, + { + name: "skeleton lines are placeholders: angle brackets, bare ellipsis, empty", + body: "## Acceptance criteria\n\n- AC-1: \n- AC-2: ...\n- AC-3: …\n- AC-4:\n- AC-5: real bold text\n", + found: true, + criteria: []Criterion{ + {ID: "AC-5", Text: "real bold text"}, + }, + placeholders: []string{"AC-1", "AC-2", "AC-3", "AC-4"}, + }, + { + name: "indented continuation joins; prose and other bullets end it", + body: "## Acceptance criteria\n- AC-1: first line\n second line\n- not a criterion\n stray indent\n* AC-2: star bullet\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "first line second line"}, + {ID: "AC-2", Text: "star bullet"}, + }, + placeholders: []string{}, + }, + { + name: "headings and AC lines inside a code fence are ignored", + body: "```\n## Acceptance criteria\n- AC-9: fenced\n```\n## Acceptance Criteria ##\n- AC-1: real\n~~~\n- AC-2: fenced too\n## Not a heading here\n~~~\n- AC-3: after the fence\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "real"}, + {ID: "AC-3", Text: "after the fence"}, + }, + placeholders: []string{}, + }, + { + name: "heading with nothing under it", + body: "## Acceptance criteria\n\n## Next\n", + found: true, + criteria: []Criterion{}, + placeholders: []string{}, + raw: "", + }, + { + name: "CRLF bodies", + body: "## Acceptance criteria\r\n- AC-1: windows\r\n", + found: true, + criteria: []Criterion{ + {ID: "AC-1", Text: "windows"}, + }, + placeholders: []string{}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Parse(tc.body) + if got.Found != tc.found { + t.Fatalf("Found = %v, want %v", got.Found, tc.found) + } + if !tc.found { + if got.Criteria != nil || got.Placeholders != nil || got.Raw != "" { + t.Fatalf("not found, but got %+v", got) + } + return + } + if !reflect.DeepEqual(got.Criteria, tc.criteria) { + t.Errorf("Criteria = %#v, want %#v", got.Criteria, tc.criteria) + } + if !reflect.DeepEqual(got.Placeholders, tc.placeholders) { + t.Errorf("Placeholders = %#v, want %#v", got.Placeholders, tc.placeholders) + } + if tc.raw != "" || tc.name == "heading with nothing under it" { + if got.Raw != tc.raw { + t.Errorf("Raw = %q, want %q", got.Raw, tc.raw) + } + } + }) + } +} diff --git a/internal/collections/spec_acceptance_test.go b/internal/collections/spec_acceptance_test.go new file mode 100644 index 000000000..5de923125 --- /dev/null +++ b/internal/collections/spec_acceptance_test.go @@ -0,0 +1,24 @@ +package collections + +import ( + "reflect" + "testing" + + "github.com/PerpetualSoftware/pad/internal/acceptance" +) + +// An unedited spec has an acceptance-criteria section whose every line is a +// placeholder: the parser must read the template's own skeleton that way, or +// a fresh spec would report two real criteria (PLAN-3598). +func TestSpecTemplateAcceptanceCriteriaAreAllPlaceholders(t *testing.T) { + got := acceptance.Parse(specContentTemplate) + if !got.Found { + t.Fatal("spec template has no acceptance-criteria section") + } + if len(got.Criteria) != 0 { + t.Fatalf("template skeleton read as real criteria: %#v", got.Criteria) + } + if want := []string{"AC-1", "AC-2"}; !reflect.DeepEqual(got.Placeholders, want) { + t.Fatalf("Placeholders = %#v, want %#v", got.Placeholders, want) + } +}