mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-27 10:11:40 +00:00
653d47e03c
## 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>
209 lines
6.2 KiB
Go
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 - "))
|
|
}
|
|
}
|
|
|