mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
fix(acceptance): CommonMark fences, setext headings, and nested AC bullets (PLAN-3598 slice (a))
Codex round 1 at b1091c645 found three P2s. A fence closed on any line
sharing its first three characters, so a four-backtick block ended at a
three-backtick line or an info-string line, and fenced headings could end
the section. A bullet indented under a criterion counted as a criterion of
its own. Setext headings were not recognized at all.
Fences now close only on a run of their own character at least as long as
the opener with nothing after it, and a backtick opener with a backtick in
its info string is not a fence. Setext underlines make headings only under
a paragraph line, never under a list item or an indented continuation,
where the underline is a thematic break. A criterion is a top-level bullet;
an indented AC-shaped bullet joins the text of the criterion above it.
Six of the new cases fail on the previous parser.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
@@ -31,44 +31,78 @@ type Section struct {
|
||||
}
|
||||
|
||||
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}(```|~~~)")
|
||||
atxRe = regexp.MustCompile(`^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$`)
|
||||
setextRe = regexp.MustCompile(`^ {0,3}(=+|-+)[ \t]*$`)
|
||||
bulletRe = regexp.MustCompile(`^ {0,3}[-*+](?:[ \t]|$)`)
|
||||
criterionRe = regexp.MustCompile(`^ ?[-*+][ \t]+(AC-[0-9]+)[ \t]*:[ \t]*(.*)$`)
|
||||
fenceOpenRe = regexp.MustCompile("^ {0,3}(`{3,}|~{3,})(.*)$")
|
||||
closeHashRe = regexp.MustCompile(`(?:^|[ \t]+)#+$`)
|
||||
)
|
||||
|
||||
// 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)
|
||||
// heading is one ATX or setext heading: it starts at line `at` and its body
|
||||
// starts at line `body`.
|
||||
type heading struct {
|
||||
at, body, level int
|
||||
text string
|
||||
}
|
||||
|
||||
// scan marks the lines inside fenced code blocks (fence lines included) and
|
||||
// lists the headings outside them, following CommonMark's rules for both: a
|
||||
// fence closes only on a run of its own character at least as long as the
|
||||
// opener with nothing after it, and a setext underline makes a heading only
|
||||
// of a paragraph line, never of a list item, a blank line, or an indented
|
||||
// line (which continues a list item, so the underline is a thematic break).
|
||||
func scan(lines []string) (fenced []bool, heads []heading) {
|
||||
fenced = make([]bool, 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 {
|
||||
if fence != "" {
|
||||
fenced[i] = true
|
||||
t := strings.TrimRight(strings.TrimLeft(line, " "), " \t")
|
||||
if len(line)-len(strings.TrimLeft(line, " ")) <= 3 && len(t) >= len(fence) && strings.Trim(t, fence[:1]) == "" {
|
||||
fence = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fence != "" {
|
||||
if m := fenceOpenRe.FindStringSubmatch(line); m != nil && !(m[1][0] == '`' && strings.Contains(m[2], "`")) {
|
||||
fence = m[1]
|
||||
fenced[i] = true
|
||||
continue
|
||||
}
|
||||
m := headingRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
if m := atxRe.FindStringSubmatch(line); m != nil {
|
||||
text := closeHashRe.ReplaceAllString(m[2], "")
|
||||
heads = append(heads, heading{at: i, body: i + 1, level: len(m[1]), text: strings.TrimSpace(text)})
|
||||
continue
|
||||
}
|
||||
if m := setextRe.FindStringSubmatch(line); m != nil && i > 0 && !fenced[i-1] {
|
||||
prev := lines[i-1]
|
||||
if strings.TrimSpace(prev) != "" && !atxRe.MatchString(prev) && !bulletRe.MatchString(prev) && !setextRe.MatchString(prev) && !strings.HasPrefix(prev, " ") && !strings.HasPrefix(prev, "\t") {
|
||||
level := 2
|
||||
if m[1][0] == '=' {
|
||||
level = 1
|
||||
}
|
||||
heads = append(heads, heading{at: i - 1, body: i + 1, level: level, text: strings.TrimSpace(prev)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return fenced, heads
|
||||
}
|
||||
|
||||
// Parse returns the first acceptance-criteria section in body.
|
||||
func Parse(body string) Section {
|
||||
lines := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n")
|
||||
fenced, heads := scan(lines)
|
||||
var out Section
|
||||
start, end, level := -1, len(lines), 0
|
||||
for _, h := range heads {
|
||||
if start < 0 {
|
||||
if strings.EqualFold(strings.TrimSpace(m[2]), "acceptance criteria") {
|
||||
level = len(m[1])
|
||||
start = i + 1
|
||||
if strings.EqualFold(h.text, "acceptance criteria") {
|
||||
start, level = h.body, h.level
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(m[1]) <= level {
|
||||
end = i
|
||||
if h.level <= level {
|
||||
end = h.at
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -76,8 +110,7 @@ func Parse(body string) Section {
|
||||
return out
|
||||
}
|
||||
out.Found = true
|
||||
section := lines[start:end]
|
||||
out.Raw = strings.TrimSpace(strings.Join(section, "\n"))
|
||||
out.Raw = strings.TrimSpace(strings.Join(lines[start:end], "\n"))
|
||||
out.Criteria = []Criterion{}
|
||||
out.Placeholders = []string{}
|
||||
|
||||
@@ -94,20 +127,15 @@ func Parse(body string) Section {
|
||||
}
|
||||
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 = ""
|
||||
}
|
||||
for i := start; i < end; i++ {
|
||||
line := lines[i]
|
||||
if fenced[i] {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
if fence != "" {
|
||||
continue
|
||||
}
|
||||
// A criterion is a top-level bullet (at most one space of indent);
|
||||
// an indented AC-shaped bullet is nested under the one above it and
|
||||
// joins its text rather than counting as a criterion of its own.
|
||||
if m := criterionRe.FindStringSubmatch(line); m != nil {
|
||||
flush()
|
||||
cur = &Criterion{ID: m[1], Text: m[2]}
|
||||
|
||||
@@ -69,6 +69,75 @@ func TestParse(t *testing.T) {
|
||||
},
|
||||
placeholders: []string{},
|
||||
},
|
||||
{
|
||||
name: "a fence closes only on its own marker, at least as long, with nothing after",
|
||||
body: "## Acceptance criteria\n````\n```\n- AC-8: still fenced\n```info\n- AC-9: still fenced\n````\n- AC-1: after\n",
|
||||
found: true,
|
||||
criteria: []Criterion{
|
||||
{ID: "AC-1", Text: "after"},
|
||||
},
|
||||
placeholders: []string{},
|
||||
},
|
||||
{
|
||||
name: "a fenced heading cannot end the section once a short closer fails to close the fence",
|
||||
body: "## Acceptance criteria\n- AC-1: a\n````\n```\n## Fenced\n````\n- AC-2: b\n",
|
||||
found: true,
|
||||
criteria: []Criterion{
|
||||
{ID: "AC-1", Text: "a"},
|
||||
{ID: "AC-2", Text: "b"},
|
||||
},
|
||||
placeholders: []string{},
|
||||
},
|
||||
{
|
||||
name: "a backtick run with a backtick in its info string is not a fence",
|
||||
body: "## Acceptance criteria\n``` a`b\n- AC-1: not fenced\n",
|
||||
found: true,
|
||||
criteria: []Criterion{
|
||||
{ID: "AC-1", Text: "not fenced"},
|
||||
},
|
||||
placeholders: []string{},
|
||||
},
|
||||
{
|
||||
name: "an indented AC-shaped bullet is nested, not a criterion",
|
||||
body: "## Acceptance criteria\n- AC-1: outer\n - AC-2: example\n- AC-3: next\n",
|
||||
found: true,
|
||||
criteria: []Criterion{
|
||||
{ID: "AC-1", Text: "outer - AC-2: example"},
|
||||
{ID: "AC-3", Text: "next"},
|
||||
},
|
||||
placeholders: []string{},
|
||||
},
|
||||
{
|
||||
name: "setext headings open and close the section",
|
||||
body: "Acceptance criteria\n===================\n- AC-1: a\n\nOpen questions\n==============\n- AC-2: b\n",
|
||||
found: true,
|
||||
criteria: []Criterion{
|
||||
{ID: "AC-1", Text: "a"},
|
||||
},
|
||||
placeholders: []string{},
|
||||
raw: "- AC-1: a",
|
||||
},
|
||||
{
|
||||
name: "a dash underline under a list item or an indented line is a thematic break, not a heading",
|
||||
body: "Acceptance criteria\n---\n- AC-1: a\n---\n- AC-2: b\n more\n---\n- AC-3: c\n# Next\n",
|
||||
found: true,
|
||||
criteria: []Criterion{
|
||||
{ID: "AC-1", Text: "a"},
|
||||
{ID: "AC-2", Text: "b more"},
|
||||
{ID: "AC-3", Text: "c"},
|
||||
},
|
||||
placeholders: []string{},
|
||||
},
|
||||
{
|
||||
name: "a hash with no space after it is not a heading",
|
||||
body: "## Acceptance criteria\n- AC-1: a\n#hashtag\n- AC-2: b\n",
|
||||
found: true,
|
||||
criteria: []Criterion{
|
||||
{ID: "AC-1", Text: "a"},
|
||||
{ID: "AC-2", Text: "b"},
|
||||
},
|
||||
placeholders: []string{},
|
||||
},
|
||||
{
|
||||
name: "heading with nothing under it",
|
||||
body: "## Acceptance criteria\n\n## Next\n",
|
||||
|
||||
Reference in New Issue
Block a user