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 token is optionally wrapped in markdown bold // (real rows use `**Complete**`; synthetic/future rows may use bare // `Pending`), and 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*\*{0,2}(Complete|Pending)\*{0,2}[^|]*\|\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 }