Some checks failed
planctl / build-test (1.22, macos-latest) (push) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (push) Has been cancelled
planctl / build-test (1.22, windows-latest) (push) Has been cancelled
planctl / build-test (stable, macos-latest) (push) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (push) Has been cancelled
planctl / build-test (stable, windows-latest) (push) Has been cancelled
planctl / bench (push) Has been cancelled
Pre-PR codex review found a gap in the R1.1 annotation grammar handling: * scanTagAfterOpener treated the first '_' after an opener as the closing delimiter regardless of paren nesting, so a legitimate tag like `_Requirements: R1.1 (foo_bar)_` would incorrectly close at the underscore inside the annotation. * parseTagBody split on every ', ' regardless of paren context, so `_Requirements: R1.1 (note, with comma), R2.1_` split into three bogus refs. Both are per-spec: PRD R1.1 defines annotation body as 'arbitrary non-) characters', which includes _ and ,. The state machine and the ref-splitter are now paren-depth-aware; regression tests added for both cases.
476 lines
14 KiB
Go
476 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// scanFromString is the indexer's test-helper analogue of Scan: parses a
|
|
// markdown source string through the same goldmark pipeline the production
|
|
// code uses, returning a *ScanResult suitable for the extractor entry points.
|
|
func scanFromString(path, source string) *ScanResult {
|
|
r := scanBytes(path, []byte(source))
|
|
return &r
|
|
}
|
|
|
|
// spec:planctl/R1.1+R1.7+R2.9
|
|
// TestValidators sanity-checks the grammar predicates from task 3.2. The
|
|
// exhaustive coverage is exercised through TestExtractTaskTags, but these
|
|
// direct asserts pin the boundary cases.
|
|
func TestValidators(t *testing.T) {
|
|
t.Run("reqID", func(t *testing.T) {
|
|
// PRD R1.1: R<digits>(.<digits>)+[a-z]?. At least one sub-number.
|
|
valid := []string{"R1.2", "R3.2a", "R10.15", "R1.2.3", "R9.9b", "R1.2.3d"}
|
|
invalid := []string{"R3", "R", "r1.2", "R1.2.", "R1.2A", "R1.2.3da", "R1.2A.3"}
|
|
for _, s := range valid {
|
|
if !isValidReqID(s) {
|
|
t.Errorf("isValidReqID(%q) = false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range invalid {
|
|
if isValidReqID(s) {
|
|
t.Errorf("isValidReqID(%q) = true, want false", s)
|
|
}
|
|
}
|
|
// R1.7: `infra` sentinel passes.
|
|
if !isValidReqID("infra") {
|
|
t.Errorf("isValidReqID(\"infra\") = false, want true")
|
|
}
|
|
if !isInfraRef("infra") {
|
|
t.Errorf("isInfraRef(\"infra\") = false, want true")
|
|
}
|
|
if isInfraRef("infrastructure") {
|
|
t.Errorf("isInfraRef(\"infrastructure\") = true, want false")
|
|
}
|
|
})
|
|
t.Run("desID", func(t *testing.T) {
|
|
valid := []string{"D§3", "D§3.4", "D§1.2.3", "D§10"}
|
|
invalid := []string{"D3", "§3", "D§", "D§3a", "D§3.", "D§.3"}
|
|
for _, s := range valid {
|
|
if !isValidDesID(s) {
|
|
t.Errorf("isValidDesID(%q) = false, want true", s)
|
|
}
|
|
}
|
|
for _, s := range invalid {
|
|
if isValidDesID(s) {
|
|
t.Errorf("isValidDesID(%q) = true, want false", s)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// spec:planctl/R1.1+R1.2+R1.3+R1.4+R1.5+R1.6+R1.7+R2.3+R2.9+D§3.3
|
|
// TestExtractTaskTags covers the tag state-machine + body-parsing pipeline
|
|
// end-to-end by running extractTaskTags on a parsed tasks.md ScanResult.
|
|
// The test cases map directly to task 3.9's sub-items (a) through (g).
|
|
func TestExtractTaskTags(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
source string
|
|
wantRefs []TagRef
|
|
wantDiags []Diagnostic
|
|
}{
|
|
{
|
|
// (a) single well-formed Requirements tag
|
|
name: "single well-formed tag",
|
|
source: "- [ ] 1.1 do X _Requirements: R1.2_\n",
|
|
wantRefs: []TagRef{
|
|
{Kind: KindRequirements, ID: "R1.2", File: "tasks.md", Line: 1},
|
|
},
|
|
},
|
|
{
|
|
// (b) two tags on one line
|
|
name: "two tags on one line",
|
|
source: "- [ ] 1.1 do X _Requirements: R1.2_ _Design: D§3.4_\n",
|
|
wantRefs: []TagRef{
|
|
{Kind: KindRequirements, ID: "R1.2", File: "tasks.md", Line: 1},
|
|
{Kind: KindDesign, ID: "D§3.4", File: "tasks.md", Line: 1},
|
|
},
|
|
},
|
|
{
|
|
// (c) tag with trailing annotation
|
|
name: "tag with annotation",
|
|
source: "- [ ] 1.1 _Requirements: R1.2 (note)_\n",
|
|
wantRefs: []TagRef{
|
|
{Kind: KindRequirements, ID: "R1.2", File: "tasks.md", Line: 1},
|
|
},
|
|
},
|
|
{
|
|
name: "multi-ref body split on comma-space with annotation",
|
|
source: "- [ ] 1.1 _Requirements: R1.2, R2.3a (legacy)_\n",
|
|
wantRefs: []TagRef{
|
|
{Kind: KindRequirements, ID: "R1.2", File: "tasks.md", Line: 1},
|
|
{Kind: KindRequirements, ID: "R2.3a", File: "tasks.md", Line: 1},
|
|
},
|
|
},
|
|
{
|
|
// (d) infra sentinel
|
|
name: "infra sentinel",
|
|
source: "- [ ] 0.1 scaffold _Requirements: infra_\n",
|
|
wantRefs: []TagRef{
|
|
{Kind: KindRequirements, ID: "infra", File: "tasks.md", Line: 1},
|
|
},
|
|
},
|
|
{
|
|
// (e) tag-unclosed recovery — from design §3.3 DQ3 fixture
|
|
name: "tag-unclosed recovery yields one diag + one tag",
|
|
source: "- [ ] 1.1 _Requirements: R3 _Design: D§2_\n",
|
|
wantRefs: []TagRef{
|
|
{Kind: KindDesign, ID: "D§2", File: "tasks.md", Line: 1},
|
|
},
|
|
wantDiags: []Diagnostic{
|
|
{Path: "tasks.md", Line: 1, Severity: SevError, Code: CodeTagUnclosed},
|
|
},
|
|
},
|
|
{
|
|
name: "truly unclosed tag at EOL emits one diag and no refs",
|
|
source: joinLines(
|
|
"- [ ] 1.1 _Requirements: R1.2",
|
|
"",
|
|
),
|
|
wantDiags: []Diagnostic{
|
|
{Path: "tasks.md", Line: 1, Severity: SevError, Code: CodeTagUnclosed},
|
|
},
|
|
},
|
|
{
|
|
name: "malformed ref body emits tag-syntax",
|
|
source: "- [ ] 1.1 _Requirements: R3_\n",
|
|
wantDiags: []Diagnostic{
|
|
{Path: "tasks.md", Line: 1, Severity: SevError, Code: CodeTagSyntax},
|
|
},
|
|
},
|
|
{
|
|
// (f) tag inside fenced code block — zero extractions
|
|
name: "tag inside fenced block is skipped via InCode",
|
|
source: joinLines(
|
|
"Prose.",
|
|
"```",
|
|
"- [ ] 1.1 _Requirements: R1.2_",
|
|
"```",
|
|
"",
|
|
),
|
|
},
|
|
{
|
|
// (g) tag inside inline backticks — zero extractions
|
|
name: "tag inside inline code span is masked",
|
|
source: "- [ ] 1.1 example `_Requirements: R9.9_` here\n",
|
|
},
|
|
{
|
|
// Paren-nesting: underscore inside annotation must not close the tag.
|
|
name: "annotation may contain underscore without closing tag",
|
|
source: "- [ ] 1.1 _Requirements: R1.1 (foo_bar)_\n",
|
|
wantRefs: []TagRef{
|
|
{Kind: KindRequirements, ID: "R1.1", File: "tasks.md", Line: 1},
|
|
},
|
|
},
|
|
{
|
|
// Paren-nesting: comma inside annotation must not split the ref.
|
|
name: "annotation may contain comma without splitting ref",
|
|
source: "- [ ] 1.1 _Requirements: R1.1 (note, with comma), R2.1_\n",
|
|
wantRefs: []TagRef{
|
|
{Kind: KindRequirements, ID: "R1.1", File: "tasks.md", Line: 1},
|
|
{Kind: KindRequirements, ID: "R2.1", File: "tasks.md", Line: 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
s := scanFromString("tasks.md", tc.source)
|
|
tags, diags := extractTaskTags(s)
|
|
assertTagRefs(t, tc.wantRefs, tags)
|
|
assertDiagsExact(t, tc.wantDiags, diags)
|
|
})
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R2.1+D§3.3
|
|
// TestExtractPRDReqs covers the PRD R-id declaration extractor (task 3.5)
|
|
// — case (h) in the sub-task list. Includes both the AST-primary path and
|
|
// an implicit fallback smoke via a file without list items.
|
|
func TestExtractPRDReqs(t *testing.T) {
|
|
source := joinLines(
|
|
"# PRD — Foo",
|
|
"",
|
|
"## 4. Functional Requirements",
|
|
"",
|
|
"### R1. Tag-syntax validation",
|
|
"- R1.1 WHEN planctl runs, THE SYSTEM SHALL do X.",
|
|
"- R1.2 IF condition, THEN THE SYSTEM SHALL do Y.",
|
|
"- R2.3a THE SYSTEM SHALL do Z.",
|
|
"",
|
|
"Not a bullet: R9.9 does stuff.",
|
|
"",
|
|
)
|
|
s := scanFromString("prd.md", source)
|
|
got := extractPRDReqs(s)
|
|
want := map[string]int{ // id → expected line (1-indexed)
|
|
"R1.1": 6,
|
|
"R1.2": 7,
|
|
"R2.3a": 8,
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("want %d decls, got %d: %v", len(want), len(got), got)
|
|
}
|
|
for id, wantLine := range want {
|
|
pos, ok := got[id]
|
|
if !ok {
|
|
t.Errorf("missing R-id %q", id)
|
|
continue
|
|
}
|
|
if pos.Line != wantLine {
|
|
t.Errorf("R-id %q: Line=%d, want %d", id, pos.Line, wantLine)
|
|
}
|
|
if pos.File != "prd.md" {
|
|
t.Errorf("R-id %q: File=%q, want prd.md", id, pos.File)
|
|
}
|
|
}
|
|
if _, ok := got["R9.9"]; ok {
|
|
t.Errorf("non-bullet R9.9 should not be extracted")
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R2.2+D§3.3
|
|
// TestExtractDesignSections covers the design D§ declaration extractor
|
|
// (task 3.6) — case (i). Uses both `N.M Title` (no period) and `N. Title`
|
|
// (period) conventions to confirm the optional-period tolerance.
|
|
func TestExtractDesignSections(t *testing.T) {
|
|
source := joinLines(
|
|
"# Design — Foo",
|
|
"",
|
|
"## 0. Scope notes",
|
|
"",
|
|
"Content.",
|
|
"",
|
|
"## 3. Component definitions",
|
|
"",
|
|
"### 3.1 Diagnostic",
|
|
"",
|
|
"Sub-content.",
|
|
"",
|
|
"### 3.4 Lint rules",
|
|
"",
|
|
"#### 3.4.1 Helper section",
|
|
"",
|
|
"##### 5.5 Level-5 headings are ignored",
|
|
"",
|
|
"## Not numbered — skipped",
|
|
"",
|
|
)
|
|
s := scanFromString("design.md", source)
|
|
got := extractDesignSections(s)
|
|
want := map[string]int{
|
|
"D§0": 3,
|
|
"D§3": 7,
|
|
"D§3.1": 9,
|
|
"D§3.4": 13,
|
|
"D§3.4.1": 15,
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("want %d decls, got %d: %v", len(want), len(got), got)
|
|
}
|
|
for id, wantLine := range want {
|
|
pos, ok := got[id]
|
|
if !ok {
|
|
t.Errorf("missing D§ id %q", id)
|
|
continue
|
|
}
|
|
if pos.Line != wantLine {
|
|
t.Errorf("D§ %q: Line=%d, want %d", id, pos.Line, wantLine)
|
|
}
|
|
}
|
|
if _, ok := got["D§5.5"]; ok {
|
|
t.Errorf("level-5 heading should not declare D§5.5")
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R3.3+D§3.3
|
|
// TestExtractTaskLines covers the task-line extractor — case (j). Mixes
|
|
// `[ ]` and `[x]`, top-level and nested bullets, and a tag-like token inside
|
|
// a fenced block that must NOT count as a task.
|
|
func TestExtractTaskLines(t *testing.T) {
|
|
source := joinLines(
|
|
"# Tasks",
|
|
"",
|
|
"- [ ] 1.0 Parent task",
|
|
" - [x] 1.1 Nested checked",
|
|
" - [ ] 1.2 Nested unchecked",
|
|
" - [x] 1.2.1 Double-nested",
|
|
"- [x] 2.0 Another parent",
|
|
"",
|
|
"```",
|
|
"- [ ] 3.0 Decoy inside fence — not a task",
|
|
"```",
|
|
"",
|
|
"Prose paragraph, not a task.",
|
|
"",
|
|
)
|
|
s := scanFromString("tasks.md", source)
|
|
got := extractTaskLines(s)
|
|
type want struct {
|
|
line int
|
|
indent int
|
|
checked bool
|
|
}
|
|
wantLines := []want{
|
|
{line: 3, indent: 0, checked: false},
|
|
{line: 4, indent: 2, checked: true},
|
|
{line: 5, indent: 2, checked: false},
|
|
{line: 6, indent: 4, checked: true},
|
|
{line: 7, indent: 0, checked: true},
|
|
}
|
|
if len(got) != len(wantLines) {
|
|
t.Fatalf("want %d tasks, got %d: %+v", len(wantLines), len(got), got)
|
|
}
|
|
// Order is AST walk order; scanResult preserves document order for list
|
|
// items so this matches top-to-bottom source order.
|
|
for i, w := range wantLines {
|
|
if got[i].Line != w.line {
|
|
t.Errorf("task %d: Line=%d, want %d", i, got[i].Line, w.line)
|
|
}
|
|
if got[i].Indent != w.indent {
|
|
t.Errorf("task %d: Indent=%d, want %d", i, got[i].Indent, w.indent)
|
|
}
|
|
if got[i].Checked != w.checked {
|
|
t.Errorf("task %d: Checked=%v, want %v", i, got[i].Checked, w.checked)
|
|
}
|
|
}
|
|
}
|
|
|
|
// spec:planctl/R1.3+R1.4+R2.1+R2.2+R2.3+R3.3+D§3.3
|
|
// TestBuildIndex_SmokeWholeFlow runs the full orchestrator against a
|
|
// minimal synthetic plan: a PRD, a design, and a tasks.md referencing
|
|
// both. Cross-reference checks live in task 4.3; here we only assert that
|
|
// BuildIndex populates the Index's slices and maps correctly.
|
|
func TestBuildIndex_SmokeWholeFlow(t *testing.T) {
|
|
prd := joinLines(
|
|
"# PRD",
|
|
"",
|
|
"### R1. Stuff",
|
|
"- R1.1 THE SYSTEM SHALL do X.",
|
|
"- R1.2 THE SYSTEM SHALL do Y.",
|
|
"",
|
|
)
|
|
design := joinLines(
|
|
"# Design",
|
|
"",
|
|
"### 3.1 Foo",
|
|
"",
|
|
"### 3.2 Bar",
|
|
"",
|
|
)
|
|
tasks := joinLines(
|
|
"# Tasks",
|
|
"",
|
|
"- [x] 1.0 Something _Requirements: R1.1, R1.2_ _Design: D§3.1_",
|
|
"- [ ] 1.1 More _Requirements: R9_", // malformed — tag-syntax
|
|
"- [ ] 1.2 Partial _Requirements: R1.1",
|
|
"",
|
|
)
|
|
plan := &Plan{
|
|
Dir: "/fake/plan",
|
|
PRD: scanFromString("prd.md", prd),
|
|
Design: scanFromString("design.md", design),
|
|
Tasks: scanFromString("tasks.md", tasks),
|
|
}
|
|
idx := BuildIndex(plan)
|
|
|
|
// Requirements declarations.
|
|
if _, ok := idx.PRDRequirements["R1.1"]; !ok {
|
|
t.Errorf("missing R1.1 declaration")
|
|
}
|
|
if _, ok := idx.PRDRequirements["R1.2"]; !ok {
|
|
t.Errorf("missing R1.2 declaration")
|
|
}
|
|
// Design declarations.
|
|
if _, ok := idx.DesignSections["D§3.1"]; !ok {
|
|
t.Errorf("missing D§3.1 declaration")
|
|
}
|
|
// Well-formed task tags: R1.1, R1.2, D§3.1 on line 3.
|
|
wantTags := map[string]Kind{
|
|
"R1.1": KindRequirements,
|
|
"R1.2": KindRequirements,
|
|
"D§3.1": KindDesign,
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, tag := range idx.TaskTags {
|
|
seen[tag.ID] = true
|
|
}
|
|
for id := range wantTags {
|
|
if !seen[id] {
|
|
t.Errorf("missing task tag %q in TaskTags", id)
|
|
}
|
|
}
|
|
// MalformedTags: one tag-syntax (R9 on line 4), one tag-unclosed (line 5).
|
|
if !hasDiag(idx.MalformedTags, CodeTagSyntax, 4) {
|
|
t.Errorf("want tag-syntax on line 4, got %+v", idx.MalformedTags)
|
|
}
|
|
if !hasDiag(idx.MalformedTags, CodeTagUnclosed, 5) {
|
|
t.Errorf("want tag-unclosed on line 5, got %+v", idx.MalformedTags)
|
|
}
|
|
// TaskLines: 3 total, one [x] and two [ ].
|
|
if len(idx.TaskLines) != 3 {
|
|
t.Errorf("want 3 task lines, got %d: %+v", len(idx.TaskLines), idx.TaskLines)
|
|
}
|
|
}
|
|
|
|
// assertTagRefs compares TagRef slices on the key identity fields
|
|
// (Kind, ID, File, Line). Col is intentionally not asserted — it's
|
|
// covered indirectly by the tag-unclosed recovery test, and pinning
|
|
// exact byte offsets would make the tests brittle.
|
|
func assertTagRefs(t *testing.T, want, got []TagRef) {
|
|
t.Helper()
|
|
if len(want) != len(got) {
|
|
t.Errorf("tag count: want %d, got %d (got=%+v)", len(want), len(got), got)
|
|
return
|
|
}
|
|
for i := range want {
|
|
if want[i].Kind != got[i].Kind {
|
|
t.Errorf("tag %d Kind: want %v, got %v", i, want[i].Kind, got[i].Kind)
|
|
}
|
|
if want[i].ID != got[i].ID {
|
|
t.Errorf("tag %d ID: want %q, got %q", i, want[i].ID, got[i].ID)
|
|
}
|
|
if want[i].File != got[i].File {
|
|
t.Errorf("tag %d File: want %q, got %q", i, want[i].File, got[i].File)
|
|
}
|
|
if want[i].Line != got[i].Line {
|
|
t.Errorf("tag %d Line: want %d, got %d", i, want[i].Line, got[i].Line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// assertDiagsExact asserts that got contains exactly len(want) diagnostics
|
|
// AND every want entry has a matching got entry on (Path, Line, Severity,
|
|
// Code). Extras in got fail the test — this is the tight assertion the
|
|
// indexer tests use so a stray tag-syntax or tag-unclosed cannot slip
|
|
// past the recovery / in-code-exclusion cases.
|
|
func assertDiagsExact(t *testing.T, want, got []Diagnostic) {
|
|
t.Helper()
|
|
if len(got) != len(want) {
|
|
t.Errorf("diag count: want %d, got %d (got=%+v)", len(want), len(got), got)
|
|
return
|
|
}
|
|
for _, w := range want {
|
|
found := false
|
|
for _, g := range got {
|
|
if g.Path == w.Path && g.Line == w.Line && g.Severity == w.Severity && g.Code == w.Code {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("missing diagnostic %+v (got=%+v)", w, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// hasDiag reports whether diags contains any entry with the given code and
|
|
// line. Used by BuildIndex smoke test.
|
|
func hasDiag(diags []Diagnostic, code Code, line int) bool {
|
|
for _, d := range diags {
|
|
if d.Code == code && d.Line == line {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|