// schemasync validates that Bifrost Go config types stay in sync with // transports/config.schema.json. // // Starting from a configured entry-point type (default: ConfigData in // transports/bifrost-http/lib), it recursively walks every nested struct // field via go/types. For each field it verifies: // // 1. The json:"X" tag has a corresponding property in config.schema.json at // the propagated schema path (handling $ref, allOf, oneOf, if/then/else). // 2. If the field's Go type is a named string type with const declarations, // the set of Go constant values matches the schema's enum array. // // Exit 0 on full agreement, 1 on any mismatch. package main import ( "encoding/json" "flag" "fmt" "go/constant" "go/types" "os" "path/filepath" "sort" "strings" "golang.org/x/tools/go/packages" ) type entrypoint struct { pkg string // Go import path typeName string // exported type name schemaPath string // JSON pointer path in config.schema.json (e.g. "/properties") moduleDir string // directory (relative to --pkg-root) that contains the go.mod } var entrypoints = []entrypoint{ { pkg: "github.com/maximhq/bifrost/transports/bifrost-http/lib", typeName: "ConfigData", schemaPath: "", // root schema node — collectProperties will find .properties moduleDir: "transports", }, } // Schema properties that intentionally have no Go counterpart and vice versa. // Key is a JSON pointer path; value is a short reason. var ignoreSchemaProps = map[string]string{ "/properties/$schema": "JSON schema self-reference", // GORM foreignKey slice relations that ARE user-submittable config input. // Go-side: schemasync skips them via the gorm-tag filter; schema-side: // these entries prevent the missing-in-go warning for them. "/properties/governance/properties/virtual_keys/items/properties/provider_configs": "gorm fk slice; user-submittable", "/properties/governance/properties/virtual_keys/items/properties/mcp_configs": "gorm fk slice; user-submittable", "/properties/governance/properties/routing_rules/items/properties/targets": "gorm fk slice; user-submittable", // MCP headers map — documented escape hatch is envFrom: // plus env.X references in values; no chart-native secretRef. "/properties/mcp/properties/client_configs/items/properties/headers/additionalProperties": "documented envFrom pattern", // Object-storage identity fields (bucket/region/endpoint/project_id) are // EnvVar-typed for flexibility but are not inherently secret. Operators // can write `env.MY_VAR` in values and use envFrom to inject. Access // keys, session tokens, and credentials DO have chart-native secret // support via `storage.logsStore.objectStorage.existingSecret`. "/properties/logs_store/properties/object_storage/properties/bucket": "not a secret; env.X + envFrom pattern", "/properties/logs_store/properties/object_storage/properties/region": "not a secret; env.X + envFrom pattern", "/properties/logs_store/properties/object_storage/properties/endpoint": "not a secret; env.X + envFrom pattern", "/properties/logs_store/properties/object_storage/properties/project_id": "not a secret; env.X + envFrom pattern", } // ignoreGoFields keys are "schemaPath|fieldName"; value is the reason. var ignoreGoFields = map[string]string{ "|auth_config": "deprecated; moved to governance.auth_config", } // ignoreGoFieldNames are field names (regardless of parent path) that are // DB bookkeeping or runtime-derived — never part of user-submitted config. var ignoreGoFieldNames = map[string]string{ "created_at": "DB bookkeeping", "updated_at": "DB bookkeeping", "config_hash": "internal hash", "status": "runtime-derived", "state": "runtime-derived", } // opaqueLeafTypes are named Go types that have custom JSON marshalling and // should be treated as leaves. The walker does NOT recurse into their fields, // and they are collected for downstream checks (e.g., EnvVar → helm secret). var opaqueLeafTypes = map[string]string{ "github.com/maximhq/bifrost/core/schemas.EnvVar": "env-aware string; custom JSON", } // envVarLocation records where an EnvVar-typed field appears in config.json // so a downstream pass can confirm the helm chart supports Secret-backed // injection (existingSecret / secretRef / env.BIFROST_*) for that path. type envVarLocation struct { schemaPath string goPath string } // Finding categorises every issue the tool surfaces so the final report can // group by category and render as a table. type Finding struct { Category string // e.g. "missing-in-schema", "missing-in-go", "enum-drift", "envvar-no-secret" Severity string // "ERROR" or "WARN" Path string // schema path or enum path Detail string // field name, Go path, missing/extra values, etc. Go string // Go-side location (package.Type.Field) } type checker struct { schema map[string]any pkgs map[string]*packages.Package // path → pkg // enumConsts[namedType] -> list of string values found in any loaded package enumConsts map[string][]string // visited type names to break cycles visited map[string]bool // envVarFields records where EnvVar types occur, for downstream checks envVarFields []envVarLocation findings []Finding } func main() { schemaFlag := flag.String("schema", "transports/config.schema.json", "path to config.schema.json") pkgDir := flag.String("pkg-root", ".", "repo root used as packages.Load dir") helmValuesFlag := flag.String("helm-values", "helm-charts/bifrost/values.schema.json", "path to helm values.schema.json (for EnvVar secret-support check)") helmHelpersFlag := flag.String("helm-helpers", "helm-charts/bifrost/templates/_helpers.tpl", "path to helm _helpers.tpl (for env.BIFROST_* emission detection)") flag.Parse() schemaBytes, err := os.ReadFile(*schemaFlag) if err != nil { fmt.Fprintf(os.Stderr, "read schema: %v\n", err) os.Exit(2) } var schema map[string]any if err := json.Unmarshal(schemaBytes, &schema); err != nil { fmt.Fprintf(os.Stderr, "parse schema: %v\n", err) os.Exit(2) } // Group entrypoints by moduleDir so we load each module's package graph once. byModule := map[string][]entrypoint{} orderedMods := []string{} for _, e := range entrypoints { if _, seen := byModule[e.moduleDir]; !seen { orderedMods = append(orderedMods, e.moduleDir) } byModule[e.moduleDir] = append(byModule[e.moduleDir], e) } absRoot, err := filepath.Abs(*pkgDir) if err != nil { fmt.Fprintf(os.Stderr, "abs pkg-root: %v\n", err) os.Exit(2) } // Always use the repo's go.work so local modules resolve against each // other (not against registry tarballs). The tool refuses to run without // go.work — that's the only configuration bifrost is tested against. goworkPath := filepath.Join(absRoot, "go.work") if _, err := os.Stat(goworkPath); err != nil { fmt.Fprintf(os.Stderr, "schemasync requires go.work at %s: %v\n", goworkPath, err) os.Exit(2) } allPkgs := map[string]*packages.Package{} for _, mod := range orderedMods { modDir := filepath.Join(absRoot, mod) env := append([]string{}, os.Environ()...) env = append(env, "GOWORK="+goworkPath) cfg := &packages.Config{ Mode: packages.NeedName | packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo | packages.NeedDeps | packages.NeedImports | packages.NeedFiles, Dir: modDir, Env: env, } imports := []string{} for _, e := range byModule[mod] { imports = append(imports, e.pkg) } pkgs, err := packages.Load(cfg, imports...) if err != nil { fmt.Fprintf(os.Stderr, "load %s: %v\n", mod, err) os.Exit(2) } hadLoadErr := false packages.Visit(pkgs, nil, func(p *packages.Package) { for _, e := range p.Errors { fmt.Fprintln(os.Stderr, e) hadLoadErr = true } }) if hadLoadErr { os.Exit(2) } for k, v := range collectPkgs(pkgs) { allPkgs[k] = v } } c := &checker{ schema: schema, pkgs: allPkgs, enumConsts: map[string][]string{}, visited: map[string]bool{}, } c.collectConsts() for _, e := range entrypoints { p := c.pkgs[e.pkg] if p == nil { c.add(Finding{Category: "entrypoint", Severity: "ERROR", Detail: "package not loaded: " + e.pkg}) continue } obj := p.Types.Scope().Lookup(e.typeName) if obj == nil { c.add(Finding{Category: "entrypoint", Severity: "ERROR", Detail: fmt.Sprintf("type %s not found in %s", e.typeName, e.pkg)}) continue } named, ok := obj.Type().(*types.Named) if !ok { c.add(Finding{Category: "entrypoint", Severity: "ERROR", Detail: fmt.Sprintf("%s.%s is not a named type", e.pkg, e.typeName)}) continue } c.walkType(named, e.schemaPath, fmt.Sprintf("%s.%s", e.pkg, e.typeName)) } // EnvVar → helm-chart secret-support pass. For each Go field typed as // schemas.EnvVar, the helm chart must either (a) emit an env.BIFROST_* // placeholder for that JSON path via _helpers.tpl, or (b) expose a // secretRef/existingSecret knob in values.schema.json at the equivalent // camelCase location. If neither, warn. c.checkEnvVarHelmSupport(*helmValuesFlag, *helmHelpersFlag) printReport(os.Stderr, c.findings) errCount := c.countErrs() warnCount := c.countWarns() if errCount > 0 { fmt.Fprintf(os.Stderr, "\nschemasync: %d errors, %d warnings\n", errCount, warnCount) os.Exit(1) } fmt.Fprintf(os.Stderr, "\nschemasync: OK (%d warnings)\n", warnCount) } // printReport groups findings by category and prints a markdown-style table // for each non-empty group. func printReport(w interface{ Write([]byte) (int, error) }, findings []Finding) { if len(findings) == 0 { return } groups := map[string][]Finding{} order := []string{} for _, f := range findings { if _, ok := groups[f.Category]; !ok { order = append(order, f.Category) } groups[f.Category] = append(groups[f.Category], f) } titles := map[string]string{ "missing-in-schema": "Missing in config.schema.json (Go has field, schema doesn't) — ERRORS", "missing-in-go": "Missing in Go (schema has property, ConfigData doesn't) — WARNINGS", "enum-drift": "Enum drift (Go constants vs schema enum array)", "enum-no-schema": "Go enum types with no schema `enum` constraint — WARNINGS", "envvar-no-secret": "EnvVar fields lacking chart-native Secret support — WARNINGS", "schema-path-not-found": "Schema path not found for a walked Go type — ERRORS", "entrypoint": "Entrypoint problems — ERRORS", } for _, cat := range order { items := groups[cat] title := titles[cat] if title == "" { title = cat } fmt.Fprintf(w.(interface{ Write([]byte) (int, error) }), "\n### %s (%d)\n\n", title, len(items)) // Pick columns based on category for readability. switch cat { case "missing-in-schema", "schema-path-not-found": renderTable(w, []string{"severity", "schema path", "Go location"}, func() [][]string { out := [][]string{} for _, f := range items { out = append(out, []string{f.Severity, f.Path, f.Go}) } return out }()) case "missing-in-go": renderTable(w, []string{"severity", "schema path", "property", "Go parent"}, func() [][]string { out := [][]string{} for _, f := range items { out = append(out, []string{f.Severity, f.Path, f.Detail, f.Go}) } return out }()) case "enum-drift", "enum-no-schema": renderTable(w, []string{"severity", "enum path", "drift", "Go location"}, func() [][]string { out := [][]string{} for _, f := range items { out = append(out, []string{f.Severity, f.Path, f.Detail, f.Go}) } return out }()) case "envvar-no-secret": renderTable(w, []string{"severity", "config path", "Go location", "note"}, func() [][]string { out := [][]string{} for _, f := range items { out = append(out, []string{f.Severity, f.Path, f.Go, f.Detail}) } return out }()) default: renderTable(w, []string{"severity", "detail"}, func() [][]string { out := [][]string{} for _, f := range items { out = append(out, []string{f.Severity, f.Detail}) } return out }()) } } } // renderTable writes a markdown table. Truncates long cells to keep width sane. func renderTable(w interface{ Write([]byte) (int, error) }, headers []string, rows [][]string) { const maxCol = 80 widths := make([]int, len(headers)) for i, h := range headers { widths[i] = len(h) } truncate := func(s string) string { if len(s) <= maxCol { return s } return s[:maxCol-1] + "…" } trimmed := make([][]string, len(rows)) for i, r := range rows { trimmed[i] = make([]string, len(r)) for j, cell := range r { trimmed[i][j] = truncate(cell) if j < len(widths) && len(trimmed[i][j]) > widths[j] { widths[j] = len(trimmed[i][j]) } } } writeRow := func(cells []string) { var sb strings.Builder sb.WriteString("| ") for i, c := range cells { sb.WriteString(c) if pad := widths[i] - len(c); pad > 0 { sb.WriteString(strings.Repeat(" ", pad)) } sb.WriteString(" | ") } sb.WriteString("\n") _, _ = w.Write([]byte(sb.String())) } writeRow(headers) sep := make([]string, len(headers)) for i := range headers { sep[i] = strings.Repeat("-", widths[i]) } writeRow(sep) for _, r := range trimmed { writeRow(r) } } // checkEnvVarHelmSupport verifies that every Go field of type schemas.EnvVar // has a way to be sourced from a Kubernetes secret via the helm chart. Proof // of support is any of: // // 1. An `env.BIFROST_*` string literal appears in _helpers.tpl (indicating // a rewrite is wired up for the corresponding config path), OR // 2. values.schema.json declares a `secretRef` or `existingSecret` object // at the camelCase equivalent of the schema path. // // Neither heuristic is perfect — this is a structural review aid, not a // proof. Treat misses as warnings so they don't block CI on borderline cases. func (c *checker) checkEnvVarHelmSupport(valuesPath, helpersPath string) { helpersBytes, err := os.ReadFile(helpersPath) if err != nil { c.add(Finding{Category: "envvar-no-secret", Severity: "WARN", Detail: fmt.Sprintf("could not read helm helpers %s: %v — skipping EnvVar helm-support check", helpersPath, err)}) return } helpers := string(helpersBytes) // Extract every env.BIFROST_* token mentioned in _helpers.tpl. envBifrostMentions := map[string]bool{} for _, line := range strings.Split(helpers, "\n") { // crude extraction: look for "env.BIFROST_X" substrings idx := 0 for idx < len(line) { k := strings.Index(line[idx:], "env.BIFROST_") if k < 0 { break } start := idx + k end := start for end < len(line) { ch := line[end] if ch == '"' || ch == ' ' || ch == '\t' || ch == '}' || ch == ')' { break } end++ } envBifrostMentions[line[start:end]] = true idx = end } } valuesBytes, err := os.ReadFile(valuesPath) hasValues := err == nil var valuesSchema map[string]any if hasValues { _ = json.Unmarshal(valuesBytes, &valuesSchema) } for _, loc := range c.envVarFields { // Heuristic 1: any env.BIFROST_* is present in helpers — broad acceptance. // We can't easily map a specific EnvVar field to a specific env var // without per-field config, so we just check that the helpers file // has AT LEAST ONE envBifrost mention that maps to this field's path. // To make this stricter, we look for a helpers line mentioning either // the camelCase field's parent path or an env var matching it. camel := schemaPathToCamelCase(loc.schemaPath) matched := false // Heuristic 2: values.schema.json declares secretRef under the parent path. if hasValues && valuesSchema != nil { if hasSecretRefAt(valuesSchema, camel) { matched = true } } if !matched && len(envBifrostMentions) > 0 { // Fall back to "some envBifrost wiring exists somewhere" — we flag it // as a weaker hit so maintainers know to verify the mapping manually. // Do not accept purely from presence; require a name-similarity match. tail := lastSchemaComponent(loc.schemaPath) for mention := range envBifrostMentions { up := strings.ToUpper(tail) if strings.Contains(mention, "_"+up) || strings.HasSuffix(mention, up) { matched = true break } } } if !matched { if _, ignored := ignoreSchemaProps[loc.schemaPath]; ignored { continue } c.add(Finding{ Category: "envvar-no-secret", Severity: "WARN", Path: loc.schemaPath, Detail: "helm has no secretRef/existingSecret at " + camel + " or parent", Go: loc.goPath, }) } } } // schemaPathToCamelCase converts a JSON pointer like // "/properties/governance/properties/auth_config/properties/admin_username" // into a best-effort camelCase helm values path like // "properties.bifrost.properties.governance.properties.authConfig.properties.adminUsername". func schemaPathToCamelCase(p string) string { parts := strings.Split(strings.TrimPrefix(p, "/"), "/") out := []string{"properties", "bifrost"} for _, part := range parts { if part == "" { continue } if part == "properties" { out = append(out, "properties") continue } out = append(out, snakeToCamel(part)) } return strings.Join(out, ".") } func snakeToCamel(s string) string { parts := strings.Split(s, "_") for i := 1; i < len(parts); i++ { if parts[i] != "" { parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] } } return strings.Join(parts, "") } func lastSchemaComponent(p string) string { parts := strings.Split(p, "/") for i := len(parts) - 1; i >= 0; i-- { if parts[i] != "" && parts[i] != "properties" { return parts[i] } } return "" } // hasSecretRefAt returns true if EITHER (a) the target subtree declares a // secretRef/existingSecret/*Secret knob inside its own "properties", OR // (b) a SIBLING of the target (at the same properties-map level) is named // "Secret" / "secretRef" / "existingSecret" / has "Secret" suffix. // Sibling match is how the helm chart's encryptionKey + encryptionKeySecret // pattern works: the Secret-source knob is a sibling of the field itself. func hasSecretRefAt(schema map[string]any, dotted string) bool { parts := strings.Split(dotted, ".") var cur any = schema var propsAtTarget map[string]any // map in which the last non-"properties" part lives var targetName string for _, p := range parts { m, ok := cur.(map[string]any) if !ok { return false } // Resolve $ref at this node before descending. if ref, ok := m["$ref"].(string); ok && strings.HasPrefix(ref, "#/") { resolved := jsonPointerGet(schema, strings.TrimPrefix(ref, "#/")) if rm, ok := resolved.(map[string]any); ok { m = rm } } if p != "properties" { propsAtTarget = m targetName = p } next, present := m[p] if !present { break } cur = next } // (a) target itself declares a Secret knob in its own properties. if m, ok := cur.(map[string]any); ok && secretRefPresent(m) { return true } // (b) a sibling of target matches Secret or a generic Secret knob. if propsAtTarget != nil && targetName != "" { for k := range propsAtTarget { if k == targetName { continue } if k == "secretRef" || k == "existingSecret" { return true } if strings.HasSuffix(k, "Secret") || strings.HasSuffix(k, "SecretRef") { return true } } } return false } // jsonPointerGet resolves a /-delimited JSON Pointer into a schema root. // Used by hasSecretRefAt to follow $ref entries in helm values.schema.json. func jsonPointerGet(root any, pointer string) any { if pointer == "" { return root } parts := strings.Split(pointer, "/") cur := root for _, p := range parts { p = strings.ReplaceAll(p, "~1", "/") p = strings.ReplaceAll(p, "~0", "~") m, ok := cur.(map[string]any) if !ok { return nil } cur = m[p] if cur == nil { return nil } } return cur } func secretRefPresent(m map[string]any) bool { if m == nil { return false } props, ok := m["properties"].(map[string]any) if !ok { return false } for k := range props { if k == "secretRef" || k == "existingSecret" || k == "encryptionKeySecret" { return true } if strings.HasSuffix(k, "Secret") || strings.HasSuffix(k, "SecretRef") { return true } } return false } func collectPkgs(roots []*packages.Package) map[string]*packages.Package { out := map[string]*packages.Package{} packages.Visit(roots, nil, func(p *packages.Package) { out[p.PkgPath] = p }) return out } func (c *checker) add(f Finding) { c.findings = append(c.findings, f) } // countErrs returns the number of ERROR-severity findings. func (c *checker) countErrs() int { n := 0 for _, f := range c.findings { if f.Severity == "ERROR" { n++ } } return n } // countWarns returns the number of WARN-severity findings. func (c *checker) countWarns() int { n := 0 for _, f := range c.findings { if f.Severity == "WARN" { n++ } } return n } // collectConsts scans all loaded packages for `const X NamedStringType = "v"` // and indexes them by namedType key "pkgpath.TypeName". func (c *checker) collectConsts() { for _, p := range c.pkgs { if p.Types == nil { continue } scope := p.Types.Scope() for _, name := range scope.Names() { obj := scope.Lookup(name) cnst, ok := obj.(*types.Const) if !ok { continue } named, ok := cnst.Type().(*types.Named) if !ok { continue } // Only named string types basic, ok := named.Underlying().(*types.Basic) if !ok || basic.Kind() != types.String { continue } key := named.Obj().Pkg().Path() + "." + named.Obj().Name() v := cnst.Val() if v.Kind() != constant.String { continue } c.enumConsts[key] = append(c.enumConsts[key], constant.StringVal(v)) } } for k := range c.enumConsts { sort.Strings(c.enumConsts[k]) } } // walkType recursively walks a struct type, verifying each json-tagged field // has a schema counterpart at the propagated schemaPath. func (c *checker) walkType(t types.Type, schemaPath, goPath string) { t = deref(t) named, _ := t.(*types.Named) if named != nil { key := named.Obj().Pkg().Path() + "." + named.Obj().Name() // Treat opaque types (like schemas.EnvVar) as leaves. if _, isOpaque := opaqueLeafTypes[key]; isOpaque { if key == "github.com/maximhq/bifrost/core/schemas.EnvVar" { c.envVarFields = append(c.envVarFields, envVarLocation{schemaPath, goPath}) } return } if c.visited[key+"@"+schemaPath] { return } c.visited[key+"@"+schemaPath] = true } structType, ok := t.Underlying().(*types.Struct) if !ok { return } schemaNode := c.resolveSchema(schemaPath) if schemaNode == nil { c.add(Finding{Category: "schema-path-not-found", Severity: "ERROR", Path: schemaPath, Go: goPath}) return } // Collect every property key reachable from this schema node across // properties/allOf/oneOf/anyOf/if-then-else branches. schemaProps := c.collectProperties(schemaNode, schemaPath) goFieldTags := map[string]*types.Var{} for i := 0; i < structType.NumFields(); i++ { f := structType.Field(i) if !f.Exported() { continue } tag := reflectTag(structType.Tag(i), "json") if tag == "" || tag == "-" { continue } name := strings.Split(tag, ",")[0] if name == "" { continue } // Skip GORM relational fields (populated from joins; never user-submitted). // GORM relational fields (`foreignKey`, `many2many`) are populated // from DB joins, not user-submitted config. Skip them from the walk so // schemasync only compares user-input config against config.schema.json. // Schema properties for these relations may still exist (validated at // the missing-in-go layer); add the schema path to ignoreSchemaProps // for deliberate exceptions (see below for `provider_configs`, etc.). gormTag := reflectTag(structType.Tag(i), "gorm") if strings.Contains(gormTag, "foreignKey") || strings.Contains(gormTag, "many2many") { continue } goFieldTags[name] = f } // Go-field → schema check for name, f := range goFieldTags { childPath := schemaPath + "/properties/" + name if _, ignored := ignoreGoFields[schemaPath+"|"+name]; ignored { continue } if _, ignored := ignoreGoFieldNames[name]; ignored { continue } childSchema := schemaProps[name] if childSchema == nil { c.add(Finding{ Category: "missing-in-schema", Severity: "ERROR", Path: schemaPath + "/properties/" + name, Detail: name, Go: goPath + "." + f.Name(), }) continue } // Recurse into field type c.walkField(f.Type(), childSchema, childPath, goPath+"."+f.Name()) } // Schema-key → Go field check (warnings; schema may legitimately be broader) for name := range schemaProps { if _, ignored := ignoreSchemaProps[schemaPath+"/properties/"+name]; ignored { continue } if _, ignored := ignoreGoFields[schemaPath+"|"+name]; ignored { continue } if _, ok := goFieldTags[name]; !ok { c.add(Finding{ Category: "missing-in-go", Severity: "WARN", Path: schemaPath + "/properties/" + name, Detail: name, Go: goPath, }) } } } // walkField dispatches based on the field's Go type. func (c *checker) walkField(t types.Type, schemaNode map[string]any, schemaPath, goPath string) { t = deref(t) // Named type → opaque-leaf check + enum check (if string const type) if named, ok := t.(*types.Named); ok { key := named.Obj().Pkg().Path() + "." + named.Obj().Name() if _, isOpaque := opaqueLeafTypes[key]; isOpaque { if key == "github.com/maximhq/bifrost/core/schemas.EnvVar" { c.envVarFields = append(c.envVarFields, envVarLocation{schemaPath, goPath}) } return // do not recurse into opaque types } if goVals, hasConsts := c.enumConsts[key]; hasConsts && len(goVals) > 0 { c.checkEnum(goVals, schemaNode, schemaPath, goPath, key) } } switch u := t.Underlying().(type) { case *types.Struct: // Recurse into named struct (anonymous structs are inlined below) if _, ok := t.(*types.Named); ok { c.walkType(t, schemaPath, goPath) } else { // Anonymous inline struct — rare but handle by walking tags c.walkAnonymous(u, schemaPath, goPath) } case *types.Slice: elem := u.Elem() if _, isStruct := deref(elem).Underlying().(*types.Struct); isStruct { itemsNode := c.resolveRef(schemaNode) if _, ok := itemsNode["items"].(map[string]any); ok { c.walkType(elem, schemaPath+"/items", goPath+"[]") } } case *types.Array: elem := u.Elem() if _, isStruct := deref(elem).Underlying().(*types.Struct); isStruct { itemsNode := c.resolveRef(schemaNode) if _, ok := itemsNode["items"].(map[string]any); ok { c.walkType(elem, schemaPath+"/items", goPath+"[]") } } case *types.Map: elem := u.Elem() if _, isStruct := deref(elem).Underlying().(*types.Struct); isStruct { node := c.resolveRef(schemaNode) if _, ok := node["additionalProperties"].(map[string]any); ok { c.walkType(elem, schemaPath+"/additionalProperties", goPath+"[]") } // If no additionalProperties/patternProperties, silently skip — schemas // often describe provider-keyed maps via oneOf branches. } case *types.Basic, *types.Interface: // Leaf — nothing to recurse into. } } // walkAnonymous handles anonymous (inline) struct fields — rare; we treat // them as a struct walk at the same schemaPath. func (c *checker) walkAnonymous(st *types.Struct, schemaPath, goPath string) { // Not common in this codebase; fall back to flat tag-check. schemaNode := c.resolveSchema(schemaPath) if schemaNode == nil { return } props := c.collectProperties(schemaNode, schemaPath) for i := 0; i < st.NumFields(); i++ { f := st.Field(i) if !f.Exported() { continue } tag := reflectTag(st.Tag(i), "json") if tag == "" || tag == "-" { continue } name := strings.Split(tag, ",")[0] if _, ok := props[name]; !ok { c.add(Finding{ Category: "missing-in-schema", Severity: "ERROR", Path: schemaPath + "/properties/" + name, Detail: name, Go: goPath + "." + f.Name(), }) } } } // checkEnum diffs Go string-const values against schema enum array. func (c *checker) checkEnum(goVals []string, schemaNode map[string]any, schemaPath, goPath, typeKey string) { node := c.resolveRef(schemaNode) rawEnum, ok := node["enum"] if !ok { c.add(Finding{ Category: "enum-no-schema", Severity: "WARN", Path: schemaPath, Detail: fmt.Sprintf("%v (Go consts)", goVals), Go: typeKey, }) return } enumArr, ok := rawEnum.([]any) if !ok { c.add(Finding{Category: "enum-drift", Severity: "ERROR", Path: schemaPath, Detail: "schema enum is not an array"}) return } schemaSet := map[string]bool{} for _, v := range enumArr { if s, ok := v.(string); ok { schemaSet[s] = true } } goSet := map[string]bool{} for _, v := range goVals { goSet[v] = true } var missingInSchema, extraInSchema []string for v := range goSet { if !schemaSet[v] { missingInSchema = append(missingInSchema, v) } } for v := range schemaSet { if !goSet[v] { extraInSchema = append(extraInSchema, v) } } sort.Strings(missingInSchema) sort.Strings(extraInSchema) if len(missingInSchema) > 0 { c.add(Finding{ Category: "enum-drift", Severity: "ERROR", Path: schemaPath, Detail: fmt.Sprintf("schema missing Go consts %v", missingInSchema), Go: goPath + " (" + typeKey + ")", }) } if len(extraInSchema) > 0 { c.add(Finding{ Category: "enum-drift", Severity: "WARN", Path: schemaPath, Detail: fmt.Sprintf("schema has %v with no Go const", extraInSchema), Go: typeKey, }) } } // collectProperties walks the schema subtree rooted at `node`, unioning // property keys from the direct `properties`, and recursively from `allOf`, // `oneOf`, `anyOf`, `then`, `else`. Handles $ref. // Returns map of propertyName → subschema. func (c *checker) collectProperties(node map[string]any, atPath string) map[string]map[string]any { out := map[string]map[string]any{} c.mergeProperties(out, node, atPath, map[string]bool{}) return out } func (c *checker) mergeProperties(out map[string]map[string]any, node map[string]any, atPath string, seen map[string]bool) { if node == nil { return } node = c.resolveRef(node) if ref, ok := node["$ref"].(string); ok && seen[ref] { return } if ref, ok := node["$ref"].(string); ok { seen[ref] = true } if props, ok := node["properties"].(map[string]any); ok { for k, v := range props { if m, ok := v.(map[string]any); ok { if _, already := out[k]; !already { out[k] = m } } } } for _, key := range []string{"allOf", "oneOf", "anyOf"} { if arr, ok := node[key].([]any); ok { for _, item := range arr { if m, ok := item.(map[string]any); ok { c.mergeProperties(out, m, atPath+"/"+key, seen) } } } } for _, key := range []string{"then", "else"} { if m, ok := node[key].(map[string]any); ok { c.mergeProperties(out, m, atPath+"/"+key, seen) } } } // resolveSchema walks a JSON-pointer path into c.schema, resolving $ref at // each intermediate node. func (c *checker) resolveSchema(path string) map[string]any { parts := strings.Split(strings.TrimPrefix(path, "/"), "/") var cur any = c.schema for _, p := range parts { if p == "" { continue } m, ok := cur.(map[string]any) if !ok { return nil } m = c.resolveRef(m) cur = m[unescapeJSONPointer(p)] if cur == nil { return nil } } if m, ok := cur.(map[string]any); ok { return c.resolveRef(m) } return nil } // resolveRef follows a $ref pointer (recursively) to the final target node. // $ref values are expected as "#/$defs/xxx" style JSON pointers. func (c *checker) resolveRef(node map[string]any) map[string]any { for i := 0; i < 16; i++ { ref, ok := node["$ref"].(string) if !ok { return node } if !strings.HasPrefix(ref, "#/") { return node // external refs unsupported } parts := strings.Split(strings.TrimPrefix(ref, "#/"), "/") var cur any = c.schema ok2 := true for _, p := range parts { m, isMap := cur.(map[string]any) if !isMap { ok2 = false break } cur = m[unescapeJSONPointer(p)] if cur == nil { ok2 = false break } } if !ok2 { return node } next, ok := cur.(map[string]any) if !ok { return node } node = next } return node } func unescapeJSONPointer(s string) string { s = strings.ReplaceAll(s, "~1", "/") s = strings.ReplaceAll(s, "~0", "~") return s } // deref strips pointer wrappers to get the underlying type. func deref(t types.Type) types.Type { for { p, ok := t.(*types.Pointer) if !ok { return t } t = p.Elem() } } // reflectTag parses a single struct-tag key; mirrors reflect.StructTag.Get. func reflectTag(tag, key string) string { for tag != "" { for tag != "" && tag[0] == ' ' { tag = tag[1:] } i := 0 for i < len(tag) && tag[i] > ' ' && tag[i] != ':' && tag[i] != '"' && tag[i] != 0x7f { i++ } if i == 0 || i+1 >= len(tag) || tag[i] != ':' || tag[i+1] != '"' { break } name := tag[:i] tag = tag[i+1:] i = 1 for i < len(tag) && tag[i] != '"' { if tag[i] == '\\' { i++ } i++ } if i >= len(tag) { break } val := tag[1:i] tag = tag[i+1:] if name == key { return val } } return "" }