Reconcile docs with reality; fix module graph, token compare, health #1
@@ -277,3 +277,32 @@ func assertBefore(t *testing.T, ctx, first, second string) {
|
||||
t.Fatalf("%q must precede %q:\n%s", first, second, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Ingested acceptance criteria must reach the rendered task in order, and an
|
||||
// absent one must say so rather than be silently omitted.
|
||||
func TestAcceptanceCriteriaRenderInOrder(t *testing.T) {
|
||||
in := input()
|
||||
in.Task.Acceptance = []string{"labelled score does not regress", "targeted cases improve"}
|
||||
in.Intent.Task = in.Task
|
||||
got, err := Build(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := strings.Index(got.Task, "- labelled score does not regress")
|
||||
second := strings.Index(got.Task, "- targeted cases improve")
|
||||
if first < 0 || second < 0 {
|
||||
t.Fatalf("acceptance criteria missing from rendered task:\n%s", got.Task)
|
||||
}
|
||||
if first > second {
|
||||
t.Fatal("acceptance criteria rendered out of order")
|
||||
}
|
||||
in.Task.Acceptance = nil
|
||||
in.Intent.Task = in.Task
|
||||
got, err = Build(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got.Task, "## Acceptance\n\nNot stated.") {
|
||||
t.Fatalf("absent acceptance did not render Not stated:\n%s", got.Task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -297,6 +298,57 @@ func (g Gitea) client() *http.Client {
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// acceptanceHeading opens the one recognized acceptance section. The
|
||||
// convention is deliberately tiny: two spellings, markdown heading only. An
|
||||
// ingestion that infers acceptance from arbitrary prose eventually invents
|
||||
// requirements, and a fabricated acceptance criterion outranks every human
|
||||
// decision below it in the authority order.
|
||||
var acceptanceHeading = regexp.MustCompile(`(?i)^#{1,6}[ \t]*acceptance([ \t]+criteria)?[ \t]*:?[ \t]*$`)
|
||||
|
||||
// acceptanceItem matches a bullet or checklist item and captures its text.
|
||||
var acceptanceItem = regexp.MustCompile(`^[ \t]*[-*+][ \t]+(?:\[[ xX]\][ \t]*)?(.*)$`)
|
||||
|
||||
var markdownHeading = regexp.MustCompile(`^#{1,6}[ \t]+`)
|
||||
|
||||
// splitAcceptance separates an issue body into the description and the
|
||||
// acceptance criteria the issue stated for itself. Everything from the
|
||||
// recognized heading to the next heading leaves the description, so a criterion
|
||||
// is never also read as instruction prose. An empty result is not an ingestion
|
||||
// failure: a task with no stated acceptance renders "Not stated." and the frame
|
||||
// phase is where that gets resolved, through the decision-request path.
|
||||
func splitAcceptance(body string) (string, []string) {
|
||||
lines := strings.Split(body, "\n")
|
||||
start := -1
|
||||
for i, l := range lines {
|
||||
if acceptanceHeading.MatchString(strings.TrimRight(l, " \t\r")) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return body, nil
|
||||
}
|
||||
var acceptance []string
|
||||
end := len(lines)
|
||||
for i := start + 1; i < len(lines); i++ {
|
||||
line := strings.TrimRight(lines[i], " \t\r")
|
||||
if markdownHeading.MatchString(line) {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
m := acceptanceItem.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if item := strings.TrimSpace(m[1]); item != "" {
|
||||
acceptance = append(acceptance, item)
|
||||
}
|
||||
}
|
||||
kept := append(append([]string{}, lines[:start]...), lines[end:]...)
|
||||
return strings.Trim(strings.Join(kept, "\n"), "\n"), acceptance
|
||||
}
|
||||
|
||||
func (g Gitea) event(issue giteaIssue, source, project string) domain.Event {
|
||||
caps := []string{}
|
||||
for _, l := range issue.Labels {
|
||||
@@ -306,7 +358,11 @@ func (g Gitea) event(issue giteaIssue, source, project string) domain.Event {
|
||||
// authority in every rendered context. Dropping it here made every
|
||||
// Gitea-sourced task run on its title alone, with "Acceptance: Not stated."
|
||||
// Found on the first burn-in task, 2026-08-26.
|
||||
p := map[string]any{"source": source, "external_id": strconv.Itoa(issue.Number), "project": project, "title": issue.Title, "description": issue.Body, "capability": caps}
|
||||
description, acceptance := splitAcceptance(issue.Body)
|
||||
p := map[string]any{"source": source, "external_id": strconv.Itoa(issue.Number), "project": project, "title": issue.Title, "description": description, "capability": caps}
|
||||
if len(acceptance) > 0 {
|
||||
p["acceptance"] = acceptance
|
||||
}
|
||||
b, _ := json.Marshal(p)
|
||||
return domain.Event{ID: domain.NewID(), TaskID: domain.NewID(), Type: "TaskCreated", Version: 1, Payload: b, Surface: string(authz.System)}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"orchestra/internal/domain"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -22,3 +23,81 @@ func TestJSONLRejectsMalformedLine(t *testing.T) {
|
||||
t.Fatalf("n=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A Gitea issue states its own acceptance under one recognized heading. Before
|
||||
// this, every Gitea-sourced task rendered "Acceptance: Not stated." no matter
|
||||
// what the body said, because nothing on that path ever set the field.
|
||||
func TestGiteaIssueAcceptanceSection(t *testing.T) {
|
||||
body := "improve ambiguous attribution.\n\n## acceptance\n- labelled score does not regress\n- targeted cases improve\n"
|
||||
e := Gitea{}.event(giteaIssue{Number: 7, Title: "fix attribution", Body: body}, "gitea", "p")
|
||||
var p struct {
|
||||
Description string `json:"description"`
|
||||
Acceptance []string `json:"acceptance"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Description != "improve ambiguous attribution." {
|
||||
t.Fatalf("description=%q, want the prose without the acceptance section", p.Description)
|
||||
}
|
||||
want := []string{"labelled score does not regress", "targeted cases improve"}
|
||||
if len(p.Acceptance) != len(want) {
|
||||
t.Fatalf("acceptance=%q, want %q", p.Acceptance, want)
|
||||
}
|
||||
for i := range want {
|
||||
if p.Acceptance[i] != want[i] {
|
||||
t.Fatalf("acceptance[%d]=%q, want %q (order is preserved)", i, p.Acceptance[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A body with no recognized section yields no acceptance, and that is correct
|
||||
// rather than an ingestion failure: the task renders "Not stated." and the
|
||||
// frame phase resolves it through the decision-request path. Inferring
|
||||
// criteria from arbitrary prose would invent requirements that outrank every
|
||||
// human decision beneath them.
|
||||
func TestGiteaIssueWithoutAcceptanceSectionStaysEmpty(t *testing.T) {
|
||||
body := "just do the thing.\n\n## notes\n- not an acceptance criterion\n"
|
||||
e := Gitea{}.event(giteaIssue{Number: 8, Title: "t", Body: body}, "gitea", "p")
|
||||
var p map[string]any
|
||||
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := p["acceptance"]; ok {
|
||||
t.Fatalf("acceptance=%v, want none", p["acceptance"])
|
||||
}
|
||||
if p["description"] != body {
|
||||
t.Fatalf("description=%q, want the body unchanged", p["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAcceptanceConventions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
desc string
|
||||
want []string
|
||||
}{
|
||||
{"checkboxes", "do it.\n\n## Acceptance\n- [ ] thing a works\n- [x] behaviour b unchanged\n", "do it.", []string{"thing a works", "behaviour b unchanged"}},
|
||||
{"acceptance criteria spelling", "do it.\n\n### Acceptance Criteria\n* one\n", "do it.", []string{"one"}},
|
||||
{"section ends at the next heading", "do it.\n\n## acceptance\n- one\n\n## notes\n- not acceptance\n", "do it.\n\n## notes\n- not acceptance", []string{"one"}},
|
||||
{"empty items ignored", "do it.\n\n## acceptance\n-\n- [ ]\n- real\n", "do it.", []string{"real"}},
|
||||
{"prose inside the section is not an item", "do it.\n\n## acceptance\nthese are the criteria:\n- one\n", "do it.", []string{"one"}},
|
||||
{"heading only", "do it.\n\n## acceptance\n", "do it.", nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
desc, got := splitAcceptance(tc.body)
|
||||
if desc != tc.desc {
|
||||
t.Fatalf("description=%q, want %q", desc, tc.desc)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("acceptance=%q, want %q", got, tc.want)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("acceptance[%d]=%q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user