feat(mcp): derive env vars from the framework's real struct tags
Minor to v1.1.0. Make the whole environment-variable surface derive from the
indexed component-config tags instead of a hand-maintained list that drifts.
Also folds in the scaffold-symbol fixes staged as v1.0.1 (never tagged); the
generated scaffold compiles clean against einherjar v1.0.0.
internal/envspec (new):
- Parse index struct tags into env vars {name, module, struct, field, required,
default}. One source of truth: ParseTag, ForModule, FindStruct, All, KnownNames.
internal/tools:
- get_config_env: list the real env vars a component config reads (or all).
- check_env: flag unknown EINHERJAR_* names, required vars missing for the
composed modules, and dead vars (set for an uncomposed module).
- get_scaffold: .env.example is now DERIVED from the index — required vars
uncommented with a dev value, defaulted vars commented with their default.
The scaffold now composes logz.Config (Log logz.Config) so EINHERJAR_LOG_*
are live and documented, not hardcoded/ignored (log format is env-driven).
- validate_snippet: inject the real env-var name set into the rules package.
internal/index/builtins (wire conventions):
- Route the incremental "compose a component later" flow to get_config_env /
check_env; distinguish framework EINHERJAR_* from app-owned APP_* (JWT).
- Compose logz.Config in the config + Run() examples, to match the scaffold.
internal/rules:
- config.unknown-env-var (twelfth rule): reject an env:"EINHERJAR_*" struct tag
the framework doesn't declare. No-op until the server injects the name set, so
it never fires on incomplete knowledge.
Fixed (was v1.0.1): scaffold health hook + wire builtin used logz.Logger (real:
contracts/logging.Logger) and postgres.Component (hooks take Provider); env tags
were EINHERJAR_SERVER_ADDR / EINHERJAR_PG_DATABASE (real: _HOST/_PORT / _PG_NAME).
Tests: envspec unit tests; env tools against the real data/index.json; the rule.
Verified by generating the scaffold, building it against local einherjar v1.0.0
(exit 0), and runtime-loading the composed logz.Config (EINHERJAR_LOG_LEVEL=DEBUG
-> slog.LevelDebug, EINHERJAR_LOG_JSON=true). Version bumped to v1.1.0 (badge +
serverVersion). No dependency changes.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.nochebuena.dev/einherjar/mcp/internal/index"
|
||||
)
|
||||
|
||||
// loadRealIndex builds the index from the sibling framework checkout at test
|
||||
// time, so the env tools are tested against the framework's actual struct tags.
|
||||
// It deliberately does NOT read data/index.json: that file is only an empty
|
||||
// placeholder in the repo (the real index is regenerated by cmd/indexer at
|
||||
// image-build time). When the sibling modules aren't present — e.g. a CI that
|
||||
// checks out only this module — the test skips rather than fails.
|
||||
func loadRealIndex(t *testing.T) *index.Index {
|
||||
t.Helper()
|
||||
idx, err := index.Build(filepath.Join("..", "..", ".."))
|
||||
if err != nil || idx == nil || len(idx.Modules) == 0 {
|
||||
t.Skip("framework checkout not available; skipping index-derived test")
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func TestRenderEnvExample(t *testing.T) {
|
||||
idx := loadRealIndex(t)
|
||||
got := renderEnvExample(idx, "myapp")
|
||||
|
||||
// Required, no default → uncommented with a runnable dev value.
|
||||
mustContain := []string{
|
||||
"APP_ENV=local",
|
||||
"APP_CORS_ORIGINS=*",
|
||||
"EINHERJAR_PG_HOST=localhost",
|
||||
"EINHERJAR_PG_USER=postgres",
|
||||
"EINHERJAR_PG_PASSWORD=postgres",
|
||||
"EINHERJAR_PG_NAME=myapp",
|
||||
// logz is composed → its vars (both defaulted) are documented, commented.
|
||||
"# EINHERJAR_LOG_LEVEL=INFO",
|
||||
"# EINHERJAR_LOG_JSON=false",
|
||||
// Has a framework default → commented line documenting it.
|
||||
"# EINHERJAR_SERVER_HOST=0.0.0.0",
|
||||
"# EINHERJAR_SERVER_PORT=8080",
|
||||
"# EINHERJAR_PG_PORT=5432",
|
||||
"# EINHERJAR_PG_SSL_MODE=disable",
|
||||
}
|
||||
for _, s := range mustContain {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf(".env.example missing line: %q\n---\n%s", s, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The historical drift names must never appear.
|
||||
mustNotContain := []string{
|
||||
"EINHERJAR_PG_DATABASE",
|
||||
"EINHERJAR_SERVER_ADDR",
|
||||
// CORS lives on web.Config, which the scaffold does not compose — so it
|
||||
// must not be emitted (it would be a dead var).
|
||||
"EINHERJAR_SERVER_CORS_ORIGINS",
|
||||
}
|
||||
for _, s := range mustNotContain {
|
||||
if strings.Contains(got, s) {
|
||||
t.Errorf(".env.example must not contain %q\n---\n%s", s, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A required var must never be silently emitted as a commented line.
|
||||
for _, line := range strings.Split(got, "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "# EINHERJAR_PG_NAME") {
|
||||
t.Errorf("required EINHERJAR_PG_NAME emitted commented: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEnvUnknownVar(t *testing.T) {
|
||||
idx := loadRealIndex(t)
|
||||
env := "EINHERJAR_PG_DATABASE=x\nEINHERJAR_SERVER_ADDR=:8080\nAPP_ENV=local\n"
|
||||
findings, err := checkEnvVars(idx, env, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unknown := map[string]bool{}
|
||||
for _, f := range findings {
|
||||
if f.Kind == "unknown-var" {
|
||||
unknown[f.Var] = true
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"EINHERJAR_PG_DATABASE", "EINHERJAR_SERVER_ADDR"} {
|
||||
if !unknown[want] {
|
||||
t.Errorf("expected unknown-var finding for %s; got %+v", want, findings)
|
||||
}
|
||||
}
|
||||
// APP_* must never be flagged.
|
||||
for _, f := range findings {
|
||||
if f.Var == "APP_ENV" {
|
||||
t.Errorf("APP_ENV should never be flagged: %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEnvMissingRequired(t *testing.T) {
|
||||
idx := loadRealIndex(t)
|
||||
// Compose db-postgres but set none of its required vars.
|
||||
findings, err := checkEnvVars(idx, "APP_ENV=local\n", []string{"db-postgres"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
missing := map[string]bool{}
|
||||
for _, f := range findings {
|
||||
if f.Kind == "missing-required" {
|
||||
missing[f.Var] = true
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"EINHERJAR_PG_HOST", "EINHERJAR_PG_USER", "EINHERJAR_PG_PASSWORD", "EINHERJAR_PG_NAME"} {
|
||||
if !missing[want] {
|
||||
t.Errorf("expected missing-required for %s; got %+v", want, findings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEnvNotComposed(t *testing.T) {
|
||||
idx := loadRealIndex(t)
|
||||
// A real MinIO var while only composing db-postgres → dead (not-composed).
|
||||
env := "EINHERJAR_MINIO_ENDPOINT=x\nEINHERJAR_PG_HOST=h\nEINHERJAR_PG_USER=u\nEINHERJAR_PG_PASSWORD=p\nEINHERJAR_PG_NAME=n\n"
|
||||
findings, err := checkEnvVars(idx, env, []string{"db-postgres"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got bool
|
||||
for _, f := range findings {
|
||||
if f.Kind == "not-composed" && f.Var == "EINHERJAR_MINIO_ENDPOINT" {
|
||||
got = true
|
||||
}
|
||||
}
|
||||
if !got {
|
||||
t.Errorf("expected not-composed for EINHERJAR_MINIO_ENDPOINT; got %+v", findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEnvClean(t *testing.T) {
|
||||
idx := loadRealIndex(t)
|
||||
// The scaffold's own output, checked against the modules it composes, is clean.
|
||||
env := renderEnvExample(idx, "myapp")
|
||||
findings, err := checkEnvVars(idx, env, []string{"core", "web", "db-postgres"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range findings {
|
||||
if f.Severity == "error" {
|
||||
t.Errorf("scaffold .env.example should have no errors, got %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEnvUnknownModule(t *testing.T) {
|
||||
idx := loadRealIndex(t)
|
||||
if _, err := checkEnvVars(idx, "", []string{"nope"}); err == nil {
|
||||
t.Error("expected error for unknown module")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user