* 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).
372 lines
12 KiB
Go
372 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// Diagnostic types and classification codes — see design §3.1 and §4.
|
||
//
|
||
// Code values are the public grep-stable contract: downstream CI / agent
|
||
// tooling matches on these exact strings, so renaming a code is a breaking
|
||
// change. Adding a new code is additive. Keep this file minimal on type
|
||
// definitions; rule-check functions live alongside them in later sub-tasks.
|
||
|
||
// spec:planctl/D§3.1+D§4
|
||
// Severity classifies a Diagnostic. Only two levels per PRD §6 — there is
|
||
// no `info` / `hint` / `style` tier. Promotion of warnings to errors (for
|
||
// exit-code purposes) is handled by --strict at the emit layer.
|
||
type Severity int
|
||
|
||
const (
|
||
SevError Severity = iota // exit 1 always
|
||
SevWarning // exit 0 alone; exit 1 with --strict
|
||
)
|
||
|
||
// spec:planctl/D§3.1+D§4
|
||
// Code is a diagnostic's classification. Each value is a kebab-case string
|
||
// matching the PRD's R1–R4 rule categories. Downstream consumers grep for
|
||
// these literals, so they double as the public contract for the tool.
|
||
type Code string
|
||
|
||
const (
|
||
CodeTagSyntax Code = "tag-syntax"
|
||
CodeTagUnclosed Code = "tag-unclosed"
|
||
CodeOrphanRequirement Code = "orphan-requirement"
|
||
CodeOrphanDesign Code = "orphan-design"
|
||
CodeUncoveredRequirement Code = "uncovered-requirement"
|
||
CodeUncoveredDesign Code = "uncovered-design"
|
||
CodeMissingPRD Code = "missing-prd"
|
||
CodeMissingCloseoutFile Code = "missing-closeout-file"
|
||
CodeEARSViolation Code = "ears-violation"
|
||
)
|
||
|
||
// spec:planctl/R5.2+D§3.1
|
||
// Diagnostic is one lint finding. PlanDir is used by the emitter to
|
||
// attribute diagnostics in multi-plan output (R5.9); Path is relative to
|
||
// PlanDir. Line == 0 flags a directory-level diagnostic (missing-prd,
|
||
// missing-closeout-file — there is no line to point at).
|
||
//
|
||
// Rule checkers populate Path / Line / Severity / Code / Message; the
|
||
// orchestrator backfills PlanDir when aggregating per-plan diagnostics
|
||
// into a PlanResult.
|
||
type Diagnostic struct {
|
||
PlanDir string
|
||
Path string
|
||
Line int
|
||
Severity Severity
|
||
Code Code
|
||
Message string
|
||
}
|
||
|
||
// spec:planctl/R1.3+R1.4+D§3.4
|
||
// checkTagSyntax returns the tag-syntax / tag-unclosed diagnostics produced
|
||
// by BuildIndex during tag extraction. It exists as its own rule function
|
||
// so the pipeline has a uniform `(idx, ...) []Diagnostic` surface — the
|
||
// actual work happens during the indexer's left-to-right state machine.
|
||
func checkTagSyntax(idx Index) []Diagnostic {
|
||
return idx.MalformedTags
|
||
}
|
||
|
||
// spec:planctl/R2.4+R2.5+R2.6+R2.7+R2.8+R2.9+D§3.4
|
||
// checkCrossRef emits the four R2 cross-reference diagnostics: orphan-*
|
||
// (task cites an id not declared in prd.md / design.md) and uncovered-*
|
||
// (a declared id with no covering task tag).
|
||
//
|
||
// hasDesign skips orphan-design and uncovered-design per R2.8 when
|
||
// design.md is absent for the plan. The `infra` sentinel on task tags is
|
||
// exempt from orphan-requirement per R2.9.
|
||
//
|
||
// Diagnostic sites follow design §3.4: orphan-* cite the task tag's
|
||
// file/line; uncovered-* cite the prd.md / design.md declaration line.
|
||
// Uncovered-* diagnostics are emitted in sorted-id order so the caller
|
||
// receives deterministic output even before sortDiagnostics (task 4.7).
|
||
func checkCrossRef(idx Index, hasDesign bool) []Diagnostic {
|
||
var out []Diagnostic
|
||
coveredReqs := map[string]bool{}
|
||
coveredDess := map[string]bool{}
|
||
|
||
for _, tag := range idx.TaskTags {
|
||
switch tag.Kind {
|
||
case KindRequirements:
|
||
if isInfraRef(tag.ID) {
|
||
continue
|
||
}
|
||
if _, declared := idx.PRDRequirements[tag.ID]; !declared {
|
||
out = append(out, Diagnostic{
|
||
Path: tag.File,
|
||
Line: tag.Line,
|
||
Severity: SevError,
|
||
Code: CodeOrphanRequirement,
|
||
Message: fmt.Sprintf("cites %s, not declared in prd.md", tag.ID),
|
||
})
|
||
continue
|
||
}
|
||
coveredReqs[tag.ID] = true
|
||
case KindDesign:
|
||
if !hasDesign {
|
||
continue
|
||
}
|
||
if _, declared := idx.DesignSections[tag.ID]; !declared {
|
||
out = append(out, Diagnostic{
|
||
Path: tag.File,
|
||
Line: tag.Line,
|
||
Severity: SevError,
|
||
Code: CodeOrphanDesign,
|
||
Message: fmt.Sprintf("cites %s, not declared in design.md", tag.ID),
|
||
})
|
||
continue
|
||
}
|
||
coveredDess[tag.ID] = true
|
||
}
|
||
}
|
||
|
||
for _, id := range sortedIDs(idx.PRDRequirements) {
|
||
if coveredReqs[id] {
|
||
continue
|
||
}
|
||
pos := idx.PRDRequirements[id]
|
||
out = append(out, Diagnostic{
|
||
Path: pos.File,
|
||
Line: pos.Line,
|
||
Severity: SevError,
|
||
Code: CodeUncoveredRequirement,
|
||
Message: fmt.Sprintf("%s has no covering task", id),
|
||
})
|
||
}
|
||
|
||
if hasDesign {
|
||
for _, id := range sortedIDs(idx.DesignSections) {
|
||
if coveredDess[id] {
|
||
continue
|
||
}
|
||
pos := idx.DesignSections[id]
|
||
out = append(out, Diagnostic{
|
||
Path: pos.File,
|
||
Line: pos.Line,
|
||
Severity: SevError,
|
||
Code: CodeUncoveredDesign,
|
||
Message: fmt.Sprintf("%s has no covering task", id),
|
||
})
|
||
}
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
// sortedIDs returns the keys of m in ascending lexicographic order. Used
|
||
// by checkCrossRef to emit uncovered-* diagnostics in stable order prior
|
||
// to the orchestrator's sortDiagnostics pass.
|
||
func sortedIDs(m map[string]Position) []string {
|
||
keys := make([]string, 0, len(m))
|
||
for k := range m {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
return keys
|
||
}
|
||
|
||
// spec:planctl/R3.4+R3.5+R3.6+R3.7+D§3.4
|
||
// checkCloseoutFiles emits missing-closeout-file for a fully-closed-out
|
||
// plan that lacks codex-sessions.md or any handoff*.md file.
|
||
//
|
||
// "Fully closed out" is defined in PRD R3.4: at least one task AND every
|
||
// task Checked == true. A zero-task tasks.md is NOT closed out and never
|
||
// triggers this check. Missing design.md is never flagged here (R3.8) —
|
||
// design presence is an R2.8 concern handled by checkCrossRef.
|
||
//
|
||
// Line is 0 (directory-level diagnostic); Path is left empty and the
|
||
// emitter substitutes the plan-dir basename per design §3.7's
|
||
// `<plandir>:0:` format.
|
||
func checkCloseoutFiles(p *Plan, idx Index) []Diagnostic {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
if len(idx.TaskLines) == 0 {
|
||
return nil
|
||
}
|
||
for _, tl := range idx.TaskLines {
|
||
if !tl.Checked {
|
||
return nil
|
||
}
|
||
}
|
||
var out []Diagnostic
|
||
if !p.CodexLog {
|
||
out = append(out, Diagnostic{
|
||
Line: 0,
|
||
Severity: SevError,
|
||
Code: CodeMissingCloseoutFile,
|
||
Message: "fully closed-out plan has no codex-sessions.md",
|
||
})
|
||
}
|
||
if !p.HandoffFound {
|
||
out = append(out, Diagnostic{
|
||
Line: 0,
|
||
Severity: SevError,
|
||
Code: CodeMissingCloseoutFile,
|
||
Message: "fully closed-out plan has no handoff*.md",
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// spec:planctl/R3.1+R3.2+D§3.4
|
||
// newMissingPRDResult returns the single missing-prd diagnostic produced
|
||
// when a plan directory has no prd.md. Per PRD R3.2 this is a fatal
|
||
// condition: without a PRD there are no R-ids to cross-reference and no
|
||
// acceptance criteria to EARS-check, so the orchestrator (task 5.3) skips
|
||
// R2 and R4 checks for the plan.
|
||
//
|
||
// PlanDir is populated here rather than backfilled by the orchestrator,
|
||
// because this helper is invoked on the short-circuit path and producing
|
||
// a complete Diagnostic keeps the fatal case self-contained.
|
||
func newMissingPRDResult(planDir string) []Diagnostic {
|
||
return []Diagnostic{{
|
||
PlanDir: planDir,
|
||
Line: 0,
|
||
Severity: SevError,
|
||
Code: CodeMissingPRD,
|
||
Message: "plan directory has no prd.md",
|
||
}}
|
||
}
|
||
|
||
// spec:planctl/R4.1+D§3.4
|
||
// earsPatterns enumerates the five EARS keyword forms per design §3.4.
|
||
// Each pattern is anchored to the start of the criterion body (the text
|
||
// after the `R<n.m> ` id prefix) and requires the explicit SHALL or THEN
|
||
// continuation — a bare `WHEN foo bar` fails, which is the codex-R1
|
||
// hardened behavior over the more permissive original draft.
|
||
//
|
||
// The non-greedy `.+?` means each pattern stops at the FIRST comma before
|
||
// testing for SHALL / THEN, so a body with no comma or no SHALL/THEN
|
||
// cannot silently match a prefix.
|
||
var earsPatterns = []*regexp.Regexp{
|
||
regexp.MustCompile(`^THE SYSTEM SHALL\b`),
|
||
regexp.MustCompile(`^WHEN .+?, THE SYSTEM SHALL\b`),
|
||
regexp.MustCompile(`^WHILE .+?, THE SYSTEM SHALL\b`),
|
||
regexp.MustCompile(`^WHERE .+?, THE SYSTEM SHALL\b`),
|
||
regexp.MustCompile(`^IF .+?, THEN (THE SYSTEM SHALL\b|.+\b)`),
|
||
}
|
||
|
||
// earsBoldPrefix is the narrow bold-prefixed phrase exemption documented
|
||
// in design §3.4: a body whose opening-`**` is matched by a closing-`**`
|
||
// on the SAME line is treated as a definitional / descriptive bullet and
|
||
// skipped. An unmatched opening `**` does NOT trigger the exemption, so
|
||
// a malformed requirement that happens to start with `**foo` (missing
|
||
// closer) still trips ears-violation.
|
||
var earsBoldPrefix = regexp.MustCompile(`^\*\*[^*]+\*\*`)
|
||
|
||
// spec:planctl/R4.1+R4.2+R4.3+R4.4+D§3.4
|
||
// checkEars scans PRD R-id declarations and emits ears-violation (at
|
||
// SevWarning per R4.3) for any criterion body that doesn't match one of
|
||
// the five EARS patterns. The body is the text after `R<n.m> ` on the
|
||
// declaration line, as captured in Position.Text during extraction.
|
||
//
|
||
// skip (the --no-ears flag) short-circuits the entire pass per R4.4.
|
||
// Bodies with a matched bold-pair prefix are exempted per the narrow
|
||
// exemption in design §3.4.
|
||
func checkEars(idx Index, skip bool) []Diagnostic {
|
||
if skip {
|
||
return nil
|
||
}
|
||
var out []Diagnostic
|
||
for _, id := range sortedIDs(idx.PRDRequirements) {
|
||
pos := idx.PRDRequirements[id]
|
||
body := earsBody(id, pos.Text)
|
||
if body == "" {
|
||
continue
|
||
}
|
||
if earsBoldPrefix.MatchString(body) {
|
||
continue
|
||
}
|
||
if matchesEars(body) {
|
||
continue
|
||
}
|
||
out = append(out, Diagnostic{
|
||
Path: pos.File,
|
||
Line: pos.Line,
|
||
Severity: SevWarning,
|
||
Code: CodeEARSViolation,
|
||
Message: fmt.Sprintf(
|
||
"%s: body %q does not match an EARS keyword; consider starting with \"WHEN <trigger>, THE SYSTEM SHALL ...\"",
|
||
id, earsBodyPrefix(body),
|
||
),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// spec:planctl/R4.1+D§3.4
|
||
// earsBody returns the portion of lineText after the `R<n.m> ` prefix
|
||
// for id. Bullet markers (`- `) and leading whitespace are stripped so
|
||
// the returned string is the raw criterion body for EARS matching.
|
||
//
|
||
// Returns "" when the line doesn't contain the id followed by a space —
|
||
// that can happen if Position.Text wasn't populated; callers treat "" as
|
||
// "nothing to check".
|
||
func earsBody(id, lineText string) string {
|
||
trimmed := strings.TrimLeft(lineText, " \t-")
|
||
prefix := id + " "
|
||
if !strings.HasPrefix(trimmed, prefix) {
|
||
return ""
|
||
}
|
||
return trimmed[len(prefix):]
|
||
}
|
||
|
||
// spec:planctl/R4.2+D§3.4
|
||
// earsBodyPrefix returns the first ~40 chars of body for the diagnostic
|
||
// message. Trimmed to a word boundary when possible so the quoted excerpt
|
||
// doesn't end mid-word.
|
||
func earsBodyPrefix(body string) string {
|
||
const cap = 40
|
||
if len(body) <= cap {
|
||
return body
|
||
}
|
||
cut := cap
|
||
if idx := strings.LastIndex(body[:cap], " "); idx > 20 {
|
||
cut = idx
|
||
}
|
||
return body[:cut] + "…"
|
||
}
|
||
|
||
// spec:planctl/R4.1+D§3.4
|
||
// matchesEars returns true iff body starts with any of the five EARS
|
||
// patterns. Kept as a named helper because tests may want to assert the
|
||
// matcher in isolation without constructing a full Diagnostic flow.
|
||
func matchesEars(body string) bool {
|
||
for _, p := range earsPatterns {
|
||
if p.MatchString(body) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// spec:planctl/R5.2+D§3.1
|
||
// sortDiagnostics sorts diagnostics in place by (Path, Line, Code,
|
||
// Message) so emitted output is deterministic run-to-run per PRD §6
|
||
// "Diagnostic ordering".
|
||
//
|
||
// Message is the final tie-breaker because checkCloseoutFiles can emit
|
||
// two Line=0 / empty-Path diagnostics that share the same Code; without
|
||
// a Message tie-breaker their relative order would depend on the
|
||
// unspecified behaviour of an unstable sort for equal keys. sort.Stable
|
||
// is additionally used so any insertion-order-derived precedence inside
|
||
// the rule checkers (which is itself deterministic) is preserved for
|
||
// equal keys.
|
||
func sortDiagnostics(diags []Diagnostic) {
|
||
sort.SliceStable(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
|
||
})
|
||
}
|