template-jj/cmd/planctl/lint.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

372 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 R1R4 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
})
}