feat(mcp): scaffold tool, config/env conventions, and scaffold-hygiene rules

Major release to v1.0.0, aligned with the v1.0.0 framework. The MCP documented
the wiring conventions but not the config half of a project, and its example
main.go omitted the godotenv autoload — so an assistant starting a service from
zero still hand-rolled main.go and the launcher, and got config wrong. This adds
a first-class scaffold, completes the config/.env.example conventions, and adds
rules that catch the "mess in main" pattern.

internal/tools:
- New get_scaffold: returns the canonical minimum application scaffold as
  ready-to-write files (main.go with godotenv autoload + wire.Run(), wire.go,
  a composed config.go, a health hook, .env.example), with import paths filled
  from a `module` argument. Registered in tools.go.

internal/rules:
- Three new validate_snippet rules, appended in scaffold_rules.go: main.dirty
  (launcher/components built in main instead of internal/wire),
  main.no-godotenv-autoload (a wire-convention main that never loads .env), and
  config.raw-getenv (an EINHERJAR_* var read via os.Getenv instead of composing
  the component Config; EINHERJAR_LOG_* stays with logz.direct-env-read).
- scaffold_rules_test.go — internal/rules had no tests; asserts each new rule
  fires and that a clean main is not flagged.

internal/index (builtins):
- The synthetic wire module gains a Config section (compose the framework's
  component configs, load with caarlos0/env, APP_* app fields / EINHERJAR_*
  framework fields) and a Config & .env.example discipline (every env var the
  config reads is documented in .env.example, kept in lock-step).
- main.go now shows the `_ "github.com/joho/godotenv/autoload"` blank import,
  previously omitted. The assembly file is renamed launcher.go -> wire.go.
- Migrations and seeding removed from the documented scaffold — developer
  choices, not framework conventions. Re-synced against iron-dough-api / pei-api.

Version:
- Badge and serverVersion const were stale at v0.1.0; both now v1.0.0.

Docs:
- README (eleven tools, eleven validation rules) and CHANGELOG updated.

