go run ./scripts/generate-openapi [--generate-missing] [--validate]
Two independent fixes, both reproduced and verified in /tmp/repro-openapi (Go 1.26, module repro).
A goconst slice added scripts/generate-openapi/constants.go:
package main
const (
// StrSpecsOpenapiYaml is the generated OpenAPI output path.
StrSpecsOpenapiYaml = "specs/openapi-generated.yaml"
// StrSystem is the API base path used in the generated spec's server URL.
StrSystem = "/system"
)
main.go references both constants. go run scripts/generate-openapi/main.go compiles only main.go (command-line-arguments), so constants.go (and any sibling like collect.go) is invisible and the build dies with undefined: StrSpecsOpenapiYaml / undefined: StrSystem. The fix is to run the whole package:
# BROKEN — compiles main.go in isolation
go run scripts/generate-openapi/main.go
# FIXED — compiles the entire scripts/generate-openapi package
go run ./scripts/generate-openapi [--generate-missing] [--validate]
scripts/generate-openapi/generate.sh (and any CI/Makefile/README invocation) must use the package form:
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../.."
go run ./scripts/generate-openapi "$@"
Str* constantsThe route scanner matches r.Group(...), r.GET(...), etc. A regex that only accepts string literals (r\.Group\("...") silently drops registrations written with constants (v1 := r.Group(StrV1X), r.GET(StrSystem+"/ping")) — the generated spec loses whole route trees and Spec Validation goes red.
The fix adds loadStringConstants (parses const X = "...", const X string = "...", and grouped const ( ... ) blocks across the app tree) and resolves identifiers — bare and package-qualified (consts.StrV1X) — inside path expressions:
// constLineRe: const Name [string] = "value"
var constLineRe = regexp.MustCompile(`\bconst\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:string)?\s*=\s*"([^"]*)"`)
// constBlockRe: const ( ... )
var constBlockRe = regexp.MustCompile(`(?m)\bconst\s*\(([^)]*)\)`)
func loadStringConstants(dirs ...string) map[string]string {
consts := map[string]string{}
for _, dir := range dirs {
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") ||
strings.HasSuffix(path, "_test.go") {
return nil
}
src, _ := os.ReadFile(path)
text := string(src)
for _, m := range constBlockRe.FindAllStringSubmatch(text, -1) { // grouped first
for _, e := range constEntryRe.FindAllStringSubmatch(m[1], -1) {
consts[e[1]] = e[2]
}
}
rest := constBlockRe.ReplaceAllString(text, "") // single-line outside blocks
for _, m := range constLineRe.FindAllStringSubmatch(rest, -1) {
consts[m[1]] = m[2]
}
return nil
})
}
return consts
}
// resolveIdentifier: bare (StrV1X) or package-qualified (consts.StrV1X).
func resolveIdentifier(tok string, consts map[string]string) (string, bool) {
if v, ok := consts[tok]; ok {
return v, true
}
if i := strings.LastIndexByte(tok, '.'); i >= 0 {
if v, ok := consts[tok[i+1:]]; ok {
return v, true
}
}
return "", false
}
// extractPath: "literal" | StrV1X | consts.StrV1X | StrSystem + "/ping"
// Unresolvable identifiers yield ok=false → the route is dropped with a
// verbose warning instead of being emitted with a wrong path.
func extractPath(expr string, consts map[string]string, resolve bool) (string, bool) {
var b strings.Builder
for _, part := range splitConcat(strings.TrimSpace(expr)) { // top-level '+'
part = strings.TrimSpace(part)
if part == "" {
continue
}
if len(part) >= 2 && (part[0] == '"' || part[0] == '\'') {
b.WriteString(part[1 : len(part)-1])
continue
}
if !resolve {
return "", false // pre-fix regex behavior
}
if v, ok := resolveIdentifier(part, consts); ok {
b.WriteString(v)
continue
}
return "", false
}
return b.String(), true
}
collectRoutes then (1) resolves group prefixes first — v1 := r.Group(consts.StrV1X) registers v1 → "/v1x" — and (2) prefixes every v1.GET("/users") with its group. Comments are stripped before matching so commented-out examples never leak into the spec.
go run ./scripts/generate-openapi --generate-missing && git diff --exit-code -- specs/openapi-generated.yaml # must be zero diff
--generate-missing is a no-op when the spec is fresh, rewrites when missing/out of date, so git diff --exit-code fails (exit 1) on any drift — including the silent route drops from before the fix.
Reproduced end-to-end in `/tmp/repro-openapi` (module `repro`, Go 1.26.0 linux/amd64):
| # | Verification | Result |
|---|--------------|--------|
| 1 | `go run scripts/generate-openapi/main.go` (single-file form, constants.go present) | **Fails** — `undefined: StrSpecsOpenapiYaml`, `undefined: StrSystem` (and sibling-file symbols), exit 1 |
| 2 | `go run ./scripts/generate-openapi` (package form) | **Succeeds** — spec written with 4 paths |
| 3 | `-v` trace shows group resolution: `group v1 = /v1x`, `group sys = /system`; routes `/v1x/users`, `/system/version`, `/system/ping` added | constant registration recovered |
| 4 | `--no-resolve-constants` (pre-fix simulation) | Spec shrinks to 1 path (`/health`); `/v1x/*`, `/system/*` silently dropped — reproduces the reported symptom |
| 5 | `--validate` with fixed spec | `GREEN spec validation passed (4 paths)`, exit 0 |
| 6 | `--validate` with pre-fix (stale) spec | `RED spec validation failed — missing: /v1x/users, /system/version, /system/ping`, exit 1 |
| 7 | Freshness gate on fresh committed HEAD | **ZERO DIFF — PASS**, exit 0 |
| 8 | Freshness gate on stale committed HEAD | `out of date, rewriting` → `git diff --exit-code` exit 1 (drift caught) |
| 9 | `./scripts/generate-openapi/generate.sh --generate-missing` then `git diff --exit-code` | zero diff, wrapper OK |
| 10 | `go test ./scripts/generate-openapi/` | **7/7 tests pass** |
Edge cases covered by tests:
- **Typed constant** `const StrV1X string = "/v1x"` (used in `internal/consts`) resolves correctly.
- **Grouped const blocks** (`const ( StrSystem = "/system" ... )`) vs single-line decls — no double matching, non-string entries (`LocalCount = 3`) and const-to-const refs (`Derived = StrSystem`) ignored.
- **Package-qualified refs** `consts.StrV1X` / `consts.StrSystem + "/ping"` resolve; unknown identifiers (`StrNope`) are dropped, not mis-emitted.
- **Concatenation** splits only on top-level `+` — `"/pi+ng"` inside a quoted literal survives.
- **Comment hygiene** — `// r.GET("/secret", nil)` and `/* */` examples never enter the spec; `"// not a comment"` string literals are untouched.
- **Determinism** — render sorts + dedupes; duplicate route registration emits one path key; repeated runs are byte-identical (required for zero-diff freshness).
- **`_test.go` files** excluded from both constant loading and route collection.{"model": "deepseek-v4-flash", "problem_class": "go-openapi-generator-invocation", "result": "passed", "tests": 7}