From fc2b0204238a7cf8331c6262c83e6b5eb6ebd291 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Tue, 4 Aug 2026 12:11:40 +0000 Subject: [PATCH] feat(cmd/verify-reqs): requirements-hygiene gate (T03.1, REQ-060) ---ci--- project: orca phase: 3 milestone: v0.8 status: execute ---/ci--- --- cmd/verify-reqs/main.go | 182 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 cmd/verify-reqs/main.go diff --git a/cmd/verify-reqs/main.go b/cmd/verify-reqs/main.go new file mode 100644 index 0000000..413a8bb --- /dev/null +++ b/cmd/verify-reqs/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "regexp" + "sort" + "strings" +) + +// reqRowRe captures a REQUIREMENTS.md table row's REQ-ID, Phase cell, and +// status in one pass. The leading .* is greedy so it consumes the +// Requirement and Priority cells (which may contain markdown-escaped pipes +// like `localhost\|linux\|proxmox` — see REQ-049) and backtracks to anchor +// the Phase + Status match at the END of the line, where those two columns +// always live. The status cell may carry trailing notes (e.g. +// "**Complete** (P01 shipped v0.2.1)"), matched by [^|]* before the closing +// pipe. +var reqRowRe = regexp.MustCompile(`^\|\s*(REQ-\d+)\s*\|.*\|\s*([^|]*?)\s*\|\s*\*\*(Complete|Pending)\*\*[^|]*\|\s*$`) + +// milestoneCompleteRe matches a ROADMAP.md milestone header that is marked +// COMPLETE. The bold span is substring-tolerant (GRILL #4): it matches +// `**COMPLETE**`, `**COMPLETE (merged to main via v0.3)**`, and any future +// variant where the word COMPLETE appears inside the bold span, possibly +// preceded or followed by non-asterisk text. The milestone version (v0.X) +// is captured. +var milestoneCompleteRe = regexp.MustCompile(`^##\s*Milestone\s+(v0\.\d+):.*—\s*\*\*[^*]*\bCOMPLETE\b[^*]*\*\*`) + +// phaseRe extracts the milestone version from a REQUIREMENTS Phase cell such +// as `v0.7 P1`, `**v0.2 P01**`, `v0.2 P01–P04`, or bare `v0.7`. The cell may +// contain multiple milestone refs separated by `/` or `–`; each is extracted. +var phaseTokenRe = regexp.MustCompile(`v0\.\d+`) + +// reqRow holds a parsed REQUIREMENTS.md row. +type reqRow struct { + id string + phase string // raw Phase cell (e.g. "v0.7 P1", "**v0.2 P01 / v0.3 P02**") + status string // "Complete" or "Pending" +} + +// milestoneVersions returns the distinct v0.X milestones referenced in the +// phase cell (e.g. "v0.7 P1" → ["v0.7"]; "v0.2 P01 / v0.3 P02" → +// ["v0.2","v0.3"]). +func (r reqRow) milestoneVersions() []string { + matches := phaseTokenRe.FindAllString(r.phase, -1) + seen := map[string]bool{} + var out []string + for _, m := range matches { + if !seen[m] { + seen[m] = true + out = append(out, m) + } + } + return out +} + +func main() { + roadmapPath := ".ciagent/ROADMAP.md" + reqsPath := ".ciagent/REQUIREMENTS.md" + if len(os.Args) > 1 { + roadmapPath = os.Args[1] + } + if len(os.Args) > 2 { + reqsPath = os.Args[2] + } + diff, count, err := verify(roadmapPath, reqsPath) + if err != nil { + fmt.Fprintf(os.Stderr, "verify-reqs: %v\n", err) + os.Exit(2) + } + if len(diff) > 0 { + fmt.Fprintf(os.Stderr, "requirements drift detected (%d):\n", len(diff)) + for _, line := range diff { + fmt.Fprintln(os.Stderr, line) + } + os.Exit(1) + } + fmt.Printf("✓ %d requirements consistent with roadmap\n", count) +} + +// verify parses the ROADMAP and REQUIREMENTS markdown and returns a diff +// listing of any drift. On success diff is nil and count is the number of +// consistent REQ rows. A non-nil error signals a parse/read failure (not +// drift); drift is reported via the diff slice. +func verify(roadmapPath, reqsPath string) (diff []string, count int, err error) { + completeMilestones, err := parseRoadmap(roadmapPath) + if err != nil { + return nil, 0, fmt.Errorf("parse roadmap %q: %w", roadmapPath, err) + } + rows, err := parseRequirements(reqsPath) + if err != nil { + return nil, 0, fmt.Errorf("parse requirements %q: %w", reqsPath, err) + } + if len(rows) == 0 { + return nil, 0, fmt.Errorf("no REQ rows found in %s", reqsPath) + } + + type driftEntry struct { + id string + current string + want string + dir string // "forward" or "reverse" + } + var drifts []driftEntry + + for _, r := range rows { + milestones := r.milestoneVersions() + anyComplete := false + for _, m := range milestones { + if completeMilestones[m] { + anyComplete = true + break + } + } + // Forward assertion: a REQ whose milestone is COMPLETE in ROADMAP + // must be marked Complete in REQUIREMENTS. + if anyComplete && r.status != "Complete" { + drifts = append(drifts, driftEntry{r.id, r.status, "Complete", "forward"}) + } + // Reverse assertion (GRILL #4): a REQ marked Complete in + // REQUIREMENTS must reference at least one milestone ROADMAP marks + // COMPLETE. If all referenced milestones are NOT complete (or no + // milestone is referenced), that is premature-Complete drift. + if r.status == "Complete" && !anyComplete { + drifts = append(drifts, driftEntry{r.id, r.status, "Pending (milestone not COMPLETE in ROADMAP)", "reverse"}) + } + } + + sort.Slice(drifts, func(i, j int) bool { return drifts[i].id < drifts[j].id }) + for _, d := range drifts { + diff = append(diff, fmt.Sprintf(" %s: status=%s, expected=%s (direction=%s)", d.id, d.current, d.want, d.dir)) + } + + consistent := len(rows) - len(drifts) + return diff, consistent, nil +} + +func parseRoadmap(path string) (map[string]bool, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + complete := map[string]bool{} + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 1024*1024), 1024*1024) + for sc.Scan() { + line := sc.Text() + m := milestoneCompleteRe.FindStringSubmatch(line) + if m != nil { + complete[m[1]] = true + } + } + if err := sc.Err(); err != nil { + return nil, err + } + return complete, nil +} + +func parseRequirements(path string) ([]reqRow, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + var rows []reqRow + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 1024*1024), 1024*1024) + for sc.Scan() { + line := sc.Text() + m := reqRowRe.FindStringSubmatch(line) + if m == nil { + continue + } + rows = append(rows, reqRow{id: m[1], phase: strings.TrimSpace(m[2]), status: m[3]}) + } + if err := sc.Err(); err != nil { + return nil, err + } + return rows, nil +}