Files
meshcore-analyzer/cmd/server/openapi_completeness_test.go
T
Kpa-clawbot 653d47e03c test(openapi): add CI completeness gate for /api routes (Phase 1 of #1670) (#1678)
## Summary

Partial fix for #1670 — **Phase 1 only** (CI completeness gate). Phase 2
(backfilling the 18 currently-undocumented routes into `openapi.go`) is
deferred to a separate issue per the triage on #1670 and is explicitly
out of scope here.

## What this adds

- `cmd/server/openapi_completeness_test.go` — AST-walks every
non-`_test.go` file in `cmd/server/`, finds string-literal first args to
`*.HandleFunc(...)` calls beginning with `/api/`, and diffs against the
paths declared in `routeDescriptions()` in `cmd/server/openapi.go`.
- `cmd/server/openapi_known_gaps.json` — seeded allowlist of the **18**
`/api/` routes currently registered via `HandleFunc` but not yet
documented in `openapi.go`.

## Ratchet pattern

From this branch forward, `TestOpenAPICompleteness` fails when:

1. A new `HandleFunc("/api/...")` is added without a matching entry in
`openapi.go` **or** the allowlist (regression gate — the main goal of
Phase 1).
2. A route in the allowlist is *also* documented in `openapi.go` — the
allowlist must shrink as Phase 2 backfills land, never go stale.

The two-commit history (red → green) demonstrates the gate works:

- **Red commit**: adds only the test. Fails on master with the 18
missing routes listed.
- **Green commit**: adds the allowlist seeded with that exact 18-route
set. Test passes at the current baseline.

## Local verification

- `go test ./cmd/server/ -run TestOpenAPICompleteness -v` → PASS at
baseline (`44/62 covered; 18 in allowlist; 18 gaps remain`).
- Ratchet validation: temporarily inserted
`r.HandleFunc("/api/ratchet-test-route", ...)` into `routes.go` → test
FAILED with that exact route name; reverted → test PASSES again.

## Files changed

- `cmd/server/openapi_completeness_test.go` (+203 / new)
- `cmd/server/openapi_known_gaps.json` (+24 / new)

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all hard gates pass; no warnings.

## Out of scope

- Backfilling the 18 allowlisted routes into `openapi.go` (Phase 2 —
tracked separately).
- Schema validation of the spec against OpenAPI 3.0 (Phase 3 per the
issue).
- PR template checkbox update (Phase 2 follow-up).

Issue #1670 stays open for Phase 2.

---------

Co-authored-by: clawbot <bot@corescope.local>
2026-06-12 01:52:12 -07:00

209 lines
6.2 KiB
Go

// Package main: openapi completeness gate.
//
// Phase 1 of issue #1670: enforce that every `/api/*` route registered via
// `*.HandleFunc("/api/...", ...)` in cmd/server/*.go (non-_test) has a
// corresponding entry in the OpenAPI spec map declared in
// cmd/server/openapi.go (the `routeDescriptions` map literal).
//
// Ratchet pattern:
// - On first land, the spec covers only a subset of handlers. The full
// missing list is "frozen" into cmd/server/openapi_known_gaps.json.
// - The test FAILS when a NEW HandleFunc("/api/...") is added without
// either (a) adding the route to openapi.go, or (b) appending it to
// openapi_known_gaps.json.
// - It also FAILS if any entry in openapi_known_gaps.json is now covered
// by openapi.go (the allowlist must shrink as Phase 2 backfills land).
//
// Phase 2 (the actual backfill of ~18 routes into openapi.go) is tracked
// in a separate issue per the triage on #1670. This file is the gate
// that ensures the gap does not GROW while Phase 2 is in progress.
package main
import (
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"os"
"sort"
"strconv"
"strings"
"testing"
)
const knownGapsFile = "openapi_known_gaps.json"
// collectHandlerRoutes walks every non-_test .go file in cmd/server/ and
// returns the set of string-literal first args to any `*.HandleFunc(...)`
// or `*.Handle(...)` call whose value starts with "/api/".
//
// Both forms are used in cmd/server/routes.go: bare handlers use
// `r.HandleFunc("/api/...", fn)`, while handlers wrapped in auth
// middleware use `r.Handle("/api/...", wrapped).Methods("...")`. The
// completeness gate MUST consider both — anything less lets the
// gorilla-style chained routes slip past the ratchet.
func collectHandlerRoutes(t *testing.T) map[string]string {
t.Helper()
out := map[string]string{} // route -> "file:line"
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read cmd/server dir: %v", err)
}
fset := token.NewFileSet()
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, name, nil, parser.AllErrors)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel == nil {
return true
}
if sel.Sel.Name != "HandleFunc" && sel.Sel.Name != "Handle" {
return true
}
if len(call.Args) < 1 {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
v, err := strconv.Unquote(lit.Value)
if err != nil {
return true
}
if !strings.HasPrefix(v, "/api/") {
return true
}
pos := fset.Position(lit.Pos())
if _, exists := out[v]; !exists {
out[v] = pos.String()
}
return true
})
}
return out
}
// strconvUnquote strips Go string-literal quoting without pulling strconv
// into the import list (keeps the file's imports lean).
func strconvUnquote(s string) (string, error) {
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1], nil
}
if len(s) >= 2 && s[0] == '`' && s[len(s)-1] == '`' {
return s[1 : len(s)-1], nil
}
return s, nil
}
// collectSpecRoutes returns the set of "/api/..." paths declared in the
// routeDescriptions() map in openapi.go. Keys are "METHOD /path"; we strip
// the method and take just the path.
func collectSpecRoutes(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
for k := range routeDescriptions() {
// key shape: "GET /api/foo" — split once on space.
idx := strings.IndexByte(k, ' ')
if idx < 0 {
continue
}
path := k[idx+1:]
if strings.HasPrefix(path, "/api/") {
out[path] = true
}
}
return out
}
// loadKnownGaps returns the allowlist of currently-known-missing routes.
// Missing file is treated as an empty allowlist (the initial RED state).
func loadKnownGaps(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
b, err := os.ReadFile(knownGapsFile)
if err != nil {
if os.IsNotExist(err) {
return out
}
t.Fatalf("read %s: %v", knownGapsFile, err)
}
var payload struct {
Routes []string `json:"routes"`
}
if err := json.Unmarshal(b, &payload); err != nil {
t.Fatalf("parse %s: %v", knownGapsFile, err)
}
for _, r := range payload.Routes {
out[r] = true
}
return out
}
// TestOpenAPICompleteness is the ratchet gate for issue #1670.
func TestOpenAPICompleteness(t *testing.T) {
handlers := collectHandlerRoutes(t)
spec := collectSpecRoutes(t)
gaps := loadKnownGaps(t)
// 1. Find routes registered via HandleFunc but missing from spec AND
// not in the allowlist — these are new regressions.
var newMissing []string
for route := range handlers {
if spec[route] {
continue
}
if gaps[route] {
continue
}
newMissing = append(newMissing, route)
}
sort.Strings(newMissing)
// 2. Find allowlist entries that are now covered by the spec — the
// allowlist must shrink, not stay stale.
var stale []string
for route := range gaps {
if spec[route] {
stale = append(stale, route)
}
}
sort.Strings(stale)
// 3. (Diagnostic only) Total current gap count, for visibility.
var currentGaps []string
for route := range handlers {
if !spec[route] {
currentGaps = append(currentGaps, route)
}
}
sort.Strings(currentGaps)
t.Logf("openapi spec covers %d/%d /api/ handler routes; %d in allowlist; %d total gaps remain",
len(handlers)-len(currentGaps), len(handlers), len(gaps), len(currentGaps))
if len(newMissing) > 0 {
t.Errorf("\n%d /api/ route(s) registered in cmd/server but NOT in openapi.go spec AND NOT in %s:\n - %s\n\nFix one of:\n a) Add the route to routeDescriptions() in cmd/server/openapi.go (preferred — Phase 2 of #1670)\n b) Append the route to cmd/server/%s (ratchet — only if Phase 2 backfill is genuinely deferred)\n",
len(newMissing), knownGapsFile, strings.Join(newMissing, "\n - "), knownGapsFile)
}
if len(stale) > 0 {
t.Errorf("\n%d route(s) in %s are now covered by openapi.go and must be REMOVED from the allowlist (ratchet must shrink):\n - %s\n",
len(stale), knownGapsFile, strings.Join(stale, "\n - "))
}
}