template-jj/cmd/planctl/lint_test.go
Sid 77e1ff19b6 feat(planctl): lint rules — tag-syntax, cross-reference, file-presence, EARS
* lint.go: Severity / Code (full set) / Diagnostic types migrated
  here from their temporary home in index.go. Code constants form the
  grep-stable public contract per design §4.
* checkTagSyntax (passthrough over idx.MalformedTags).
* checkCrossRef with four diagnostic classes (orphan-requirement,
  orphan-design, uncovered-requirement, uncovered-design), R2.8
  hasDesign gate, R2.9 infra-sentinel exemption, and sorted-id emission
  for stable uncovered-* order.
* checkCloseoutFiles gated on R3.4 fully-closed-out definition
  (at-least-one + all-checked); emits Line=0 diagnostics with empty
  Path for the emitter to substitute plan-dir.
* newMissingPRDResult: R3.1/R3.2 fatal short-circuit.
* checkEars with the five design §3.4 regex patterns, narrow
  `^\*\*[^*]+\*\*` bold-prefix exemption, and --no-ears R4.4 skip.
* sortDiagnostics uses sort.SliceStable with (Path, Line, Code,
  Message) tie-breakers so output is deterministic run-to-run per
  PRD §6 even when two diagnostics share the first three keys
  (missing-closeout-file Line=0 pair).
* Position extended with a Text field populated at extraction time
  so checkEars keeps its literal `(idx, skip)` signature without
  passing a second *ScanResult.
* lint_test.go: one subtest per checker plus sort stability
  regression.

Parent task 4.0 from dev/plans/26172-planctl/tasks.md.
Codex code-review session: 019db2b8-21c8-7d61-85d6-1da5c18d8033 (2 rounds).
2026-04-21 19:11:29 -06:00

328 lines
11 KiB
Go

