b6dd86fdf3
- README: status banner v0.12+v0.13, latest tag v0.12.10, subcommand table expanded (auth/nft/peer-setup/secrets rotate-master), "mTLS by default" corrected to "SSH-push canonical", docs table updated - docs/cli.md: complete rewrite (521->1465 lines), all ~40 subcommands - CHANGELOG: regenerated from git log (v0.11.29..HEAD) - help text: job run HCL->markdown, job stop daemon->SSH-push - docs/security-runbook.md: expanded to match P05 reality (seal/unseal, doctor audit/modes/oidc, incident response) - docs/webauthn.md: added auth register (P06) - docs/namespace.md: added inherit + set-constraint - internal/proxmox/bootstrap.go: comments password->key auth - internal/cli/status.go: deprecation warning - scripts/verify-docs.sh + make verify-docs: cli.md <-> orca --help - cmd/verify-reqs/main.go: fix bold-format regex (was bypassing v0.12) + case-insensitive status matching - .ciagent/REQUIREMENTS.md: v0.12 REQs marked complete - .ciagent/ROADMAP.md: v0.12 bolded COMPLETE ---ci--- project: orca phase: 11 milestone: v0.13 status: complete requirements: covered: [160] ---/ci---
198 lines
6.3 KiB
Go
198 lines
6.3 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` or `complete` in any case), and may carry trailing notes
|
||
// (e.g. "**Complete** (P01 shipped v0.2.1)") matched by [^|]* before
|
||
// the closing pipe. The (?i) flag makes the match case-insensitive so
|
||
// lowercase `pending` (used by v0.12/v0.13 REQ rows) is captured;
|
||
// normalizeStatus canonicalizes the captured value to title case.
|
||
var reqRowRe = regexp.MustCompile(`(?i)^\|\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 markers are optional (GRILL #4 + REQ-160 T11): it
|
||
// matches `**COMPLETE**`, `**COMPLETE (merged to main via v0.3)**`, and
|
||
// bare `COMPLETE` (as used by the v0.12 milestone header). The word
|
||
// COMPLETE may be preceded or followed by non-asterisk text. The milestone
|
||
// version (v0.X) is captured.
|
||
var milestoneCompleteRe = regexp.MustCompile(`^##\s*Milestone\s+(v0\.\d+):.*—\s*\*{0,2}[^*]*\bCOMPLETE\b[^*]*\*{0,2}`)
|
||
|
||
// 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"
|
||
}
|
||
|
||
// normalizeStatus canonicalizes a captured status token to the title-case
|
||
// form ("Complete" or "Pending") so that case-insensitive matches like
|
||
// "pending" or "complete" compare correctly against the drift assertions.
|
||
func normalizeStatus(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return s
|
||
}
|
||
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
|
||
}
|
||
|
||
// 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: normalizeStatus(m[3])})
|
||
}
|
||
if err := sc.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return rows, nil
|
||
}
|