016039d1a3
---ci--- project: orca phase: 3 milestone: v0.8 status: execute ---/ci---
184 lines
5.7 KiB
Go
184 lines
5.7 KiB
Go
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
|
||
}
|