// Package rules defines the lightweight, pattern-based conventions enforced // by the validate_snippet MCP tool. // // These are not a substitute for "go vet" or running the user's tests. They // catch wiring mistakes that an AI assistant frequently makes when first // adopting Einherjar: forgetting to call Run(), constructing a Launcher // without registering components, reading EINHERJAR_* env vars directly // instead of letting the framework load them, and so on. package rules import ( "fmt" "go/ast" "go/parser" "go/token" "strings" ) // Severity describes how seriously a finding should be treated. type Severity string const ( SeverityError Severity = "error" SeverityWarning Severity = "warning" SeverityInfo Severity = "info" ) // Finding is one rule hit against a snippet. type Finding struct { RuleID string `json:"ruleId"` Severity Severity `json:"severity"` Module string `json:"module,omitempty"` Message string `json:"message"` Hint string `json:"hint,omitempty"` Line int `json:"line,omitempty"` } // Rule is a single named convention check. type Rule struct { ID string Severity Severity Module string Check func(ctx *Context) []Finding } // Context is the parsed view of a snippet provided to every rule. type Context struct { Fset *token.FileSet File *ast.File Imports map[string]string // path → local name (e.g. "code.nochebuena.dev/einherjar/core/launcher" → "launcher") Calls []CallSite // every function call in the file } // CallSite is a recorded function call. Func is the textual form // ("launcher.New", "lc.Append", "lc.Run"). type CallSite struct { Func string Line int } // Run parses the source and applies every rule, returning all findings in // order. If the source cannot be parsed even after wrapping in a synthetic // package, parse errors are returned as findings with rule id "parse". func Run(src string) []Finding { fset := token.NewFileSet() file, err := parser.ParseFile(fset, "snippet.go", src, parser.AllErrors|parser.ParseComments) if err != nil { wrapped := "package _snippet\n\nfunc _main() {\n" + src + "\n}\n" fset = token.NewFileSet() file, err = parser.ParseFile(fset, "snippet.go", wrapped, parser.AllErrors|parser.ParseComments) if err != nil { return []Finding{{ RuleID: "parse", Severity: SeverityError, Message: "could not parse snippet: " + err.Error(), }} } } ctx := &Context{Fset: fset, File: file, Imports: map[string]string{}} for _, imp := range file.Imports { path := strings.Trim(imp.Path.Value, `"`) name := guessImportName(path) if imp.Name != nil && imp.Name.Name != "" && imp.Name.Name != "_" { name = imp.Name.Name } ctx.Imports[path] = name } ast.Inspect(file, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } name := exprName(call.Fun) if name == "" { return true } ctx.Calls = append(ctx.Calls, CallSite{Func: name, Line: fset.Position(call.Pos()).Line}) return true }) var findings []Finding for _, r := range registered { for _, f := range r.Check(ctx) { f.RuleID = r.ID if f.Severity == "" { f.Severity = r.Severity } if f.Module == "" { f.Module = r.Module } findings = append(findings, f) } } return findings } func guessImportName(path string) string { i := strings.LastIndex(path, "/") if i < 0 { return path } return path[i+1:] } func exprName(e ast.Expr) string { switch v := e.(type) { case *ast.Ident: return v.Name case *ast.SelectorExpr: return exprName(v.X) + "." + v.Sel.Name } return "" } // Importing reports whether the snippet imports a path with the given suffix // (e.g. "core/launcher" matches "code.nochebuena.dev/einherjar/core/launcher"). func (c *Context) Importing(suffix string) bool { for path := range c.Imports { if strings.HasSuffix(path, suffix) { return true } } return false } // Calls reports whether any call site has a textual form ending in suffix. // For example, suffix ".Run" matches "lc.Run" and "launcher.Run" alike. func (c *Context) Called(suffix string) bool { for _, cs := range c.Calls { if strings.HasSuffix(cs.Func, suffix) { return true } } return false } // registered is the static rule catalog. Add new conventions here. var registered = []Rule{ { ID: "launcher.missing-run", Severity: SeverityError, Module: "core", Check: func(c *Context) []Finding { if !c.Importing("einherjar/core/launcher") { return nil } if !c.Called("launcher.New") && !c.Called(".New") { return nil } if c.Called(".Run") { return nil } return []Finding{{ Message: "core/launcher constructed but Run() never called — application will never start", Hint: "After lc := launcher.New(logger); lc.Append(...); call if err := lc.Run(); err != nil { ... }", }} }, }, { ID: "launcher.no-components", Severity: SeverityWarning, Module: "core", Check: func(c *Context) []Finding { if !c.Importing("einherjar/core/launcher") { return nil } if c.Called("launcher.New") && !c.Called(".Append") { return []Finding{{ Message: "Launcher created but no components appended — Run() will start an empty application", Hint: "Use lc.Append(db, cache, server) before lc.Run() to register lifecycle components", }} } return nil }, }, { ID: "logz.direct-env-read", Severity: SeverityWarning, Module: "core", Check: func(c *Context) []Finding { if !c.Importing("einherjar/core/logz") { return nil } var hits []Finding ast.Inspect(c.File, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } if exprName(call.Fun) != "os.Getenv" || len(call.Args) == 0 { return true } lit, ok := call.Args[0].(*ast.BasicLit) if !ok { return true } if strings.HasPrefix(strings.Trim(lit.Value, `"`), "EINHERJAR_LOG_") { hits = append(hits, Finding{ Message: fmt.Sprintf("reading %s directly via os.Getenv bypasses logz configuration", strings.Trim(lit.Value, `"`)), Hint: "logz.New reads EINHERJAR_LOG_* automatically; pass logz.Config and let the framework load it", Line: c.Fset.Position(call.Pos()).Line, }) } return true }) return hits }, }, { ID: "launcher.run-error-discarded", Severity: SeverityWarning, Module: "core", Check: func(c *Context) []Finding { if !c.Importing("einherjar/core/launcher") || !c.Called(".Run") { return nil } discarded := false ast.Inspect(c.File, func(n ast.Node) bool { expr, ok := n.(*ast.ExprStmt) if !ok { return true } call, ok := expr.X.(*ast.CallExpr) if !ok { return true } if strings.HasSuffix(exprName(call.Fun), ".Run") { discarded = true } return true }) if !discarded { return nil } return []Finding{{ Message: "Launcher.Run() return value discarded — startup failures will go unnoticed", Hint: "Capture the error: if err := lc.Run(); err != nil { logger.Error(...); os.Exit(1) }", }} }, }, { ID: "web.server-not-appended", Severity: SeverityWarning, Module: "web", Check: func(c *Context) []Finding { if !c.Importing("einherjar/web/server") || !c.Importing("einherjar/core/launcher") { return nil } if !c.Called(".Append") { return nil } // Heuristic: warn if server.New is constructed but not appended via .Append. // We can't statically prove the argument was the server, so this is informational. if c.Called("server.New") { return []Finding{{ Severity: SeverityInfo, Message: "web/server is constructed — ensure it is passed to launcher.Append() so its lifecycle is managed", Hint: "lc.Append(srv) lets the launcher start and gracefully stop the HTTP server", }} } return nil }, }, }