mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:41:30 +00:00
558d350933
Two CI guards on origin/master failed against the Sprint-12 commit (30940108) because they didn't know about new files introduced by earlier Phase 9 sprints. Both are pure mechanical relocation fall-out — no actual regression in functionality. 1. scripts/ci-guards/no-new-synthetic-admin.sh — A-8 guard ==================================================================== Sprint 5 (commit51f9cf13) extracted the Auth-family from internal/config/config.go to internal/config/auth.go. The 4 'actor-demo-anon' references moved with the Auth-family code: - Line 255: 'actor-demo-anon is wired with AdminKey=true' documentation comment alongside the AdminKey wiring narrative. - Lines 283/289/293: residual-grants detector + cleanup SQL examples explaining why 'ar-demo-anon-admin' is reserved. These are the SAME comments that were previously in config.go (which IS in the allowlist), just relocated to the new sibling file. The references were always present in the codebase; the A-8 guard was just unaware of the new file location. Fix: add './internal/config/auth.go' to the ALLOWLIST with a rationale comment pointing at commit51f9cf13. Local verification: A-8 guard PASS — actor-demo-anon references confined to the declared 19-entry allowlist (was 18, now 19). 2. internal/ciparity/surface_parity_test.go — mcpToolFiles list ==================================================================== Sprint 10 (commitfbe053aa) split internal/mcp/tools.go (1867 LOC, 121 mcp.AddTool registrations) into six tool-domain sibling files: tools_certificates.go (22 tools — cert + CRL/OCSP + renewal + verify) tools_agents.go (16 tools — agents + agent groups) tools_resources.go (40 tools — issuers + targets + policies + profiles + teams + owners + notifications + intermediate-CAs) tools_jobs.go (9 tools — jobs + approvals) tools_discovery.go (10 tools — network-scan + discovery) tools_admin.go (24 tools — audit + stats + digest + metrics + health + health-check) The TestSurfaceParity_MCPToolCatalogue hard-gate counts mcp.AddTool registrations across mcpToolFiles() — a hard-coded 5-file list. After the split, only 34 tools sat in the 5 known files (tools.go itself went to 0 tools post-split; only the 4 pre-existing tools_*.go siblings carried any). The actual cross-file count is 155 (above the 150 floor). Fix: expand mcpToolFiles() to include the 6 new Sprint-10 sibling files. Doc-comment explains the Sprint-10 split + the union-of-files intent. Local verification: PASS: TestSurfaceParity_MCPToolCatalogue MCP tool catalogue: 155 tools (baseline floor 150) 3. docs/testing/skip-inventory.md — line-number drift ==================================================================== Adding the 8-line doc-comment to mcpToolFiles() (item 2) shifted the location of readFileOrSkip from line 97 to line 113 in surface_parity_test.go. The skip-inventory.md is auto-generated and records every t.Skip() site with its file:line; the skip-inventory-drift CI guard re-runs the generator and diffs. Fix: bump the inventory entry from :97 to :113. One-line tracking update; same skip site, new line number. (No t.Skip() was added or removed.) Behavior preservation contract ============================== - Zero runtime change. All three diffs touch only CI-guard metadata (allowlist string, file-list slice, doc line-number). - A-8 guard re-runs clean post-fix. - TestSurfaceParity_MCPToolCatalogue runs and reports 155 tools. - skip-inventory drift detection re-pins to the live line number. - gofmt + go vet + staticcheck remain clean on the touched files (verified pre-commit; the sandbox /sessions partition is full so the broader 'all guards' loop was interrupted on a tmpfile write, not on a real regression — the deterministic fix above matches the CI failure output byte-for-byte). Closes: CI failures on commit30940108across Frontend Build (A-8 guard) + Go Build & Test (TestSurfaceParity_MCPToolCatalogue).
309 lines
11 KiB
Go
309 lines
11 KiB
Go
package ciparity
|
|
|
|
// surface_parity_test.go — per post-v2.1.0 anti-rot item 2 (Auditable
|
|
// Codebase Bundle).
|
|
//
|
|
// Three tests, all stdlib-only:
|
|
//
|
|
// 1. TestSurfaceParity_MCPToolCatalogue (HARD GATE)
|
|
// Every MCP tool name matches the `certctl_<word>(_<word>)*`
|
|
// convention; no duplicates across files; total count ≥
|
|
// mcpBaselineFloor. Catches accidental tool deletions + naming-
|
|
// convention drift.
|
|
//
|
|
// 2. TestSurfaceParity_CLICommandCatalogue (INFORMATIONAL — t.Log only)
|
|
// Walks cmd/cli/main.go's switch-case dispatcher. Per frozen
|
|
// decision 0.9, warn-only until the CLI surface stabilizes.
|
|
//
|
|
// 3. TestSurfaceParity_OpenAPI_MCPHeuristicCoverage (INFORMATIONAL)
|
|
// Reports the fraction of OpenAPI operations whose path tokens
|
|
// overlap with any MCP tool name token. Trend metric, not a gate.
|
|
//
|
|
// 4. TestSurfaceParity_Summary (INFORMATIONAL)
|
|
// One-glance summary of the four surface counts.
|
|
//
|
|
// All file paths are resolved relative to the repo root, which this
|
|
// test discovers by walking up from $PWD until it finds go.mod. Keeps
|
|
// the test runnable from any working directory.
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// mcpBaselineFloor — see header doc. Bump when a deletion is
|
|
// deliberate; the diff captures the change.
|
|
//
|
|
// Phase 3 ARCH-H3 reconciliation (2026-05-13): the audit framing
|
|
// "121 vs floor 150 — doc/code drift" was a measurement scoping error
|
|
// — the audit counted only internal/mcp/tools.go (which has 121
|
|
// AddTool calls) and missed the four sibling files listed in
|
|
// mcpToolFiles() below (tools_audit_fix.go + tools_auth.go +
|
|
// tools_auth_bundle2.go + tools_est.go) that add another 34 unique
|
|
// names. Live total: 155 unique `Name: "certctl_*"` declarations
|
|
// across the 5 files, ≥ 150. This test therefore passes today.
|
|
//
|
|
// Bumping the floor: when the catalogue legitimately grows, raise
|
|
// this constant in the same commit that adds the new tools so the
|
|
// floor tracks the ratchet. Lower only when a deletion is intentional
|
|
// and documented in surface-parity-mcp-exemptions.yaml.
|
|
const mcpBaselineFloor = 150
|
|
|
|
var (
|
|
mcpToolNameRe = regexp.MustCompile(`^certctl_[a-z][a-z0-9_]*[a-z0-9]$`)
|
|
mcpNameDeclRe = regexp.MustCompile(`Name:\s*"(certctl_[a-z0-9_]+)"`)
|
|
openapiPathRe = regexp.MustCompile(`^ (/[^:]+):\s*$`)
|
|
openapiVerbRe = regexp.MustCompile(`^ (get|post|put|delete|patch|options|head):\s*$`)
|
|
caseLiteralRe = regexp.MustCompile(`case\s+"([a-z\-]+)":`)
|
|
)
|
|
|
|
// mcpToolFiles lists the (non-test) Go files expected to register
|
|
// MCP tools.
|
|
//
|
|
// Phase 9 Sprint 10 (commit fbe053aa, 2026-05-14): tools.go was split
|
|
// into six tool-domain sibling files in the same `mcp` package
|
|
// (tools_certificates.go + tools_agents.go + tools_resources.go +
|
|
// tools_jobs.go + tools_discovery.go + tools_admin.go). Original
|
|
// tools.go now holds only the RegisterTools dispatcher + Bundle-3
|
|
// fence wrappers + paginationQuery helper — zero mcp.AddTool calls.
|
|
// This list is the union of pre-Sprint-10 + Sprint-10 sibling files.
|
|
func mcpToolFiles(repo string) []string {
|
|
base := filepath.Join(repo, "internal", "mcp")
|
|
return []string{
|
|
// Pre-Sprint-10 catalogue.
|
|
filepath.Join(base, "tools.go"),
|
|
filepath.Join(base, "tools_audit_fix.go"),
|
|
filepath.Join(base, "tools_auth.go"),
|
|
filepath.Join(base, "tools_auth_bundle2.go"),
|
|
filepath.Join(base, "tools_est.go"),
|
|
// Phase 9 Sprint 10 sibling files.
|
|
filepath.Join(base, "tools_certificates.go"),
|
|
filepath.Join(base, "tools_agents.go"),
|
|
filepath.Join(base, "tools_resources.go"),
|
|
filepath.Join(base, "tools_jobs.go"),
|
|
filepath.Join(base, "tools_discovery.go"),
|
|
filepath.Join(base, "tools_admin.go"),
|
|
}
|
|
}
|
|
|
|
func findRepoRoot(t *testing.T) string {
|
|
t.Helper()
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("getwd: %v", err)
|
|
}
|
|
cur := wd
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(cur, "go.mod")); err == nil {
|
|
return cur
|
|
}
|
|
parent := filepath.Dir(cur)
|
|
if parent == cur {
|
|
t.Fatalf("no go.mod found from %s upward", wd)
|
|
}
|
|
cur = parent
|
|
}
|
|
}
|
|
|
|
// readFileOrSkip reads a file; on ENOENT, calls t.Skipf rather than
|
|
// failing — useful for files that may be renamed during refactors.
|
|
func readFileOrFail(t *testing.T, p string) []byte {
|
|
t.Helper()
|
|
body, err := os.ReadFile(p)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("expected file missing: %s (refactor? — update the test)", p)
|
|
}
|
|
t.Fatalf("read %s: %v", p, err)
|
|
}
|
|
return body
|
|
}
|
|
|
|
// TestSurfaceParity_MCPToolCatalogue is a HARD gate.
|
|
//
|
|
// Asserts:
|
|
//
|
|
// - Every MCP tool name conforms to certctl_<word>(_<word>)*.
|
|
// - No tool name is registered in more than one tools*.go file.
|
|
// - Total tools ≥ mcpBaselineFloor.
|
|
func TestSurfaceParity_MCPToolCatalogue(t *testing.T) {
|
|
repo := findRepoRoot(t)
|
|
names := map[string]string{}
|
|
for _, path := range mcpToolFiles(repo) {
|
|
body := readFileOrFail(t, path)
|
|
base := filepath.Base(path)
|
|
for _, m := range mcpNameDeclRe.FindAllStringSubmatch(string(body), -1) {
|
|
name := m[1]
|
|
if !mcpToolNameRe.MatchString(name) {
|
|
t.Errorf("MCP tool name %q in %s does not match certctl_<word>(_<word>)* — fix the name or relax the convention deliberately",
|
|
name, base)
|
|
continue
|
|
}
|
|
if other, dup := names[name]; dup {
|
|
t.Errorf("MCP tool name %q duplicated: first in %s, again in %s — pick a unique name",
|
|
name, filepath.Base(other), base)
|
|
continue
|
|
}
|
|
names[name] = path
|
|
}
|
|
}
|
|
if len(names) < mcpBaselineFloor {
|
|
t.Errorf("MCP tool count regressed: %d found, baseline floor %d. "+
|
|
"If the deletion is intentional, lower mcpBaselineFloor in this test in the SAME commit. "+
|
|
"If accidental, restore the deleted tools.",
|
|
len(names), mcpBaselineFloor)
|
|
}
|
|
t.Logf("MCP tool catalogue: %d tools (baseline floor %d)", len(names), mcpBaselineFloor)
|
|
}
|
|
|
|
// TestSurfaceParity_CLICommandCatalogue — informational only.
|
|
//
|
|
// Walks cmd/cli/main.go's switch-case dispatcher and reports the
|
|
// distinct verbs handled. Returns success regardless of contents.
|
|
// Promoted to fail-on-miss when the CLI surface stabilizes.
|
|
func TestSurfaceParity_CLICommandCatalogue(t *testing.T) {
|
|
repo := findRepoRoot(t)
|
|
body := readFileOrFail(t, filepath.Join(repo, "cmd", "cli", "main.go"))
|
|
verbs := map[string]struct{}{}
|
|
for _, m := range caseLiteralRe.FindAllStringSubmatch(string(body), -1) {
|
|
verbs[m[1]] = struct{}{}
|
|
}
|
|
if len(verbs) == 0 {
|
|
t.Fatal("CLI scanner found zero verbs — likely a refactor; update the test")
|
|
}
|
|
out := make([]string, 0, len(verbs))
|
|
for v := range verbs {
|
|
out = append(out, v)
|
|
}
|
|
sort.Strings(out)
|
|
t.Logf("CLI verb catalogue (%d distinct case literals; informational only per frozen decision 0.9):\n %s",
|
|
len(out), strings.Join(out, ", "))
|
|
}
|
|
|
|
// scanOpenAPIOps walks openapi.yaml's paths block and returns every
|
|
// (METHOD, PATH) tuple. Mirrors the parser used by
|
|
// internal/api/router/openapi_parity_test.go::scanOpenAPIOperations.
|
|
func scanOpenAPIOps(t *testing.T, path string) []string {
|
|
t.Helper()
|
|
body := readFileOrFail(t, path)
|
|
var out []string
|
|
inPaths := false
|
|
currentPath := ""
|
|
for _, line := range strings.Split(string(body), "\n") {
|
|
if strings.HasPrefix(line, "paths:") {
|
|
inPaths = true
|
|
continue
|
|
}
|
|
if inPaths && line != "" && !strings.HasPrefix(line, " ") {
|
|
inPaths = false
|
|
continue
|
|
}
|
|
if !inPaths {
|
|
continue
|
|
}
|
|
if m := openapiPathRe.FindStringSubmatch(line); m != nil {
|
|
currentPath = m[1]
|
|
continue
|
|
}
|
|
if m := openapiVerbRe.FindStringSubmatch(line); m != nil && currentPath != "" {
|
|
out = append(out, strings.ToUpper(m[1])+" "+currentPath)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// scanRouterRoutes scans internal/api/router/router.go for route
|
|
// registrations. Uses a regex (not go/ast) to keep this package
|
|
// stdlib-only; the router.go file is large but the patterns we care
|
|
// about are stable enough that regex is sufficient.
|
|
func scanRouterRoutes(t *testing.T, path string) []string {
|
|
t.Helper()
|
|
body := readFileOrFail(t, path)
|
|
// Match r.Register("METHOD /path", ...) and r.mux.Handle("METHOD /path", ...).
|
|
registerRe := regexp.MustCompile(`r\.Register\(\s*"([A-Z]+ /[^"]+)"`)
|
|
muxHandleRe := regexp.MustCompile(`r\.mux\.Handle\(\s*"([A-Z]+ /[^"]+)"`)
|
|
seen := map[string]struct{}{}
|
|
for _, m := range registerRe.FindAllStringSubmatch(string(body), -1) {
|
|
seen[m[1]] = struct{}{}
|
|
}
|
|
for _, m := range muxHandleRe.FindAllStringSubmatch(string(body), -1) {
|
|
seen[m[1]] = struct{}{}
|
|
}
|
|
out := make([]string, 0, len(seen))
|
|
for s := range seen {
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestSurfaceParity_OpenAPI_MCPHeuristicCoverage — informational.
|
|
//
|
|
// For each OpenAPI operation, splits the path into tokens; if any
|
|
// token appears in any MCP tool name's token set, the op is "covered."
|
|
// This is a heuristic — the actual semantic map (operationId →
|
|
// MCP tool) is not declared in the source, so we approximate.
|
|
func TestSurfaceParity_OpenAPI_MCPHeuristicCoverage(t *testing.T) {
|
|
repo := findRepoRoot(t)
|
|
specOps := scanOpenAPIOps(t, filepath.Join(repo, "api", "openapi.yaml"))
|
|
mcpTokens := map[string]struct{}{}
|
|
for _, path := range mcpToolFiles(repo) {
|
|
body := readFileOrFail(t, path)
|
|
for _, m := range mcpNameDeclRe.FindAllStringSubmatch(string(body), -1) {
|
|
for _, tok := range strings.Split(strings.TrimPrefix(m[1], "certctl_"), "_") {
|
|
mcpTokens[tok] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
covered := 0
|
|
for _, op := range specOps {
|
|
parts := strings.Split(op, " ")
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
segs := strings.FieldsFunc(parts[1], func(r rune) bool {
|
|
return r == '/' || r == '{' || r == '}' || r == '-'
|
|
})
|
|
hit := false
|
|
for _, s := range segs {
|
|
if _, ok := mcpTokens[strings.ToLower(s)]; ok {
|
|
hit = true
|
|
break
|
|
}
|
|
}
|
|
if hit {
|
|
covered++
|
|
}
|
|
}
|
|
if len(specOps) == 0 {
|
|
t.Fatal("openapi.yaml scan returned zero operations — fix the test")
|
|
}
|
|
pct := (covered * 100) / len(specOps)
|
|
t.Logf("OpenAPI↔MCP heuristic coverage (informational): %d of %d ops (%d%%) share at least one path token with an MCP tool name",
|
|
covered, len(specOps), pct)
|
|
}
|
|
|
|
// TestSurfaceParity_Summary prints the four surface counts side-by-side.
|
|
func TestSurfaceParity_Summary(t *testing.T) {
|
|
repo := findRepoRoot(t)
|
|
routes := scanRouterRoutes(t, filepath.Join(repo, "internal", "api", "router", "router.go"))
|
|
specOps := scanOpenAPIOps(t, filepath.Join(repo, "api", "openapi.yaml"))
|
|
mcpCount := 0
|
|
for _, path := range mcpToolFiles(repo) {
|
|
body := readFileOrFail(t, path)
|
|
mcpCount += len(mcpNameDeclRe.FindAllStringSubmatch(string(body), -1))
|
|
}
|
|
cliBody := readFileOrFail(t, filepath.Join(repo, "cmd", "cli", "main.go"))
|
|
cliCount := len(caseLiteralRe.FindAllStringSubmatch(string(cliBody), -1))
|
|
t.Logf("Surface parity summary (informational):\n"+
|
|
" router routes : %d\n"+
|
|
" OpenAPI ops : %d\n"+
|
|
" MCP tools : %d (floor %d)\n"+
|
|
" CLI verbs : %d (warn-only — frozen decision 0.9)",
|
|
len(routes), len(specOps), mcpCount, mcpBaselineFloor, cliCount)
|
|
}
|