feat(mcp): derive env vars from the framework's real struct tags #3

Merged
Rene Nochebuena merged 1 commits from feat/env-reconciliation into main 2026-08-07 17:00:02 -06:00
14 changed files with 1003 additions and 50 deletions
Showing only changes of commit 8c6cf2c27a - Show all commits
+50
View File
@@ -6,6 +6,56 @@ This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html
--- ---
## [1.1.0] — 2026-08-07
Minor release. The env-var story is now **derived from the framework's real struct tags** end
to end: a tool to read the truth, `.env.example` generated from it, and a rule that rejects
invented names. Also folds in the scaffold-symbol fixes that were staged as v1.0.1 (never
tagged separately). The generated scaffold compiles clean against einherjar v1.0.0.
### Added
- **`get_config_env` tool.** Lists the real environment variables a component config reads —
name, declaring module/struct/field, `required`, and default — parsed from the indexed
struct tags (`internal/envspec`). The single source of truth for which `EINHERJAR_*` vars
exist; no hand-maintained list to drift.
- **`check_env` tool.** Checks a `.env` / `.env.example` against the framework: flags
`EINHERJAR_*` names that don't exist (e.g. `EINHERJAR_PG_DATABASE`), required vars missing
for the modules an app composes, and vars set for a module it does not compose (dead vars,
the way `EINHERJAR_SERVER_CORS_ORIGINS` is inert unless you compose `web.Config`).
- **`config.unknown-env-var` rule** (now twelve). `validate_snippet` rejects any struct field
tagged `env:"EINHERJAR_*"` whose name the framework does not declare — the exact class of
drift that shipped `EINHERJAR_PG_DATABASE` / `_SERVER_ADDR`. The valid-name set is injected
from the index at startup, so the rule can never drift from the real tags.
### Changed
- **`get_scaffold` now derives `.env.example` from the index** instead of a hand-written
string. Required vars (no default) are emitted uncommented with a runnable dev value;
defaulted vars are emitted commented, documenting the framework default. Names and defaults
can no longer drift from the modules the scaffold composes (`core/logz` + `web/server` +
`db-postgres`).
- **The scaffold composes `logz.Config`** instead of hard-coding the logger, so
`EINHERJAR_LOG_LEVEL` / `_JSON` are live and documented rather than silently ignored.
Verified end to end: `env.Parse` loads `slog.Level` from `EINHERJAR_LOG_LEVEL`. (Log format
is now env-driven; set `EINHERJAR_LOG_JSON=true` in production.)
- **The `wire` builtin routes the incremental flow to the new tools.** Composing a component
later now points at `get_config_env("<module>")` for its exact vars and `check_env` to
confirm `.env.example` is complete — and spells out the split between framework `EINHERJAR_*`
(discoverable, name-checked) and app-owned `APP_*` (your discipline).
### Fixed
- **`logz.Logger``logging.Logger`.** The logger interface is `contracts/logging.Logger`;
`logz.New` returns it. The scaffold's health hook and the `wire` builtin's `withUsers`
example typed loggers as the non-existent `logz.Logger`.
- **`postgres.Component``postgres.Provider`** in hook signatures. `postgres.New` returns a
`Component` (a lifecycle component that embeds `Provider`); hooks and `NewUnitOfWork` take a
`Provider`.
- **The real env tags throughout:** `EINHERJAR_SERVER_ADDR``EINHERJAR_SERVER_HOST` +
`_PORT`, and `EINHERJAR_PG_DATABASE``EINHERJAR_PG_NAME` (plus `EINHERJAR_PG_SSL_MODE`).
- The scaffold's minimal health hook no longer takes unused `logger`/`db` parameters.
## [1.0.0] — 2026-08-07 ## [1.0.0] — 2026-08-07
The MCP reaches **v1.0.0**, aligned with the v1.0.0 framework. The headline is the The MCP reaches **v1.0.0**, aligned with the v1.0.0 framework. The headline is the
+8 -5
View File
@@ -1,6 +1,6 @@
# einherjar/mcp # einherjar/mcp
[![version](https://img.shields.io/badge/version-v1.0.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/mcp) [![version](https://img.shields.io/badge/version-v1.1.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/mcp)
[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE) [![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)
[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev) [![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev)
@@ -38,7 +38,7 @@ under pressure.
## Tools ## Tools
The server exposes **eleven** tools to MCP-aware clients (Claude desktop, Claude Code, The server exposes **thirteen** tools to MCP-aware clients (Claude desktop, Claude Code,
Cursor, Zed, and anything else that speaks MCP): Cursor, Zed, and anything else that speaks MCP):
| Tool | Purpose | | Tool | Purpose |
@@ -50,16 +50,19 @@ Cursor, Zed, and anything else that speaks MCP):
| `list_adrs` | List architectural decision records, optionally restricted to one module | | `list_adrs` | List architectural decision records, optionally restricted to one module |
| `get_adr` | Fetch a single ADR's markdown body | | `get_adr` | Fetch a single ADR's markdown body |
| `get_example` | Canonical usage snippet — pulled from module READMEs and from the synthetic `wire` conventions | | `get_example` | Canonical usage snippet — pulled from module READMEs and from the synthetic `wire` conventions |
| `get_scaffold` | The canonical **minimum application scaffold** as ready-to-write files — a clean `main.go`, `internal/wire/wire.go`, a composed `internal/config`, a health hook, and `.env.example`. Use it when starting a new Einherjar service | | `get_scaffold` | The canonical **minimum application scaffold** as ready-to-write files — a clean `main.go`, `internal/wire/wire.go`, a composed `internal/config`, a health hook, and a **`.env.example` derived from the real component-config tags**. Use it when starting a new Einherjar service |
| `get_config_env` | The **real** env vars a component config reads — name, declaring module/struct/field, required, and default — derived from the framework's struct tags. The source of truth for building a global config or a `.env` |
| `check_env` | Check a `.env` / `.env.example` against the framework: flags EINHERJAR_* names that don't exist (e.g. `EINHERJAR_PG_DATABASE`), missing required vars for the modules you compose, and vars set for a module you don't compose |
| `get_compliance` | Interface assertions and structural test names from a module's `compliance_test.go` | | `get_compliance` | Interface assertions and structural test names from a module's `compliance_test.go` |
| `get_changelog` | Full `CHANGELOG.md` markdown for one module | | `get_changelog` | Full `CHANGELOG.md` markdown for one module |
| `validate_snippet` | Pattern-match a Go snippet against framework conventions; returns findings with severity, hint, and line | | `validate_snippet` | Pattern-match a Go snippet against framework conventions; returns findings with severity, hint, and line |
`validate_snippet` ships **eleven** wiring-convention rules at v1.0.0: `validate_snippet` ships **twelve** wiring-convention rules:
`launcher.missing-run`, `launcher.no-components`, `launcher.run-error-discarded`, `launcher.missing-run`, `launcher.no-components`, `launcher.run-error-discarded`,
`logz.direct-env-read`, `web.server-not-appended`, `wire.hook-bad-signature`, `logz.direct-env-read`, `web.server-not-appended`, `wire.hook-bad-signature`,
`wire.hook-outside-beforestart`, `wire.route-specific-after-param`, `wire.hook-outside-beforestart`, `wire.route-specific-after-param`,
`main.dirty`, `main.no-godotenv-autoload`, and `config.raw-getenv`. `main.dirty`, `main.no-godotenv-autoload`, `config.raw-getenv`, and
`config.unknown-env-var` (rejects an `env:"EINHERJAR_*"` tag the framework doesn't declare).
--- ---
+1 -1
View File
@@ -23,7 +23,7 @@ import (
const ( const (
serverName = "einherjar-mcp" serverName = "einherjar-mcp"
serverVersion = "v1.0.0" serverVersion = "v1.1.0"
) )
func main() { func main() {
+157
View File
@@ -0,0 +1,157 @@
// Package envspec derives the real environment variables of Einherjar's
// component configs from the framework index — the single source of truth for
// which EINHERJAR_* vars exist, which are required, and what they default to.
//
// Every Einherjar component (db-postgres, web/server, core/logz, …) exposes a
// Config struct whose fields carry caarlos0/env tags. The indexer already
// captures those tags (index.Field.Tag); this package parses them so tools can
// build a correct global config, generate an accurate .env.example, and reject
// invented or misspelled var names — instead of anyone maintaining that list by
// hand and drifting from the framework.
package envspec
import (
"reflect"
"sort"
"strings"
"code.nochebuena.dev/einherjar/mcp/internal/index"
)
// Var is one environment variable declared by a component Config field.
type Var struct {
Name string `json:"name"` // e.g. EINHERJAR_PG_HOST
Module string `json:"module"` // e.g. db-postgres
SubPackage string `json:"subPackage,omitempty"` // e.g. server (empty at module root)
Struct string `json:"struct"` // the declaring struct, e.g. Config
Field string `json:"field"` // the Go field, e.g. Host
Type string `json:"type"` // the Go type, e.g. string
Required bool `json:"required"` // env:"...,required"
Default string `json:"default,omitempty"` // envDefault:"..." (empty when none)
HasDefault bool `json:"hasDefault"` // distinguishes "" default from no default
Doc string `json:"doc,omitempty"` // field doc comment, if any
}
// ParseTag extracts the env var declared by a raw struct tag (backticks already
// stripped, as stored in the index). ok is false when the tag has no env key or
// the key is "-" (explicitly excluded, e.g. an http.RoundTripper field).
func ParseTag(tag string) (v struct {
Name string
Required bool
Default string
HasDefault bool
}, ok bool) {
st := reflect.StructTag(tag)
raw, present := st.Lookup("env")
if !present {
return v, false
}
parts := strings.Split(raw, ",")
name := strings.TrimSpace(parts[0])
if name == "" || name == "-" {
return v, false
}
v.Name = name
for _, opt := range parts[1:] {
if strings.TrimSpace(opt) == "required" {
v.Required = true
}
}
if def, has := st.Lookup("envDefault"); has {
v.Default = def
v.HasDefault = true
}
ok = true
return v, ok
}
// varsForSymbol returns the env vars declared directly by a struct symbol's
// fields. Nested component-config fields (a field whose type is another Config,
// carrying no env tag of its own) are not expanded here — caller-facing helpers
// surface them because sibling structs are indexed independently.
func varsForSymbol(m index.Module, s index.Symbol) []Var {
var out []Var
for _, f := range s.Fields {
parsed, ok := ParseTag(f.Tag)
if !ok {
continue
}
out = append(out, Var{
Name: parsed.Name,
Module: m.Name,
SubPackage: s.SubPackage,
Struct: s.Name,
Field: f.Name,
Type: f.Type,
Required: parsed.Required,
Default: parsed.Default,
HasDefault: parsed.HasDefault,
Doc: strings.TrimSpace(f.Doc),
})
}
return out
}
// ForModule returns every env var declared by any struct in a module, across
// all its sub-packages (e.g. web yields web/server's EINHERJAR_SERVER_* plus
// web's EINHERJAR_SERVER_CORS_ORIGINS plus web/health's timeout). Returns nil
// when the module is unknown. Order is deterministic: sub-package, then struct,
// then declaration order.
func ForModule(idx *index.Index, module string) []Var {
m := idx.FindModule(module)
if m == nil {
return nil
}
var out []Var
for _, s := range m.Symbols {
if s.Kind != "type" || len(s.Fields) == 0 {
continue
}
out = append(out, varsForSymbol(*m, s)...)
}
return out
}
// FindStruct returns the env vars of one specific struct, identified by module,
// sub-package (empty for a module-root package), and struct name — the precise
// selector a scaffold uses to compose named component configs. Returns nil when
// no such struct is indexed.
func FindStruct(idx *index.Index, module, subPackage, name string) []Var {
m := idx.FindModule(module)
if m == nil {
return nil
}
for _, s := range m.Symbols {
if s.Kind == "type" && s.Name == name && s.SubPackage == subPackage {
return varsForSymbol(*m, s)
}
}
return nil
}
// All returns every env var declared anywhere in the framework, sorted by name.
func All(idx *index.Index) []Var {
var out []Var
for i := range idx.Modules {
m := idx.Modules[i]
for _, s := range m.Symbols {
if s.Kind != "type" || len(s.Fields) == 0 {
continue
}
out = append(out, varsForSymbol(m, s)...)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// KnownNames returns the set of every valid framework env-var name. It backs the
// config.unknown-env-var rule, which rejects any EINHERJAR_* tag that is not in
// this set (the exact bug class that shipped EINHERJAR_PG_DATABASE / _SERVER_ADDR).
func KnownNames(idx *index.Index) map[string]struct{} {
names := map[string]struct{}{}
for _, v := range All(idx) {
names[v.Name] = struct{}{}
}
return names
}
+132
View File
@@ -0,0 +1,132 @@
package envspec
import (
"testing"
"code.nochebuena.dev/einherjar/mcp/internal/index"
)
func TestParseTag(t *testing.T) {
cases := []struct {
tag string
wantOK bool
wantName string
wantReq bool
wantDef string
wantHas bool
}{
{`env:"EINHERJAR_PG_HOST,required"`, true, "EINHERJAR_PG_HOST", true, "", false},
{`env:"EINHERJAR_PG_PORT" envDefault:"5432"`, true, "EINHERJAR_PG_PORT", false, "5432", true},
{`env:"EINHERJAR_LOG_JSON" envDefault:"false"`, true, "EINHERJAR_LOG_JSON", false, "false", true},
{`env:"-"`, false, "", false, "", false},
{`json:"x"`, false, "", false, "", false},
{``, false, "", false, "", false},
}
for _, c := range cases {
got, ok := ParseTag(c.tag)
if ok != c.wantOK {
t.Errorf("ParseTag(%q) ok=%v want %v", c.tag, ok, c.wantOK)
continue
}
if !ok {
continue
}
if got.Name != c.wantName || got.Required != c.wantReq || got.Default != c.wantDef || got.HasDefault != c.wantHas {
t.Errorf("ParseTag(%q) = %+v, want name=%q req=%v def=%q has=%v",
c.tag, got, c.wantName, c.wantReq, c.wantDef, c.wantHas)
}
}
}
// synthIndex mirrors the shape of the real index: a web module whose server
// sub-package and root both declare env vars, plus a db-postgres module.
func synthIndex() *index.Index {
return &index.Index{
Modules: []index.Module{
{
Name: "web",
Symbols: []index.Symbol{
{Kind: "type", Name: "Config", SubPackage: "server", Fields: []index.Field{
{Name: "Host", Type: "string", Tag: `env:"EINHERJAR_SERVER_HOST" envDefault:"0.0.0.0"`},
{Name: "Port", Type: "int", Tag: `env:"EINHERJAR_SERVER_PORT" envDefault:"8080"`},
}},
{Kind: "type", Name: "Config", SubPackage: "", Fields: []index.Field{
{Name: "Server", Type: "server.Config"}, // nested, no env tag
{Name: "AllowedOrigins", Type: "[]string", Tag: `env:"EINHERJAR_SERVER_CORS_ORIGINS" envSeparator:","`},
}},
},
},
{
Name: "db-postgres",
Symbols: []index.Symbol{
{Kind: "type", Name: "Config", SubPackage: "", Fields: []index.Field{
{Name: "Host", Type: "string", Tag: `env:"EINHERJAR_PG_HOST,required"`},
{Name: "Name", Type: "string", Tag: `env:"EINHERJAR_PG_NAME,required"`},
{Name: "Port", Type: "int", Tag: `env:"EINHERJAR_PG_PORT" envDefault:"5432"`},
}},
},
},
},
}
}
func TestForModule(t *testing.T) {
idx := synthIndex()
got := ForModule(idx, "web")
if len(got) != 3 { // 2 server + 1 CORS; the nested Server field is not a leaf
t.Fatalf("ForModule(web) = %d vars, want 3: %+v", len(got), got)
}
names := map[string]bool{}
for _, v := range got {
names[v.Name] = true
}
for _, want := range []string{"EINHERJAR_SERVER_HOST", "EINHERJAR_SERVER_PORT", "EINHERJAR_SERVER_CORS_ORIGINS"} {
if !names[want] {
t.Errorf("ForModule(web) missing %s", want)
}
}
if ForModule(idx, "nope") != nil {
t.Error("ForModule(unknown) should be nil")
}
}
func TestFindStruct(t *testing.T) {
idx := synthIndex()
server := FindStruct(idx, "web", "server", "Config")
if len(server) != 2 {
t.Fatalf("FindStruct(web/server/Config) = %d, want 2", len(server))
}
// The root web.Config must NOT be returned for the server selector.
for _, v := range server {
if v.Name == "EINHERJAR_SERVER_CORS_ORIGINS" {
t.Error("server selector leaked the root web.Config CORS var")
}
}
pg := FindStruct(idx, "db-postgres", "", "Config")
if len(pg) != 3 {
t.Fatalf("FindStruct(db-postgres//Config) = %d, want 3", len(pg))
}
var host envspecVarFound
for _, v := range pg {
if v.Name == "EINHERJAR_PG_HOST" {
host = envspecVarFound{found: true, required: v.Required, hasDefault: v.HasDefault}
}
}
if !host.found || !host.required || host.hasDefault {
t.Errorf("EINHERJAR_PG_HOST: %+v, want required with no default", host)
}
}
type envspecVarFound struct {
found, required, hasDefault bool
}
func TestKnownNames(t *testing.T) {
names := KnownNames(synthIndex())
if _, ok := names["EINHERJAR_PG_NAME"]; !ok {
t.Error("KnownNames missing EINHERJAR_PG_NAME")
}
if _, ok := names["EINHERJAR_PG_DATABASE"]; ok {
t.Error("KnownNames must not contain the bogus EINHERJAR_PG_DATABASE")
}
}
+35 -13
View File
@@ -84,6 +84,7 @@ import (
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
"code.nochebuena.dev/einherjar/core/logz"
"code.nochebuena.dev/einherjar/db-postgres" "code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/server" "code.nochebuena.dev/einherjar/web/server"
) )
@@ -108,6 +109,7 @@ type Config struct {
// Framework component configs — composed verbatim. Their own EINHERJAR_* tags // Framework component configs — composed verbatim. Their own EINHERJAR_* tags
// load through this one env.Parse call. // load through this one env.Parse call.
Log logz.Config // EINHERJAR_LOG_*
Server server.Config // EINHERJAR_SERVER_* Server server.Config // EINHERJAR_SERVER_*
PG postgres.Config // EINHERJAR_PG_* PG postgres.Config // EINHERJAR_PG_*
} }
@@ -133,6 +135,21 @@ var, the same change adds its `env:"..."` tag to `config` **and** a documented l
`.env.example`. This is not optional bookkeeping — it is what stops a long feature from shipping `.env.example`. This is not optional bookkeeping — it is what stops a long feature from shipping
and then failing at boot because nobody knew which variables to set. and then failing at boot because nobody knew which variables to set.
**Two kinds of var, two ways to keep them honest:**
- **Framework component vars (`EINHERJAR_*`)** — when you compose a new component later (e.g.
`cachevalkey.Config`, `minio.Config`, `smtp.Config`), the MCP knows its real vars: call
`get_config_env("<module>")` for the exact set (name, required, default) and add each to
`.env.example`. The `config.unknown-env-var` rule rejects any `EINHERJAR_*` tag the framework
doesn't declare, so a typo like `EINHERJAR_PG_DATABASE` is caught at `validate_snippet` time.
- **App-owned vars (`APP_*`)** — like `APP_JWT_SECRET` above: the framework can't know these, so
keeping them in `.env.example` is your discipline, not something it can name-check.
After you compose a component, run **`check_env`** with the modules the app composes: it flags
`EINHERJAR_*` names that don't exist, required vars you forgot to document, and vars set for a
module you don't actually compose (dead vars). `get_scaffold` already emits a `.env.example`
derived from these same tags, so the starting point is correct by construction.
```bash ```bash
# .env.example — copy to .env for local dev. Every var the app reads lives here. # .env.example — copy to .env for local dev. Every var the app reads lives here.
@@ -142,19 +159,25 @@ APP_CORS_ORIGINS=*
APP_JWT_SECRET=change-me APP_JWT_SECRET=change-me
APP_JWT_ISSUER=myapp APP_JWT_ISSUER=myapp
# ── Einherjar: logging (EINHERJAR_LOG_*) ──────────────────────────────────
# EINHERJAR_LOG_LEVEL=INFO
# EINHERJAR_LOG_JSON=false
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ─────────────────────────── # ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────
EINHERJAR_SERVER_ADDR=:8080 EINHERJAR_SERVER_HOST=0.0.0.0
EINHERJAR_SERVER_PORT=8080
# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ──────────────────────────────── # ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────
EINHERJAR_PG_HOST=localhost EINHERJAR_PG_HOST=localhost
EINHERJAR_PG_PORT=5432 EINHERJAR_PG_PORT=5432
EINHERJAR_PG_USER=postgres EINHERJAR_PG_USER=postgres
EINHERJAR_PG_PASSWORD=postgres EINHERJAR_PG_PASSWORD=postgres
EINHERJAR_PG_DATABASE=myapp EINHERJAR_PG_NAME=myapp
``` ```
To discover the full set, walk every `env:"..."` tag reachable from `config.Config` (including the To discover the full set for any component you compose, use `get_config_env("<module>")` rather
nested framework configs) — every one of them belongs in `.env.example`. than reading source by hand; every var it returns belongs in `.env.example`, and `check_env`
confirms none are missing, misspelled, or dead.
## wire.go — Run() ## wire.go — Run()
@@ -166,8 +189,6 @@ appended, then feature hooks, then `lc.Run()`.
package wire package wire
import ( import (
"strings"
"github.com/google/uuid" "github.com/google/uuid"
authjwt "code.nochebuena.dev/einherjar/auth-jwt" authjwt "code.nochebuena.dev/einherjar/auth-jwt"
@@ -189,10 +210,11 @@ func Run() error {
return err return err
} }
logger := logz.New(logz.Config{ // logz.Config is composed in config, so EINHERJAR_LOG_LEVEL / _JSON load from
JSON: !strings.EqualFold(cfg.AppEnv, "local"), // the environment; StaticArgs are set here (they carry no env tag).
StaticArgs: []any{"service", "myapp", "env", cfg.AppEnv}, logCfg := cfg.Log
}) logCfg.StaticArgs = []any{"service", "myapp", "env", cfg.AppEnv}
logger := logz.New(logCfg)
signer := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret)) signer := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret))
@@ -239,9 +261,9 @@ registration — lives inside the closure.
package wire package wire
import ( import (
"code.nochebuena.dev/einherjar/contracts/logging"
"code.nochebuena.dev/einherjar/contracts/security" "code.nochebuena.dev/einherjar/contracts/security"
"code.nochebuena.dev/einherjar/core/launcher" "code.nochebuena.dev/einherjar/core/launcher"
"code.nochebuena.dev/einherjar/core/logz"
"code.nochebuena.dev/einherjar/core/valid" "code.nochebuena.dev/einherjar/core/valid"
"code.nochebuena.dev/einherjar/db-postgres" "code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/server" "code.nochebuena.dev/einherjar/web/server"
@@ -255,8 +277,8 @@ import (
func withUsers( func withUsers(
lc launcher.Launcher, lc launcher.Launcher,
srv server.Server, srv server.Server,
db postgres.Component, db postgres.Provider,
logger logz.Logger, logger logging.Logger,
provider security.PermissionProvider, provider security.PermissionProvider,
v valid.Validator, v valid.Validator,
) { ) {
+82
View File
@@ -0,0 +1,82 @@
package rules
import (
"go/ast"
"reflect"
"strings"
)
// knownEnvVars is the set of every real framework env-var name, injected once at
// server startup from the index (see rules.SetKnownEnvVars). When empty — e.g.
// in unit tests that exercise rules without an index — config.unknown-env-var is
// a no-op, so it never fires on incomplete knowledge.
var knownEnvVars map[string]struct{}
// SetKnownEnvVars installs the authoritative set of framework env-var names that
// backs config.unknown-env-var. Call once before serving; the server derives the
// set from the index so the rule can never drift from the real struct tags.
func SetKnownEnvVars(names map[string]struct{}) {
knownEnvVars = names
}
func init() {
registered = append(registered,
Rule{
ID: "config.unknown-env-var",
Severity: SeverityError,
Module: "wire",
Check: checkUnknownEnvVar,
},
)
}
// checkUnknownEnvVar flags any struct field tagged with an EINHERJAR_* env var
// that the framework does not actually declare — the exact class of drift that
// shipped EINHERJAR_PG_DATABASE (real name: _PG_NAME) and EINHERJAR_SERVER_ADDR
// (real: _SERVER_HOST/_PORT). App-owned prefixes (APP_*) are never flagged.
func checkUnknownEnvVar(c *Context) []Finding {
if len(knownEnvVars) == 0 {
return nil
}
var hits []Finding
ast.Inspect(c.File, func(n ast.Node) bool {
st, ok := n.(*ast.StructType)
if !ok || st.Fields == nil {
return true
}
for _, field := range st.Fields.List {
if field.Tag == nil {
continue
}
name, ok := envName(strings.Trim(field.Tag.Value, "`"))
if !ok || !strings.HasPrefix(name, "EINHERJAR_") {
continue
}
if _, real := knownEnvVars[name]; real {
continue
}
hits = append(hits, Finding{
Message: name + " is not a real Einherjar env var (invented or misspelled)",
Hint: "Verify the exact tag with get_config_env. The framework never invents EINHERJAR_* names; compose the component's real Config or fix the tag.",
Line: c.Fset.Position(field.Tag.Pos()).Line,
})
}
return true
})
return hits
}
// envName extracts the env-var name from a raw (backtick-stripped) struct tag,
// dropping the ,required / ,unset options. ok is false when there is no env key
// or it is "-".
func envName(tag string) (string, bool) {
raw, present := reflect.StructTag(tag).Lookup("env")
if !present {
return "", false
}
name := strings.TrimSpace(strings.Split(raw, ",")[0])
if name == "" || name == "-" {
return "", false
}
return name, true
}
+63
View File
@@ -0,0 +1,63 @@
package rules
import "testing"
const configWithBadTag = `package config
type Config struct {
Host string ` + "`" + `env:"EINHERJAR_PG_HOST,required"` + "`" + `
Bad string ` + "`" + `env:"EINHERJAR_PG_DATABASE"` + "`" + `
App string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + `
}
`
func findingsFor(fs []Finding, ruleID string) []Finding {
var out []Finding
for _, f := range fs {
if f.RuleID == ruleID {
out = append(out, f)
}
}
return out
}
func TestUnknownEnvVarFires(t *testing.T) {
SetKnownEnvVars(map[string]struct{}{"EINHERJAR_PG_HOST": {}})
defer SetKnownEnvVars(nil)
got := findingsFor(Run(configWithBadTag), "config.unknown-env-var")
if len(got) != 1 {
t.Fatalf("want 1 unknown-env-var finding, got %d: %+v", len(got), got)
}
if got[0].Severity != SeverityError {
t.Errorf("want error severity, got %s", got[0].Severity)
}
// EINHERJAR_PG_HOST is known and APP_ENV is app-owned — neither may be flagged.
for _, f := range got {
if f.Message == "" || f.Line == 0 {
t.Errorf("finding should carry a message and line: %+v", f)
}
}
}
func TestUnknownEnvVarNoopWithoutIndex(t *testing.T) {
SetKnownEnvVars(nil) // simulate no injected index
if got := findingsFor(Run(configWithBadTag), "config.unknown-env-var"); len(got) != 0 {
t.Errorf("rule must be a no-op without an injected var set, got %+v", got)
}
}
func TestUnknownEnvVarCleanConfig(t *testing.T) {
SetKnownEnvVars(map[string]struct{}{"EINHERJAR_PG_HOST": {}, "EINHERJAR_PG_NAME": {}})
defer SetKnownEnvVars(nil)
clean := `package config
type Config struct {
Host string ` + "`" + `env:"EINHERJAR_PG_HOST,required"` + "`" + `
Name string ` + "`" + `env:"EINHERJAR_PG_NAME,required"` + "`" + `
}
`
if got := findingsFor(Run(clean), "config.unknown-env-var"); len(got) != 0 {
t.Errorf("clean config should not fire, got %+v", got)
}
}
+179
View File
@@ -0,0 +1,179 @@
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:"the framework modules whose component configs the app composes, e.g. [\"db-postgres\",\"web\"]. Their required vars must be present; omit to only validate that EINHERJAR_* names are real."`
}
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 pass the modules the app composes — required vars that are missing, plus framework vars set for a module you don't compose (dead vars). " +
"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)
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, and the modules the app composes, it returns the drift findings.
func checkEnvVars(idx *index.Index, env string, modules []string) ([]envFinding, error) {
present := parseEnvKeys(env)
known := envspec.KnownNames(idx)
// Expected vars from the composed modules, and the module each real var belongs to.
expected := map[string]envspec.Var{}
composed := map[string]bool{}
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)
}
composed[mod] = true
for _, v := range envspec.ForModule(idx, mod) {
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. Real framework var set for a module the app doesn't compose → dead.
if len(composed) > 0 {
for name := range present {
mod, real := varModule[name]
if !real || composed[mod] {
continue
}
findings = append(findings, envFinding{
Severity: "info", Kind: "not-composed", Var: name,
Message: name + " belongs to module " + mod + ", which this app does not list as composed — it will be ignored",
Hint: "Either compose " + mod + "'s Config, or drop the var. (This is how EINHERJAR_SERVER_CORS_ORIGINS is dead unless you compose web.Config.)",
})
}
}
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
}
// 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)."
}
+160
View File
@@ -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")
}
}
+60
View File
@@ -0,0 +1,60 @@
package tools
import (
"context"
"strings"
"code.nochebuena.dev/einherjar/mcp/internal/envspec"
"code.nochebuena.dev/einherjar/mcp/internal/index"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type getConfigEnvInput struct {
Module string `json:"module,omitempty" jsonschema:"restrict to one module's env vars, e.g. db-postgres or web. Omit to return every framework env var."`
}
type getConfigEnvOutput struct {
Vars []envspec.Var `json:"vars"`
Count int `json:"count"`
Summary string `json:"summary"`
}
func registerGetConfigEnv(s *mcp.Server, idx *index.Index) {
mcp.AddTool(s, &mcp.Tool{
Name: "get_config_env",
Description: "List the REAL environment variables an Einherjar component config reads, derived from the framework's struct tags (never hand-maintained). " +
"Each entry gives the var name, the declaring module/struct/field, whether it is required, and its default. " +
"Use this before composing a global Config or writing a .env — it is the source of truth for which EINHERJAR_* vars exist. " +
"Pass a module (e.g. db-postgres, web, core, storage-minio) to scope it; omit to get all.",
}, func(ctx context.Context, req *mcp.CallToolRequest, args getConfigEnvInput) (*mcp.CallToolResult, getConfigEnvOutput, error) {
module := strings.TrimSpace(args.Module)
var vars []envspec.Var
if module == "" {
vars = envspec.All(idx)
} else {
if idx.FindModule(module) == nil {
return errorResult("module not found: " + module), getConfigEnvOutput{}, nil
}
vars = envspec.ForModule(idx, module)
}
if vars == nil {
vars = []envspec.Var{}
}
out := getConfigEnvOutput{Vars: vars, Count: len(vars), Summary: summariseEnv(vars, module)}
return jsonText(out), out, nil
})
}
func summariseEnv(vars []envspec.Var, module string) string {
var required int
for _, v := range vars {
if v.Required {
required++
}
}
scope := "the framework"
if module != "" {
scope = module
}
return itoa(len(vars)) + " env var(s) for " + scope + ", " + itoa(required) + " required."
}
+69 -30
View File
@@ -2,8 +2,10 @@ package tools
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"code.nochebuena.dev/einherjar/mcp/internal/envspec"
"code.nochebuena.dev/einherjar/mcp/internal/index" "code.nochebuena.dev/einherjar/mcp/internal/index"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@@ -54,11 +56,11 @@ func registerGetScaffold(s *mcp.Server, idx *index.Index) {
{Path: "internal/wire/wire.go", Content: repl.Replace(tplWire)}, {Path: "internal/wire/wire.go", Content: repl.Replace(tplWire)},
{Path: "internal/wire/health.go", Content: repl.Replace(tplHealth)}, {Path: "internal/wire/health.go", Content: repl.Replace(tplHealth)},
{Path: "internal/config/config.go", Content: repl.Replace(tplConfig)}, {Path: "internal/config/config.go", Content: repl.Replace(tplConfig)},
{Path: ".env.example", Content: repl.Replace(tplEnvExample)}, {Path: ".env.example", Content: renderEnvExample(idx, service)},
}, },
Notes: []string{ Notes: []string{
"main.go contains ONLY the godotenv autoload blank import and wire.Run() — never construct components there.", "main.go contains ONLY the godotenv autoload blank import and wire.Run() — never construct components there.",
"Every env var the config reads must also appear in .env.example, kept in lock-step. When a feature adds a var, update both.", ".env.example is DERIVED from the framework's real component-config tags (web/server + db-postgres), so the EINHERJAR_* names and defaults are always correct. When you compose another component, add its section — get_config_env(module) lists the exact vars, and check_env catches drift.",
"App-owned config fields use the APP_* prefix; framework component configs load their own EINHERJAR_* tags through the same env.Parse.", "App-owned config fields use the APP_* prefix; framework component configs load their own EINHERJAR_* tags through the same env.Parse.",
"Add one internal/wire/<feature>.go per feature (a with<Feature> hook) plus its internal/<feature>/{dto,handler,repository,service} layers; call validate_snippet / get_example(\"wire\") for the hook shape.", "Add one internal/wire/<feature>.go per feature (a with<Feature> hook) plus its internal/<feature>/{dto,handler,repository,service} layers; call validate_snippet / get_example(\"wire\") for the hook shape.",
"Migrations and seeding are the developer's choice — not part of this scaffold.", "Migrations and seeding are the developer's choice — not part of this scaffold.",
@@ -98,8 +100,6 @@ func main() {
const tplWire = `package wire const tplWire = `package wire
import ( import (
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"code.nochebuena.dev/einherjar/core/launcher" "code.nochebuena.dev/einherjar/core/launcher"
@@ -120,10 +120,11 @@ func Run() error {
return err return err
} }
logger := logz.New(logz.Config{ // logz.Config is composed in config.Config, so EINHERJAR_LOG_LEVEL / _JSON load
JSON: !strings.EqualFold(cfg.AppEnv, "local"), // from the environment; StaticArgs are set here in code (they carry no env tag).
StaticArgs: []any{"service", "%%APP%%", "env", cfg.AppEnv}, logCfg := cfg.Log
}) logCfg.StaticArgs = []any{"service", "%%APP%%", "env", cfg.AppEnv}
logger := logz.New(logCfg)
db := postgres.New(logger, cfg.PG) db := postgres.New(logger, cfg.PG)
srv := server.New(logger, cfg.Server, srv := server.New(logger, cfg.Server,
@@ -138,7 +139,7 @@ func Run() error {
lc := launcher.New(logger) lc := launcher.New(logger)
lc.Append(db, srv) lc.Append(db, srv)
withHealth(lc, srv, logger, db) withHealth(lc, srv)
// … one withFeature(lc, srv, …) call per feature in your domain. // … one withFeature(lc, srv, …) call per feature in your domain.
return lc.Run() return lc.Run()
@@ -151,14 +152,13 @@ import (
"net/http" "net/http"
"code.nochebuena.dev/einherjar/core/launcher" "code.nochebuena.dev/einherjar/core/launcher"
"code.nochebuena.dev/einherjar/core/logz"
"code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/server" "code.nochebuena.dev/einherjar/web/server"
) )
// withHealth registers a liveness endpoint. Grow it into a readiness check // withHealth registers a liveness endpoint — the simplest with<Feature> hook.
// (ping db and other dependencies) as the service gains infrastructure. // A real feature hook takes its deps (logging.Logger, postgres.Provider, …) and
func withHealth(lc launcher.Launcher, srv server.Server, logger logz.Logger, db postgres.Component) { // wires repo→service→handler inside the closure; see get_example("wire").
func withHealth(lc launcher.Launcher, srv server.Server) {
lc.BeforeStart(func() error { lc.BeforeStart(func() error {
srv.Get("/health", func(w http.ResponseWriter, r *http.Request) { srv.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -181,19 +181,21 @@ package config
import ( import (
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
"code.nochebuena.dev/einherjar/core/logz"
"code.nochebuena.dev/einherjar/db-postgres" "code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/server" "code.nochebuena.dev/einherjar/web/server"
) )
// Config is the fully-resolved startup configuration. caarlos0/env recurses into // Config is the fully-resolved startup configuration. caarlos0/env recurses into
// the nested framework configs, populating their EINHERJAR_SERVER_* / EINHERJAR_PG_* // the nested framework configs, populating their EINHERJAR_LOG_* / EINHERJAR_SERVER_* /
// tags from the environment next to the app-owned APP_* fields. // EINHERJAR_PG_* tags from the environment next to the app-owned APP_* fields.
type Config struct { type Config struct {
AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + ` AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + `
CORSOrigins []string ` + "`" + `env:"APP_CORS_ORIGINS" envSeparator:","` + "`" + ` CORSOrigins []string ` + "`" + `env:"APP_CORS_ORIGINS" envSeparator:","` + "`" + `
// Framework component configs — composed verbatim; their EINHERJAR_* tags // Framework component configs — composed verbatim; their EINHERJAR_* tags
// load through this same env.Parse call. // load through this same env.Parse call.
Log logz.Config // EINHERJAR_LOG_*
Server server.Config // EINHERJAR_SERVER_* Server server.Config // EINHERJAR_SERVER_*
PG postgres.Config // EINHERJAR_PG_* PG postgres.Config // EINHERJAR_PG_*
} }
@@ -207,20 +209,57 @@ func Load() (Config, error) {
} }
` `
const tplEnvExample = `# .env.example — copy to .env for local dev (godotenv autoloads it). // renderEnvExample builds .env.example from the framework's real component-config
# Every variable the config package reads lives here, documented. Keep in sync. // tags rather than a hand-maintained string, so the EINHERJAR_* names and defaults
// can never drift from the modules the scaffold composes (web/server + db-postgres).
// Required vars (no default) are emitted uncommented with a runnable dev value;
// vars that carry a framework default are emitted commented — documented, inert
// until overridden.
func renderEnvExample(idx *index.Index, app string) string {
var b strings.Builder
b.WriteString("# .env.example — copy to .env for local dev (godotenv autoloads it).\n")
b.WriteString("# Derived from the framework's real config tags. Required vars are uncommented;\n")
b.WriteString("# a commented line documents that var's framework default — uncomment to override.\n\n")
# ── App (APP_*) ──────────────────────────────────────────────────────────── b.WriteString("# ── App (APP_*) ────────────────────────────────────────────────────────────\n")
APP_ENV=local b.WriteString("APP_ENV=local\n")
APP_CORS_ORIGINS=* b.WriteString("APP_CORS_ORIGINS=*\n\n")
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ─────────────────────────── writeEnvSection(&b, "Einherjar: logging", envspec.FindStruct(idx, "core", "logz", "Config"), app)
EINHERJAR_SERVER_ADDR=:8080 writeEnvSection(&b, "Einherjar: HTTP server", envspec.FindStruct(idx, "web", "server", "Config"), app)
writeEnvSection(&b, "Einherjar: PostgreSQL", envspec.FindStruct(idx, "db-postgres", "", "Config"), app)
return b.String()
}
# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ──────────────────────────────── // envDevDefaults gives required vars (which carry no framework default) a value
EINHERJAR_PG_HOST=localhost // that runs against the local devtools stack out of the box.
EINHERJAR_PG_PORT=5432 var envDevDefaults = map[string]string{
EINHERJAR_PG_USER=postgres "EINHERJAR_PG_HOST": "localhost",
EINHERJAR_PG_PASSWORD=postgres "EINHERJAR_PG_USER": "postgres",
EINHERJAR_PG_DATABASE=%%APP%% "EINHERJAR_PG_PASSWORD": "postgres",
` }
func envDevValue(v envspec.Var, app string) string {
if v.Name == "EINHERJAR_PG_NAME" {
return app
}
return envDevDefaults[v.Name]
}
func writeEnvSection(b *strings.Builder, title string, vars []envspec.Var, app string) {
if len(vars) == 0 {
return
}
fmt.Fprintf(b, "# ── %s ──────────────────────────────────────\n", title)
for _, v := range vars {
switch {
case v.HasDefault:
fmt.Fprintf(b, "# %s=%s\n", v.Name, v.Default)
case v.Required:
fmt.Fprintf(b, "%s=%s\n", v.Name, envDevValue(v, app))
default:
fmt.Fprintf(b, "# %s=%s\n", v.Name, envDevValue(v, app))
}
}
b.WriteString("\n")
}
+2
View File
@@ -20,6 +20,8 @@ func Register(s *mcp.Server, idx *index.Index) {
registerGetADR(s, idx) registerGetADR(s, idx)
registerGetExample(s, idx) registerGetExample(s, idx)
registerGetScaffold(s, idx) registerGetScaffold(s, idx)
registerGetConfigEnv(s, idx)
registerCheckEnv(s, idx)
registerValidateSnippet(s, idx) registerValidateSnippet(s, idx)
registerGetCompliance(s, idx) registerGetCompliance(s, idx)
registerGetChangelog(s, idx) registerGetChangelog(s, idx)
+5 -1
View File
@@ -3,6 +3,7 @@ package tools
import ( import (
"context" "context"
"code.nochebuena.dev/einherjar/mcp/internal/envspec"
"code.nochebuena.dev/einherjar/mcp/internal/index" "code.nochebuena.dev/einherjar/mcp/internal/index"
"code.nochebuena.dev/einherjar/mcp/internal/rules" "code.nochebuena.dev/einherjar/mcp/internal/rules"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
@@ -17,7 +18,10 @@ type validateSnippetOutput struct {
Summary string `json:"summary"` Summary string `json:"summary"`
} }
func registerValidateSnippet(s *mcp.Server, _ *index.Index) { func registerValidateSnippet(s *mcp.Server, idx *index.Index) {
// Inject the real framework env-var names so config.unknown-env-var can reject
// invented/misspelled EINHERJAR_* tags instead of guessing.
rules.SetKnownEnvVars(envspec.KnownNames(idx))
mcp.AddTool(s, &mcp.Tool{ mcp.AddTool(s, &mcp.Tool{
Name: "validate_snippet", Name: "validate_snippet",
Description: "Validate a Go snippet against Einherjar wiring conventions: lifecycle setup, logger configuration, env-var handling, server registration. Findings are advisory, not a substitute for go vet or the project's tests.", Description: "Validate a Go snippet against Einherjar wiring conventions: lifecycle setup, logger configuration, env-var handling, server registration. Findings are advisory, not a substitute for go vet or the project's tests.",