No new dependencies. The wire conventions are embedded at build time
(//go:embed builtins/README.md) and the new tool and rules are compiled in, so a
deployment must be rebuilt to serve them; a server still running the v0.2.0
binary keeps serving the old conventions until redeployed.
This commit is contained in:
2026-08-07 12:21:00 -06:00
parent 13a186c60a
commit 262eade93d
8 changed files with 624 additions and 85 deletions
+38
View File
@@ -6,6 +6,44 @@ This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html
--- ---
## [1.0.0] — 2026-08-07
The MCP reaches **v1.0.0**, aligned with the v1.0.0 framework. The headline is the
canonical application **scaffold**: the one opinionated starting point so an AI no longer
hand-rolls `main.go` and the launcher when creating a service from zero.
### Added
- **`get_scaffold` tool.** Returns the canonical minimum application scaffold as
ready-to-write files, with import paths filled from a `module` argument: a clean `main.go`
(godotenv autoload + `wire.Run()`), `internal/wire/wire.go` (the launcher assembly), a
composed `internal/config/config.go`, a health feature hook, and `.env.example`.
- **Three `validate_snippet` rules** (now eleven total), with a rules test suite:
`main.dirty` (the launcher/components built in `main` instead of `internal/wire`),
`main.no-godotenv-autoload` (a wire-convention `main` that never loads `.env`), and
`config.raw-getenv` (reading a framework `EINHERJAR_*` var via `os.Getenv` instead of
composing the component's `Config` type).
- **Config conventions in the `wire` builtin.** A `Config` section (compose the framework's
component configs, load with `caarlos0/env`, `APP_*` for app-owned fields, `EINHERJAR_*`
for framework ones) and a **Config & .env.example** section: every env var the config
reads must also be documented in `.env.example`, kept in lock-step.
### Changed
- **`wire` builtin re-synced to the current gold standards** (`iron-dough-api`, `pei-api`):
`main.go` now shows the `_ "github.com/joho/godotenv/autoload"` blank import (previously
omitted, so the AI produced a `main` that never loaded `.env`); the assembly file is
`wire.go` (was `launcher.go`).
- **Version alignment.** The README badge and `serverVersion` were stale at `v0.1.0`; both
now read `v1.0.0`.
### Removed
- **Migrations and seeding from the `wire` builtin.** How migrations run and how the first
admin is seeded (via code, a DB team, an endpoint, a webhook, …) is the developer's
choice — it belongs to no Einherjar module and is not a hard convention, so it is out of
the scaffold and the documented conventions.
## [0.2.0] — 2026-06-10 ## [0.2.0] — 2026-06-10
Minor release. The indexer now captures the *members* of composite types, closing a gap where `get_symbol` and `search_symbols` could name a struct or interface but not describe its shape — most painfully, struct tags (env-var keys, json names) were invisible. Minor release. The indexer now captures the *members* of composite types, closing a gap where `get_symbol` and `search_symbols` could name a struct or interface but not describe its shape — most painfully, struct tags (env-var keys, json names) were invisible.
+6 -4
View File
@@ -1,6 +1,6 @@
# einherjar/mcp # einherjar/mcp
[![version](https://img.shields.io/badge/version-v0.1.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/mcp) [![version](https://img.shields.io/badge/version-v1.0.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 **ten** tools to MCP-aware clients (Claude desktop, Claude Code, The server exposes **eleven** 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,14 +50,16 @@ 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_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 **eight** wiring-convention rules at v0.1.0: `validate_snippet` ships **eleven** wiring-convention rules at v1.0.0:
`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`, and `wire.route-specific-after-param`. `wire.hook-outside-beforestart`, `wire.route-specific-after-param`,
`main.dirty`, `main.no-godotenv-autoload`, and `config.raw-getenv`.
--- ---
+1 -1
View File
@@ -23,7 +23,7 @@ import (
const ( const (
serverName = "einherjar-mcp" serverName = "einherjar-mcp"
serverVersion = "v0.1.0" serverVersion = "v1.0.0"
) )
func main() { func main() {
+167 -80
View File
@@ -2,37 +2,165 @@
> Forging a service is mostly wiring. Do it the same way every time. > Forging a service is mostly wiring. Do it the same way every time.
This is not an Einherjar *module* — it is the canonical *application* shape This is not an Einherjar *module* — it is the canonical *application* shape that uses
that uses Einherjar modules. Apps live in their own repository with an Einherjar modules. Apps live in their own repository with an `internal/wire/` package that
`internal/wire/` package that mirrors this template. The conventions here are mirrors this template. The conventions here are distilled from production services built on
distilled from a production service that has shipped on the predecessor Einherjar v1 (`iron-dough-api`, `pei-api`) and describe the **one opinionated minimum** a
micro-libs (`code.nochebuena.dev/go/*`) and have been re-mapped to the scaffolded app should have. You *can* hand-roll something else — but then it is yours to
einherjar import paths. maintain, and it will not match what the rest of the ecosystem reads at a glance.
## The opinionated minimum
Every scaffolded Einherjar application has, at minimum:
1. **A clean `main.go`** — nothing but `.env` autoload and a call to `wire.Run()`.
2. **An `internal/wire/` package** — one file per feature plus `wire.go`, which assembles everything.
3. **An `internal/config/config.go`** — one global `Config` that *composes* the framework's
component configs, loaded from the environment with `caarlos0/env`.
4. **A `.env.example`** kept in lock-step with that config (see *Config & .env.example*).
Anything a developer freely chooses — how migrations run, how the first admin is seeded, an
init-by-endpoint/webhook/email flow — is **not** part of this convention and is left to the app.
## Project layout ## Project layout
``` ```
cmd/<app>/main.go one-line entrypoint that calls wire.Run() cmd/<app>/main.go one-line entrypoint: godotenv autoload + wire.Run()
internal/wire/launcher.go Run() — builds infra and registers feature hooks internal/wire/wire.go Run() — loads config, builds infra, registers feature hooks
internal/wire/<feature>.go one file per feature, hosts a with<Feature> hook internal/wire/<feature>.go one file per feature, hosts a with<Feature> hook
internal/wire/middleware.go authz, skipPublicPaths, skipMethodPath helpers internal/wire/middleware.go authz, skipPublicPaths, skipMethodPath helpers
internal/wire/migrations.go withMigrations hook internal/config/config.go global Config composing framework component configs
internal/wire/seed.go withSuperAdminSeed and other startup seeds .env.example every env var the config reads, documented, in sync
internal/<feature>/dto/ request/response DTOs internal/<feature>/dto/ request/response DTOs
internal/<feature>/handler/ HTTP handlers internal/<feature>/handler/ HTTP handlers
internal/<feature>/repository/ data access internal/<feature>/repository/ data access
internal/<feature>/service/ domain logic internal/<feature>/service/ domain logic
``` ```
`cmd/<app>/main.go` must contain nothing but the call to `wire.Run()` and an ## main.go
`os.Exit(1)` on error. Everything else lives in `internal/wire/`.
## Run `cmd/<app>/main.go` contains **nothing** but the `.env` autoload and the call to `wire.Run()`.
The blank import `_ "github.com/joho/godotenv/autoload"` is the standard, documented way to load
a local `.env` — it never overrides variables already set in the real environment, and a missing
file is not an error, so deployments (vars injected by the platform, no `.env`) are unaffected.
The application entry point. The order below is load-bearing: configuration ```go
first, observability second, infrastructure third, cross-cutting helpers package main
fourth, then the launcher with every component appended, then feature hooks,
then `lc.Run()`. import (
"fmt"
"os"
_ "github.com/joho/godotenv/autoload"
"myapp/internal/wire"
)
func main() {
if err := wire.Run(); err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}
```
No config parsing, no component construction, no logging setup — all of that lives in
`internal/wire/`. A `main.go` that builds anything itself is the single most common scaffolding
mistake.
## Config
`internal/config/config.go` is **one** `Config` struct that *composes* the framework's component
configs as nested fields, alongside the app's own settings. `caarlos0/env` recurses into the
nested fields, so each Einherjar component's `EINHERJAR_*` env tags load automatically next to
the app-owned fields. App-owned fields use the `APP_*` prefix so they never collide with the
framework's `EINHERJAR_*` namespace. There is exactly one `Load()`.
```go
package config
import (
"time"
"github.com/caarlos0/env/v11"
"code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/server"
)
// JWTConfig is app-owned: the secret is handed to the signer in code, not consumed
// by a framework component, so it carries no EINHERJAR_ prefix.
type JWTConfig struct {
Secret string `env:"APP_JWT_SECRET,required,notEmpty"`
Issuer string `env:"APP_JWT_ISSUER" envDefault:"myapp"`
AccessTTL time.Duration `env:"APP_JWT_ACCESS_TTL" envDefault:"1h"`
RefreshTTL time.Duration `env:"APP_JWT_REFRESH_TTL" envDefault:"168h"`
}
// Config is the fully-resolved startup configuration. Einherjar component configs
// are nested fields; caarlos0/env recurses into them, populating their
// EINHERJAR_SERVER_* / EINHERJAR_PG_* tags from the environment.
type Config struct {
AppEnv string `env:"APP_ENV" envDefault:"local"`
CORSOrigins []string `env:"APP_CORS_ORIGINS" envSeparator:","`
JWT JWTConfig
// Framework component configs — composed verbatim. Their own EINHERJAR_* tags
// load through this one env.Parse call.
Server server.Config // EINHERJAR_SERVER_*
PG postgres.Config // EINHERJAR_PG_*
}
func Load() (Config, error) {
var cfg Config
if err := env.Parse(&cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
```
Never read framework env vars (`EINHERJAR_*`) with `os.Getenv` — compose the component's `Config`
type and let `caarlos0/env` load it. A raw `os.Getenv("EINHERJAR_PG_HOST")` in application code is
the mistake this convention removes.
## Config & .env.example
Every environment variable the `config` package reads **must** also appear in `.env.example` at
the repo root, documented. The two are kept in **lock-step**: when a feature introduces a new env
var, the same change adds its `env:"..."` tag to `config` **and** a documented line to
`.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.
```bash
# .env.example — copy to .env for local dev. Every var the app reads lives here.
# ── App ───────────────────────────────────────────────────────────────────
APP_ENV=local
APP_CORS_ORIGINS=*
APP_JWT_SECRET=change-me
APP_JWT_ISSUER=myapp
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────
EINHERJAR_SERVER_ADDR=:8080
# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────
EINHERJAR_PG_HOST=localhost
EINHERJAR_PG_PORT=5432
EINHERJAR_PG_USER=postgres
EINHERJAR_PG_PASSWORD=postgres
EINHERJAR_PG_DATABASE=myapp
```
To discover the full set, walk every `env:"..."` tag reachable from `config.Config` (including the
nested framework configs) — every one of them belongs in `.env.example`.
## wire.go — Run()
The application entry point. The order below is load-bearing: configuration first, observability
second, infrastructure third, cross-cutting helpers fourth, then the launcher with every component
appended, then feature hooks, then `lc.Run()`.
```go ```go
package wire package wire
@@ -45,15 +173,12 @@ import (
authjwt "code.nochebuena.dev/einherjar/auth-jwt" authjwt "code.nochebuena.dev/einherjar/auth-jwt"
"code.nochebuena.dev/einherjar/auth/authmw" "code.nochebuena.dev/einherjar/auth/authmw"
"code.nochebuena.dev/einherjar/auth/rbac" "code.nochebuena.dev/einherjar/auth/rbac"
"code.nochebuena.dev/einherjar/cache-valkey"
"code.nochebuena.dev/einherjar/core/launcher" "code.nochebuena.dev/einherjar/core/launcher"
"code.nochebuena.dev/einherjar/core/logz" "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/storage-minio"
"code.nochebuena.dev/einherjar/web/mw" "code.nochebuena.dev/einherjar/web/mw"
"code.nochebuena.dev/einherjar/web/server" "code.nochebuena.dev/einherjar/web/server"
"code.nochebuena.dev/einherjar/worker"
"myapp/internal/config" "myapp/internal/config"
) )
@@ -77,11 +202,8 @@ func Run() error {
"/api/v1/auth/refresh", "/api/v1/auth/refresh",
} }
db := postgres.New(logger, cfg.PG) db := postgres.New(logger, cfg.PG)
cache := valkey.New(logger, cfg.VK) srv := server.New(logger, cfg.Server,
pool := worker.New(logger, cfg.Worker)
mc := minio.New(logger, cfg.MinIO)
srv := server.New(logger, cfg.Server,
server.WithMiddleware( server.WithMiddleware(
mw.RequestID(uuid.NewString), mw.RequestID(uuid.NewString),
mw.Recover(logger), mw.Recover(logger),
@@ -96,12 +218,9 @@ func Run() error {
provider := rbac.NewClaimsPermissionProvider("masks", claimsFromCtx) provider := rbac.NewClaimsPermissionProvider("masks", claimsFromCtx)
lc := launcher.New(logger) lc := launcher.New(logger)
lc.Append(db, cache, pool, mc, srv) lc.Append(db, srv)
withMigrations(lc, logger, cfg) withHealth(lc, srv, logger, db)
withSuperAdminSeed(lc, db, logger, cfg)
withHealth(lc, srv, logger, db, cache, mc)
withUsers(lc, srv, db, logger, provider, v) withUsers(lc, srv, db, logger, provider, v)
// … one withFeature(...) call per feature in your domain. // … one withFeature(...) call per feature in your domain.
@@ -112,9 +231,8 @@ func Run() error {
## Feature hook ## Feature hook
One file per feature in `internal/wire/`. The function signature is fixed: One file per feature in `internal/wire/`. The function signature is fixed:
`launcher.Launcher` first, `server.Server` second when registering routes, `launcher.Launcher` first, `server.Server` second when registering routes, deps last. The body is
deps last. The body is *one* call to `lc.BeforeStart`. Everything else — *one* call to `lc.BeforeStart`. Everything else — repository, service, handler construction, route
repository construction, service construction, handler construction, route
registration — lives inside the closure. registration — lives inside the closure.
```go ```go
@@ -149,9 +267,6 @@ func withUsers(
h := userhandler.New(svc, v) h := userhandler.New(svc, v)
// Literal-segment routes register BEFORE parametrised siblings. // Literal-segment routes register BEFORE parametrised siblings.
// chi matches the first registered route that fits; if /users/{id}
// came first, "me" would bind to {id} and /users/me/password would
// never be reached.
srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword) srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword)
srv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)). srv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).
@@ -160,8 +275,6 @@ func withUsers(
Post("/api/v1/users", h.CreateUser) Post("/api/v1/users", h.CreateUser)
srv.With(authz(provider, domains.ResourceUsers, domains.GrantUpdateUser)). srv.With(authz(provider, domains.ResourceUsers, domains.GrantUpdateUser)).
Put("/api/v1/users/{user_id}", h.UpdateUser) Put("/api/v1/users/{user_id}", h.UpdateUser)
srv.With(authz(provider, domains.ResourceUsers, domains.GrantDeleteUser)).
Delete("/api/v1/users/{user_id}", h.DeleteUser)
return nil return nil
}) })
@@ -170,8 +283,8 @@ func withUsers(
## Route ordering ## Route ordering
chi matches paths in registration order. Always register literal-segment chi matches paths in registration order. Always register literal-segment routes before
routes before parametrised-segment routes that share the same prefix. parametrised-segment routes that share the same prefix.
✅ Correct: ✅ Correct:
@@ -196,14 +309,12 @@ srv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).
Get("/api/v1/users", h.ListUsers) Get("/api/v1/users", h.ListUsers)
``` ```
Resource constants and grant bits live in `internal/domains/`. Routes that Resource constants and grant bits live in `internal/domains/`. Routes that the caller owns
the caller owns (`/me/...`) intentionally skip authz — they are reachable to (`/me/...`) intentionally skip authz — they are reachable to any authenticated user.
any authenticated user.
## Middleware helpers ## Middleware helpers
These belong in `internal/wire/middleware.go` and are used across every These belong in `internal/wire/middleware.go` and are used across every feature hook.
feature hook.
```go ```go
// authz returns a per-route authorization middleware that checks one bit. // authz returns a per-route authorization middleware that checks one bit.
@@ -229,11 +340,10 @@ func skipPublicPaths(publicPaths []string, mw func(http.Handler) http.Handler) f
} }
} }
// skipMethodPath bypasses mw only when BOTH method and path match. Use this // skipMethodPath bypasses mw only when BOTH method and path match. Use this to
// to expose ONE method on an otherwise-authenticated path (e.g. GET // expose ONE method on an otherwise-authenticated path (e.g. GET /api/v1/config
// /api/v1/config public while PUT is not). Adding such a path to // public while PUT is not). Adding such a path to publicPaths would silently
// publicPaths would silently strip identity from context on the protected // strip identity from context on the protected methods, breaking authz().
// methods, breaking authz().
func skipMethodPath(method, pathPattern string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler { func skipMethodPath(method, pathPattern string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
inner := mw(next) inner := mw(next)
@@ -252,16 +362,14 @@ func skipMethodPath(method, pathPattern string, mw func(http.Handler) http.Handl
## Adapters at the wire boundary ## Adapters at the wire boundary
When a framework type does not match a service-layer port, write a small When a framework type does not match a service-layer port, write a small typed adapter in
typed adapter in `internal/wire/`. Always compile-time assert with `internal/wire/`. Always compile-time assert with `var _ TargetIface = (*adapter)(nil)`.
`var _ TargetIface = (*adapter)(nil)`.
The framework intentionally exposes only `Signer.Sign(claims) (string, error)` The framework intentionally exposes only `Signer.Sign(claims) (string, error)` — **the framework
— **the framework gives you a signing primitive; the access/refresh strategy, gives you a signing primitive; the access/refresh strategy, claim layout, and response shape are
claim layout, and response shape are application concerns.** A "helper" that application concerns.** A "helper" that returned a fixed `{access, refresh, type, expiresIn}` struct
returned a fixed `{access, refresh, type, expiresIn}` struct would silently would silently decide for every app whether refresh tokens exist, what fields to expose, and what
decide for every app whether refresh tokens exist, what fields to expose, casing to use. Those are wire-format choices the app owns.
and what casing to use. Those are wire-format choices the app owns.
```go ```go
import ( import (
@@ -315,24 +423,3 @@ func (a *tokenSignerAdapter) IssueTokenPair(subject string, custom map[string]an
}, nil }, nil
} }
``` ```
## Migrations and seeds
Migrations and seeds register as `BeforeStart` hooks too. They run after all
components have initialised but before any of them have started, so the
database is reachable and the server is not yet accepting traffic.
```go
func withMigrations(lc launcher.Launcher, logger logz.Logger, cfg config.Config) {
lc.BeforeStart(func() error {
if err := migrations.RunMigrations(context.Background(), logger, cfg); err != nil {
logger.Error("migrations: failed to apply", err)
return err
}
return nil
})
}
```
Seeds must be **idempotent**: count first, only mutate when needed, log the
skip when nothing was done.
+104
View File
@@ -0,0 +1,104 @@
package rules
import (
"go/ast"
"strings"
)
// Scaffolding conventions: keep main.go clean, load .env, and compose framework
// component configs instead of reading EINHERJAR_* env vars by hand. These catch
// the "cochinero in main.go" an AI produces when starting a project from zero.
func init() {
registered = append(registered,
Rule{
ID: "main.dirty",
Severity: SeverityError,
Module: "wire",
Check: checkMainDirty,
},
Rule{
ID: "main.no-godotenv-autoload",
Severity: SeverityWarning,
Module: "wire",
Check: checkMainGodotenv,
},
Rule{
ID: "config.raw-getenv",
Severity: SeverityWarning,
Module: "wire",
Check: checkConfigRawGetenv,
},
)
}
func isMainPackage(c *Context) bool {
return c.File != nil && c.File.Name != nil && c.File.Name.Name == "main"
}
// hasGodotenv reports whether the file loads a local .env, by either the blank
// autoload import (the standard) or the plain godotenv package.
func hasGodotenv(c *Context) bool {
return c.Importing("godotenv/autoload") || c.Importing("joho/godotenv")
}
// checkMainDirty flags a main.go that constructs the framework launcher itself.
// The launcher and every component belong in internal/wire; main.go must contain
// nothing but the godotenv autoload and wire.Run().
func checkMainDirty(c *Context) []Finding {
if !isMainPackage(c) {
return nil
}
if !c.Importing("einherjar/core/launcher") && !c.Called("launcher.New") {
return nil
}
return []Finding{{
Message: "main.go constructs the launcher/components directly — composition belongs in internal/wire, not main",
Hint: `Keep main.go to _ "github.com/joho/godotenv/autoload" + wire.Run(). Move launcher.New/Append and every component into internal/wire/wire.go Run().`,
}}
}
// checkMainGodotenv flags a wire-convention main.go that never loads .env, so
// local APP_*/EINHERJAR_* variables would be missing at config.Load().
func checkMainGodotenv(c *Context) []Finding {
if !isMainPackage(c) {
return nil
}
if !c.Called("wire.Run") && !c.Importing("internal/wire") {
return nil
}
if hasGodotenv(c) {
return nil
}
return []Finding{{
Message: "main.go calls wire.Run() but never loads .env — local config will be missing at boot",
Hint: `Add the blank import _ "github.com/joho/godotenv/autoload" (the documented standard); it never overrides real environment variables and a missing file is not an error.`,
}}
}
// checkConfigRawGetenv flags reading a framework EINHERJAR_* env var directly via
// os.Getenv, which bypasses the component-config composition. (EINHERJAR_LOG_* is
// left to logz.direct-env-read, which carries a logz-specific hint.)
func checkConfigRawGetenv(c *Context) []Finding {
var hits []Finding
ast.Inspect(c.File, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || exprName(call.Fun) != "os.Getenv" || len(call.Args) == 0 {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok {
return true
}
key := strings.Trim(lit.Value, `"`)
if !strings.HasPrefix(key, "EINHERJAR_") || strings.HasPrefix(key, "EINHERJAR_LOG_") {
return true
}
hits = append(hits, Finding{
Message: "reading " + key + " directly via os.Getenv bypasses the framework config composition",
Hint: "Compose the component's Config type (e.g. postgres.Config) into your Config struct and let caarlos0/env load its EINHERJAR_* tags through config.Load().",
Line: c.Fset.Position(call.Pos()).Line,
})
return true
})
return hits
}
+81
View File
@@ -0,0 +1,81 @@
package rules
import "testing"
func hasRule(fs []Finding, id string) bool {
for _, f := range fs {
if f.RuleID == id {
return true
}
}
return false
}
func TestMainDirty_flagsLauncherInMain(t *testing.T) {
src := `package main
import (
"code.nochebuena.dev/einherjar/core/launcher"
"code.nochebuena.dev/einherjar/core/logz"
)
func main() {
logger := logz.New(logz.Config{})
lc := launcher.New(logger)
_ = lc.Run()
}`
if !hasRule(Run(src), "main.dirty") {
t.Fatal("expected main.dirty when the launcher is constructed in main")
}
}
func TestCleanMain_isNotFlagged(t *testing.T) {
src := `package main
import (
"os"
_ "github.com/joho/godotenv/autoload"
"myapp/internal/wire"
)
func main() {
if err := wire.Run(); err != nil {
os.Exit(1)
}
}`
fs := Run(src)
if hasRule(fs, "main.dirty") {
t.Fatal("clean main must not trip main.dirty")
}
if hasRule(fs, "main.no-godotenv-autoload") {
t.Fatal("clean main has autoload; must not trip main.no-godotenv-autoload")
}
}
func TestMainNoGodotenv_flagsMissingAutoload(t *testing.T) {
src := `package main
import "myapp/internal/wire"
func main() {
_ = wire.Run()
}`
if !hasRule(Run(src), "main.no-godotenv-autoload") {
t.Fatal("expected main.no-godotenv-autoload when .env is never loaded")
}
}
func TestConfigRawGetenv_flagsEinherjarVar(t *testing.T) {
src := `package config
import "os"
func Load() string {
return os.Getenv("EINHERJAR_PG_HOST")
}`
if !hasRule(Run(src), "config.raw-getenv") {
t.Fatal("expected config.raw-getenv when an EINHERJAR_* var is read via os.Getenv")
}
}
+226
View File
@@ -0,0 +1,226 @@
package tools
import (
"context"
"strings"
"code.nochebuena.dev/einherjar/mcp/internal/index"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type getScaffoldInput struct {
Module string `json:"module,omitempty" jsonschema:"the go.mod module path of the new app, e.g. code.nochebuena.dev/org/myapp; used to fill import paths. Defaults to 'myapp'."`
Service string `json:"service,omitempty" jsonschema:"short service name for the cmd/ dir and the logz service tag, e.g. myapp. Defaults to the last path segment of module."`
}
type scaffoldFile struct {
Path string `json:"path"`
Content string `json:"content"`
}
type getScaffoldOutput struct {
Layout string `json:"layout"`
Files []scaffoldFile `json:"files"`
Notes []string `json:"notes"`
}
func registerGetScaffold(s *mcp.Server, idx *index.Index) {
mcp.AddTool(s, &mcp.Tool{
Name: "get_scaffold",
Description: "Return the canonical MINIMUM Einherjar application scaffold as ready-to-write files: " +
"a clean main.go (godotenv autoload + wire.Run), internal/wire/wire.go (the launcher assembly), " +
"internal/config/config.go (one Config composing the framework's component configs via caarlos0/env), " +
"a health feature hook, and .env.example. This is the one opinionated starting point — call it when " +
"creating a new Einherjar service so main.go and the launcher stay clean instead of being hand-rolled. " +
"Migrations and seeding are deliberately excluded — those are the developer's choice, not a framework convention.",
}, func(ctx context.Context, req *mcp.CallToolRequest, args getScaffoldInput) (*mcp.CallToolResult, getScaffoldOutput, error) {
module := strings.TrimSpace(args.Module)
if module == "" {
module = "myapp"
}
service := strings.TrimSpace(args.Service)
if service == "" {
service = module
if i := strings.LastIndex(module, "/"); i >= 0 {
service = module[i+1:]
}
}
repl := strings.NewReplacer("%%MODULE%%", module, "%%APP%%", service)
out := getScaffoldOutput{
Layout: scaffoldLayout,
Files: []scaffoldFile{
{Path: "cmd/" + service + "/main.go", Content: repl.Replace(tplMain)},
{Path: "internal/wire/wire.go", Content: repl.Replace(tplWire)},
{Path: "internal/wire/health.go", Content: repl.Replace(tplHealth)},
{Path: "internal/config/config.go", Content: repl.Replace(tplConfig)},
{Path: ".env.example", Content: repl.Replace(tplEnvExample)},
},
Notes: []string{
"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.",
"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.",
"Migrations and seeding are the developer's choice — not part of this scaffold.",
},
}
return jsonText(out), out, nil
})
}
const scaffoldLayout = `cmd/%%APP%%/main.go godotenv autoload + wire.Run()
internal/wire/wire.go Run() — config, infra, feature hooks
internal/wire/<feature>.go one file per feature (with<Feature> hook)
internal/wire/middleware.go authz / skip helpers (add when you add authz'd routes)
internal/config/config.go Config composing framework component configs
.env.example every env var the config reads, in sync
internal/<feature>/{dto,handler,repository,service}/`
const tplMain = `package main
import (
"fmt"
"os"
_ "github.com/joho/godotenv/autoload"
"%%MODULE%%/internal/wire"
)
func main() {
if err := wire.Run(); err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}
`
const tplWire = `package wire
import (
"strings"
"github.com/google/uuid"
"code.nochebuena.dev/einherjar/core/launcher"
"code.nochebuena.dev/einherjar/core/logz"
"code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/mw"
"code.nochebuena.dev/einherjar/web/server"
"%%MODULE%%/internal/config"
)
// Run loads configuration, builds infrastructure, registers feature hooks, and
// blocks until shutdown. Order is load-bearing: config, logger, infra, launcher
// with every component appended, then feature hooks, then lc.Run().
func Run() error {
cfg, err := config.Load()
if err != nil {
return err
}
logger := logz.New(logz.Config{
JSON: !strings.EqualFold(cfg.AppEnv, "local"),
StaticArgs: []any{"service", "%%APP%%", "env", cfg.AppEnv},
})
db := postgres.New(logger, cfg.PG)
srv := server.New(logger, cfg.Server,
server.WithMiddleware(
mw.RequestID(uuid.NewString),
mw.Recover(logger),
mw.CORS(cfg.CORSOrigins),
mw.RequestLogger(logger),
),
)
lc := launcher.New(logger)
lc.Append(db, srv)
withHealth(lc, srv, logger, db)
// … one withFeature(lc, srv, …) call per feature in your domain.
return lc.Run()
}
`
const tplHealth = `package wire
import (
"net/http"
"code.nochebuena.dev/einherjar/core/launcher"
"code.nochebuena.dev/einherjar/core/logz"
"code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/server"
)
// withHealth registers a liveness endpoint. Grow it into a readiness check
// (ping db and other dependencies) as the service gains infrastructure.
func withHealth(lc launcher.Launcher, srv server.Server, logger logz.Logger, db postgres.Component) {
lc.BeforeStart(func() error {
srv.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(` + "`" + `{"status":"ok"}` + "`" + `))
})
return nil
})
}
`
const tplConfig = `// Package config loads %%APP%%'s startup configuration from the environment.
//
// Einherjar component configs (server.Config, postgres.Config, …) are composed
// verbatim as nested fields so their EINHERJAR_* env tags load alongside the
// app-owned APP_* fields through a single caarlos0/env parse. Every env var
// declared here must also be documented in .env.example, kept in lock-step.
package config
import (
"github.com/caarlos0/env/v11"
"code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/server"
)
// Config is the fully-resolved startup configuration. caarlos0/env recurses into
// the nested framework configs, populating their EINHERJAR_SERVER_* / EINHERJAR_PG_*
// tags from the environment next to the app-owned APP_* fields.
type Config struct {
AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + `
CORSOrigins []string ` + "`" + `env:"APP_CORS_ORIGINS" envSeparator:","` + "`" + `
// Framework component configs — composed verbatim; their EINHERJAR_* tags
// load through this same env.Parse call.
Server server.Config // EINHERJAR_SERVER_*
PG postgres.Config // EINHERJAR_PG_*
}
func Load() (Config, error) {
var cfg Config
if err := env.Parse(&cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
`
const tplEnvExample = `# .env.example — copy to .env for local dev (godotenv autoloads it).
# Every variable the config package reads lives here, documented. Keep in sync.
# ── App (APP_*) ────────────────────────────────────────────────────────────
APP_ENV=local
APP_CORS_ORIGINS=*
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────
EINHERJAR_SERVER_ADDR=:8080
# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────
EINHERJAR_PG_HOST=localhost
EINHERJAR_PG_PORT=5432
EINHERJAR_PG_USER=postgres
EINHERJAR_PG_PASSWORD=postgres
EINHERJAR_PG_DATABASE=%%APP%%
`
+1
View File
@@ -19,6 +19,7 @@ func Register(s *mcp.Server, idx *index.Index) {
registerListADRs(s, idx) registerListADRs(s, idx)
registerGetADR(s, idx) registerGetADR(s, idx)
registerGetExample(s, idx) registerGetExample(s, idx)
registerGetScaffold(s, idx)
registerValidateSnippet(s, idx) registerValidateSnippet(s, idx)
registerGetCompliance(s, idx) registerGetCompliance(s, idx)
registerGetChangelog(s, idx) registerGetChangelog(s, idx)