Patch to v1.1.1. check_env could only reason at module granularity, so a var that lives on one struct of a multi-struct module (e.g. EINHERJAR_SERVER_CORS_ORIGINS on web.Config, not web/server.Config) couldn't be flagged dead when the app composes the sibling struct. Adds struct-level selectors to close that gap. internal/tools (check_env): - New `composes` input: exact config structs as module/subpackage/Struct selectors (e.g. web/server/Config, db-postgres/Config, core/logz/Config), alongside or instead of the coarse `modules` list. - not-composed now reasons over the union of composed vars (from modules AND struct selectors), so it surfaces struct-level dead vars. Coarse modules:["web"] keeps the prior behavior. - parseStructSelector helper; errors on malformed or non-existent selectors. Tests: struct-level dead-var catch (CORS flagged with web/server/Config but not with module web), bad-selector errors; existing calls updated to the new arg. Version bumped to v1.1.1 (badge + serverVersion). No dependency changes. Reviewed-on: #4 Co-authored-by: Rene Nochebuena Guerrero <rene@nochebuena.dev> Co-committed-by: Rene Nochebuena Guerrero <rene@nochebuena.dev>
227 lines
8.1 KiB
Go
227 lines
8.1 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"code.nochebuena.dev/einherjar/mcp/internal/envspec"
|
|
"code.nochebuena.dev/einherjar/mcp/internal/index"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
type checkEnvInput struct {
|
|
Env string `json:"env" jsonschema:"the .env or .env.example contents to check (KEY=value lines; comments and blanks ignored)."`
|
|
Modules []string `json:"modules,omitempty" jsonschema:"module-granularity: the framework modules whose component configs the app composes, e.g. [\"db-postgres\",\"web\"]. Every env var in the module counts as composed. Convenient but coarse — a module can declare more than one config struct."`
|
|
Composes []string `json:"composes,omitempty" jsonschema:"struct-granularity (preferred when precise): the exact config structs the app composes, as module/subpackage/Struct selectors, e.g. [\"web/server/Config\",\"db-postgres/Config\",\"core/logz/Config\"]. Use module/Struct when the config is at the module root. This catches struct-level dead vars, e.g. flagging EINHERJAR_SERVER_CORS_ORIGINS (which lives on web.Config, not web/server/Config)."`
|
|
}
|
|
|
|
type envFinding struct {
|
|
Severity string `json:"severity"` // error | warning | info
|
|
Kind string `json:"kind"` // unknown-var | missing-required | not-composed
|
|
Var string `json:"var"`
|
|
Message string `json:"message"`
|
|
Hint string `json:"hint,omitempty"`
|
|
}
|
|
|
|
type checkEnvOutput struct {
|
|
Findings []envFinding `json:"findings"`
|
|
OK bool `json:"ok"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
|
|
func registerCheckEnv(s *mcp.Server, idx *index.Index) {
|
|
mcp.AddTool(s, &mcp.Tool{
|
|
Name: "check_env",
|
|
Description: "Check a .env / .env.example against the framework's real env vars. Flags EINHERJAR_* names that don't exist (invented or misspelled, e.g. EINHERJAR_PG_DATABASE), " +
|
|
"and — when you declare what the app composes (via `modules` for coarse or `composes` for exact struct selectors) — required vars that are missing, plus framework vars that nothing composed reads (dead vars). " +
|
|
"Prefer `composes` (e.g. web/server/Config) for precision; it catches struct-level dead vars that module granularity can't. " +
|
|
"This is the enforcement half of building a correct global config: get_scaffold emits an accurate .env.example, get_config_env lists the truth, and check_env catches drift in an existing file.",
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest, args checkEnvInput) (*mcp.CallToolResult, checkEnvOutput, error) {
|
|
findings, err := checkEnvVars(idx, args.Env, args.Modules, args.Composes)
|
|
if err != nil {
|
|
return errorResult(err.Error()), checkEnvOutput{}, nil
|
|
}
|
|
ok := true
|
|
for _, f := range findings {
|
|
if f.Severity == "error" {
|
|
ok = false
|
|
}
|
|
}
|
|
out := checkEnvOutput{Findings: findings, OK: ok, Summary: summariseEnvCheck(findings, ok)}
|
|
return jsonText(out), out, nil
|
|
})
|
|
}
|
|
|
|
// checkEnvVars is the pure core of check_env: given the framework index, an env
|
|
// file body, the modules the app composes (coarse), and the exact config structs
|
|
// it composes (precise selectors), it returns the drift findings. A var counts as
|
|
// "composed" when it is declared by any listed module or any listed struct.
|
|
func checkEnvVars(idx *index.Index, env string, modules, composes []string) ([]envFinding, error) {
|
|
present := parseEnvKeys(env)
|
|
known := envspec.KnownNames(idx)
|
|
|
|
// The set of composed env vars (name -> Var), unioned from module-granularity
|
|
// and struct-granularity selectors. Its keyset is the "what this app reads" set.
|
|
expected := map[string]envspec.Var{}
|
|
scoped := false
|
|
for _, mod := range modules {
|
|
mod = strings.TrimSpace(mod)
|
|
if mod == "" {
|
|
continue
|
|
}
|
|
if idx.FindModule(mod) == nil {
|
|
return nil, fmt.Errorf("module not found: %s", mod)
|
|
}
|
|
scoped = true
|
|
for _, v := range envspec.ForModule(idx, mod) {
|
|
expected[v.Name] = v
|
|
}
|
|
}
|
|
for _, sel := range composes {
|
|
sel = strings.TrimSpace(sel)
|
|
if sel == "" {
|
|
continue
|
|
}
|
|
mod, sub, name, err := parseStructSelector(sel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if idx.FindModule(mod) == nil {
|
|
return nil, fmt.Errorf("module not found in selector %q: %s", sel, mod)
|
|
}
|
|
vars := envspec.FindStruct(idx, mod, sub, name)
|
|
if vars == nil {
|
|
return nil, fmt.Errorf("struct not found for selector %q (want an existing module/subpackage/Struct)", sel)
|
|
}
|
|
scoped = true
|
|
for _, v := range vars {
|
|
expected[v.Name] = v
|
|
}
|
|
}
|
|
|
|
varModule := map[string]string{}
|
|
for _, v := range envspec.All(idx) {
|
|
varModule[v.Name] = v.Module
|
|
}
|
|
|
|
var findings []envFinding
|
|
|
|
// 1. EINHERJAR_* present in the file that is not a real framework var.
|
|
for name := range present {
|
|
if !strings.HasPrefix(name, "EINHERJAR_") {
|
|
continue
|
|
}
|
|
if _, real := known[name]; real {
|
|
continue
|
|
}
|
|
findings = append(findings, envFinding{
|
|
Severity: "error", Kind: "unknown-var", Var: name,
|
|
Message: name + " is not a real Einherjar env var",
|
|
Hint: "Confirm the exact name with get_config_env; e.g. the db name is EINHERJAR_PG_NAME (not _DATABASE) and the server bind is EINHERJAR_SERVER_HOST/_PORT (not _ADDR).",
|
|
})
|
|
}
|
|
|
|
// 2. Required vars of a composed module that the file never sets.
|
|
for name, v := range expected {
|
|
if !v.Required {
|
|
continue
|
|
}
|
|
if _, ok := present[name]; !ok {
|
|
findings = append(findings, envFinding{
|
|
Severity: "error", Kind: "missing-required", Var: name,
|
|
Message: name + " is required by " + v.Module + "." + v.Struct + " but is absent from the file",
|
|
Hint: "Add " + name + "= to the file; a required var with no default fails config.Load() at boot.",
|
|
})
|
|
}
|
|
}
|
|
|
|
// 3. A real framework var set in the file that nothing composed reads → dead.
|
|
// With struct selectors this catches struct-level dead vars (a var whose
|
|
// module is partly composed but whose specific struct is not).
|
|
if scoped {
|
|
for name := range present {
|
|
mod, real := varModule[name]
|
|
if !real {
|
|
continue
|
|
}
|
|
if _, composed := expected[name]; composed {
|
|
continue
|
|
}
|
|
findings = append(findings, envFinding{
|
|
Severity: "info", Kind: "not-composed", Var: name,
|
|
Message: name + " is a real " + mod + " var, but none of the composed configs read it — it will be ignored",
|
|
Hint: "Compose the config that declares it, or drop the var. (E.g. EINHERJAR_SERVER_CORS_ORIGINS lives on web.Config; it is dead if you compose web/server/Config instead.)",
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(findings, func(i, j int) bool {
|
|
if findings[i].Kind != findings[j].Kind {
|
|
return findings[i].Kind < findings[j].Kind
|
|
}
|
|
return findings[i].Var < findings[j].Var
|
|
})
|
|
if findings == nil {
|
|
findings = []envFinding{}
|
|
}
|
|
return findings, nil
|
|
}
|
|
|
|
// parseStructSelector splits a "module/Struct" or "module/subpackage/Struct"
|
|
// selector. The struct name is always the last segment; a two-part selector
|
|
// targets a module-root config (empty sub-package).
|
|
func parseStructSelector(sel string) (module, subPackage, name string, err error) {
|
|
parts := strings.Split(sel, "/")
|
|
switch len(parts) {
|
|
case 2:
|
|
return parts[0], "", parts[1], nil
|
|
case 3:
|
|
return parts[0], parts[1], parts[2], nil
|
|
default:
|
|
return "", "", "", fmt.Errorf("invalid struct selector %q (want module/Struct or module/subpackage/Struct)", sel)
|
|
}
|
|
}
|
|
|
|
// parseEnvKeys returns the set of assigned variable names in an env file body,
|
|
// tolerating `export KEY=`, leading whitespace, comments, and blank lines.
|
|
func parseEnvKeys(body string) map[string]bool {
|
|
keys := map[string]bool{}
|
|
for _, line := range strings.Split(body, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
line = strings.TrimPrefix(line, "export ")
|
|
eq := strings.IndexByte(line, '=')
|
|
if eq <= 0 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(line[:eq])
|
|
if key != "" {
|
|
keys[key] = true
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func summariseEnvCheck(fs []envFinding, ok bool) string {
|
|
if len(fs) == 0 {
|
|
return "No issues — every EINHERJAR_* name is real and all required vars are present."
|
|
}
|
|
var errs, infos int
|
|
for _, f := range fs {
|
|
if f.Severity == "error" {
|
|
errs++
|
|
} else {
|
|
infos++
|
|
}
|
|
}
|
|
status := "OK"
|
|
if !ok {
|
|
status = "FAILED"
|
|
}
|
|
return status + ": " + itoa(errs) + " error(s), " + itoa(infos) + " note(s)."
|
|
}
|