* 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).
328 lines
11 KiB
Go
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
|
|
}
|