mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
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
This commit is contained in:
@@ -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, ">")
|
||||
}
|
||||
@@ -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: <a statement>\n- AC-2: ...\n- AC-3: …\n- AC-4:\n- AC-5: real <b>bold</b> text\n",
|
||||
found: true,
|
||||
criteria: []Criterion{
|
||||
{ID: "AC-5", Text: "real <b>bold</b> 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user