template-jj/cmd/planctl/index_test.go
Sid 1a29ff50b2
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
fix(planctl): paren-aware tag extraction and splitting
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.
2026-04-21 20:42:08 -06:00

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
}