package main
import (
"sort"
"strings"
"testing"
)
// spec:planctl/R1.3+R1.4+D§3.4
// TestCheckTagSyntax asserts checkTagSyntax is a passthrough over
// idx.MalformedTags (BuildIndex populates that slice; the rule checker
// just returns it for pipeline uniformity).
func TestCheckTagSyntax(t *testing.T) {
want := []Diagnostic{
{Path: "tasks.md", Line: 10, Severity: SevError, Code: CodeTagUnclosed, Message: "x"},
{Path: "tasks.md", Line: 11, Severity: SevError, Code: CodeTagSyntax, Message: "y"},
}
idx := Index{MalformedTags: want}
got := checkTagSyntax(idx)
if len(got) != len(want) {
t.Fatalf("want %d diags, got %d", len(want), len(got))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("diag %d: got %+v, want %+v", i, got[i], want[i])
}
}
}
// spec:planctl/R2.4+R2.5+R2.6+R2.7+R2.8+R2.9+D§3.4
func TestCheckCrossRef(t *testing.T) {
t.Run("orphan-requirement", func(t *testing.T) {
idx := Index{
PRDRequirements: map[string]Position{
"R1.1": {File: "prd.md", Line: 10, Text: "R1.1 THE SYSTEM SHALL do X."},
},
TaskTags: []TagRef{
{Kind: KindRequirements, ID: "R1.1", File: "tasks.md", Line: 30},
{Kind: KindRequirements, ID: "R3.7", File: "tasks.md", Line: 31},
},
}
got := checkCrossRef(idx, false)
if !containsDiag(got, "tasks.md", 31, CodeOrphanRequirement) {
t.Errorf("want orphan-requirement on line 31, got %+v", got)
}
// R1.1 is covered; no uncovered-requirement for it.
if containsDiag(got, "prd.md", 10, CodeUncoveredRequirement) {
t.Errorf("unexpected uncovered-requirement for R1.1: %+v", got)
}
})
t.Run("orphan-design emitted only when hasDesign", func(t *testing.T) {
idx := Index{
DesignSections: map[string]Position{
"D§3.1": {File: "design.md", Line: 5, Text: "3.1 Foo"},
},
TaskTags: []TagRef{
{Kind: KindDesign, ID: "D§9.9", File: "tasks.md", Line: 40},
},
}
// hasDesign=true → orphan-design emitted.
got := checkCrossRef(idx, true)
if !containsDiag(got, "tasks.md", 40, CodeOrphanDesign) {
t.Errorf("hasDesign=true: want orphan-design, got %+v", got)
}
// hasDesign=false → design-facing diagnostics suppressed (R2.8).
got = checkCrossRef(idx, false)
for _, d := range got {
if d.Code == CodeOrphanDesign || d.Code == CodeUncoveredDesign {
t.Errorf("hasDesign=false: unexpected design-facing diag %+v", d)
}
}
})
t.Run("uncovered-requirement and uncovered-design", func(t *testing.T) {
idx := Index{
PRDRequirements: map[string]Position{
"R6.4": {File: "prd.md", Line: 100, Text: "R6.4 THE SYSTEM SHALL y."},
},
DesignSections: map[string]Position{
"D§4.2": {File: "design.md", Line: 15, Text: "4.2 Foo"},
},
// No task tags.
}
got := checkCrossRef(idx, true)
if !containsDiag(got, "prd.md", 100, CodeUncoveredRequirement) {
t.Errorf("want uncovered-requirement on prd.md:100, got %+v", got)
}
if !containsDiag(got, "design.md", 15, CodeUncoveredDesign) {
t.Errorf("want uncovered-design on design.md:15, got %+v", got)
}
})
t.Run("infra sentinel is exempt from orphan", func(t *testing.T) {
idx := Index{
PRDRequirements: map[string]Position{},
TaskTags: []TagRef{
{Kind: KindRequirements, ID: "infra", File: "tasks.md", Line: 5},
},
}
got := checkCrossRef(idx, false)
for _, d := range got {
if d.Code == CodeOrphanRequirement {
t.Errorf("infra should be exempt from orphan-requirement: got %+v", d)
}
}
})
}
// spec:planctl/R3.4+R3.5+R3.6+R3.7+D§3.4
func TestCheckCloseoutFiles(t *testing.T) {
t.Run("zero-task tasks.md is NOT fully closed out", func(t *testing.T) {
p := &Plan{CodexLog: false, HandoffFound: false}
idx := Index{TaskLines: nil}
got := checkCloseoutFiles(p, idx)
if len(got) != 0 {
t.Errorf("zero tasks should not trigger missing-closeout-file, got %+v", got)
}
})
t.Run("any unchecked task suppresses the check", func(t *testing.T) {
p := &Plan{CodexLog: false, HandoffFound: false}
idx := Index{TaskLines: []TaskLine{
{Checked: true},
{Checked: false},
}}
got := checkCloseoutFiles(p, idx)
if len(got) != 0 {
t.Errorf("unfinished tasks should not trigger missing-closeout-file, got %+v", got)
}
})
t.Run("fully [x] missing both codex log and handoff", func(t *testing.T) {
p := &Plan{CodexLog: false, HandoffFound: false}
idx := Index{TaskLines: []TaskLine{{Checked: true}, {Checked: true}}}
got := checkCloseoutFiles(p, idx)
if len(got) != 2 {
t.Fatalf("want 2 missing-closeout-file diagnostics, got %d: %+v", len(got), got)
}
for _, d := range got {
if d.Code != CodeMissingCloseoutFile {
t.Errorf("want CodeMissingCloseoutFile, got %v", d.Code)
}
if d.Severity != SevError {
t.Errorf("want SevError, got %v", d.Severity)
}
if d.Line != 0 {
t.Errorf("want Line=0 (directory-level), got %d", d.Line)
}
}
})
t.Run("fully [x] with both files present is clean", func(t *testing.T) {
p := &Plan{CodexLog: true, HandoffFound: true}
idx := Index{TaskLines: []TaskLine{{Checked: true}}}
got := checkCloseoutFiles(p, idx)
if len(got) != 0 {
t.Errorf("fully closed-out with all files should be clean, got %+v", got)
}
})
}
// spec:planctl/R3.1+R3.2+D§3.4
func TestNewMissingPRDResult(t *testing.T) {
got := newMissingPRDResult("26172-planctl")
if len(got) != 1 {
t.Fatalf("want 1 diagnostic, got %d", len(got))
}
d := got[0]
if d.Code != CodeMissingPRD {
t.Errorf("want CodeMissingPRD, got %v", d.Code)
}
if d.Severity != SevError {
t.Errorf("want SevError, got %v", d.Severity)
}
if d.Line != 0 {
t.Errorf("want Line=0 (directory-level), got %d", d.Line)
}
if d.PlanDir != "26172-planctl" {
t.Errorf("want PlanDir=26172-planctl, got %q", d.PlanDir)
}
}
// spec:planctl/R4.1+R4.2+R4.3+R4.4+D§3.4
func TestCheckEars(t *testing.T) {
t.Run("each EARS pattern matches", func(t *testing.T) {
// Bodies representing the five EARS keyword forms. All should
// pass (no diagnostic).
cases := []struct {
id string
line int
text string
label string
}{
{"R1.1", 10, "R1.1 THE SYSTEM SHALL do X.", "THE SYSTEM SHALL"},
{"R1.2", 11, "R1.2 WHEN foo happens, THE SYSTEM SHALL bar.", "WHEN ... SHALL"},
{"R1.3", 12, "R1.3 WHILE x is active, THE SYSTEM SHALL y.", "WHILE ... SHALL"},
{"R1.4", 13, "R1.4 WHERE z holds, THE SYSTEM SHALL w.", "WHERE ... SHALL"},
{"R1.5", 14, "R1.5 IF bad, THEN THE SYSTEM SHALL recover.", "IF ... THEN SHALL"},
{"R1.6", 15, "R1.6 IF bad, THEN log and abort.", "IF ... THEN <action>"},
}
prdReqs := map[string]Position{}
for _, c := range cases {
prdReqs[c.id] = Position{File: "prd.md", Line: c.line, Text: c.text}
}
idx := Index{PRDRequirements: prdReqs}
got := checkEars(idx, false)
if len(got) != 0 {
t.Errorf("all EARS forms should pass, got %d diagnostics: %+v", len(got), got)
}
})
t.Run("non-EARS body triggers warning", func(t *testing.T) {
idx := Index{PRDRequirements: map[string]Position{
"R2.1": {File: "prd.md", Line: 20, Text: "R2.1 The tool can do something."},
}}
got := checkEars(idx, false)
if len(got) != 1 {
t.Fatalf("want 1 ears-violation, got %d: %+v", len(got), got)
}
if got[0].Code != CodeEARSViolation {
t.Errorf("want CodeEARSViolation, got %v", got[0].Code)
}
if got[0].Severity != SevWarning {
t.Errorf("want SevWarning (R4.3), got %v", got[0].Severity)
}
if !strings.Contains(got[0].Message, "R2.1") {
t.Errorf("message should cite R-id: %q", got[0].Message)
}
})
t.Run("bold-prefix exemption skips ears check", func(t *testing.T) {
idx := Index{PRDRequirements: map[string]Position{
"R3.3": {File: "prd.md", Line: 30, Text: "R3.3 **Definition — \"task\".** Lorem ipsum."},
}}
got := checkEars(idx, false)
if len(got) != 0 {
t.Errorf("bold-prefixed body should be exempt, got %+v", got)
}
})
t.Run("unmatched opening ** still checked", func(t *testing.T) {
// No closing `**` on the same line → no exemption → still checked,
// and since it's not an EARS form, emit ears-violation.
idx := Index{PRDRequirements: map[string]Position{
"R3.4": {File: "prd.md", Line: 31, Text: "R3.4 **foo without closer just prose."},
}}
got := checkEars(idx, false)
if len(got) != 1 {
t.Errorf("unmatched ** should not trigger exemption, got %d: %+v", len(got), got)
}
})
t.Run("no-ears skip short-circuits", func(t *testing.T) {
idx := Index{PRDRequirements: map[string]Position{
"R4.1": {File: "prd.md", Line: 40, Text: "R4.1 malformed"},
}}
got := checkEars(idx, true)
if len(got) != 0 {
t.Errorf("--no-ears should suppress all diagnostics, got %+v", got)
}
})
}
// spec:planctl/R5.2+D§3.1
func TestSortDiagnostics(t *testing.T) {
diags := []Diagnostic{
{Path: "tasks.md", Line: 20, Code: CodeOrphanRequirement},
{Path: "prd.md", Line: 100, Code: CodeUncoveredRequirement},
{Path: "tasks.md", Line: 10, Code: CodeTagUnclosed},
{Path: "tasks.md", Line: 10, Code: CodeTagSyntax}, // same path/line — sort by Code: tag-syntax < tag-unclosed
}
sortDiagnostics(diags)
// Expected order: prd.md < tasks.md, then by Line, then by Code asc.
wantOrder := []struct {
path string
line int
code Code
}{
{"prd.md", 100, CodeUncoveredRequirement},
{"tasks.md", 10, CodeTagSyntax},
{"tasks.md", 10, CodeTagUnclosed},
{"tasks.md", 20, CodeOrphanRequirement},
}
if len(diags) != len(wantOrder) {
t.Fatalf("length mismatch: want %d, got %d", len(wantOrder), len(diags))
}
for i, w := range wantOrder {
if diags[i].Path != w.path || diags[i].Line != w.line || diags[i].Code != w.code {
t.Errorf("diag %d: got (%s:%d %s), want (%s:%d %s)",
i, diags[i].Path, diags[i].Line, diags[i].Code, w.path, w.line, w.code)
}
}
// Sanity: the slice should actually be sorted per sort.IsSorted semantics
// (a hand-rolled predicate equivalent to the one in sortDiagnostics).
if !sort.SliceIsSorted(diags, func(i, j int) bool {
if diags[i].Path != diags[j].Path {
return diags[i].Path < diags[j].Path
}
if diags[i].Line != diags[j].Line {
return diags[i].Line < diags[j].Line
}
if diags[i].Code != diags[j].Code {
return diags[i].Code < diags[j].Code
}
return diags[i].Message < diags[j].Message
}) {
t.Errorf("sortDiagnostics output is not sorted")
}
}
// spec:planctl/R5.2+D§3.1
// TestSortDiagnostics_StableOnEqualCode guards the Message tie-breaker.
// Two missing-closeout-file diagnostics from checkCloseoutFiles can share
// (Path="", Line=0, Code=missing-closeout-file); without a Message-level
// tie-breaker, unstable-sort output would flip their order between runs.
func TestSortDiagnostics_StableOnEqualCode(t *testing.T) {
a := Diagnostic{Line: 0, Severity: SevError, Code: CodeMissingCloseoutFile, Message: "aa"}
b := Diagnostic{Line: 0, Severity: SevError, Code: CodeMissingCloseoutFile, Message: "bb"}
// Seed out-of-order; sortDiagnostics must produce [a, b] by Message.
diags := []Diagnostic{b, a}
sortDiagnostics(diags)
if diags[0].Message != "aa" || diags[1].Message != "bb" {
t.Errorf("want [aa, bb] by Message, got [%q, %q]", diags[0].Message, diags[1].Message)
}
}
// containsDiag reports whether diags holds an entry matching path+line+code.
// Used by TestCheckCrossRef subtests where the ordering within the result
// isn't asserted — only set membership.
func containsDiag(diags []Diagnostic, path string, line int, code Code) bool {
for _, d := range diags {
if d.Path == path && d.Line == line && d.Code == code {
return true
}
}
return false
}