Documentation
¶
Overview ¶
Package lint provides static analysis for ELPS lisp source files.
The linter is modeled after go vet: each check is an independent Analyzer that receives a parsed AST and reports diagnostics. The framework handles parsing, running analyzers, collecting results, and formatting output.
Analyzers are composable and extensible — embedders can define custom checks alongside the built-in set.
Index ¶
- Variables
- func AnalyzerDoc() string
- func AnalyzerNames() []string
- func ArgCount(sexpr *lisp.LVal) int
- func BuildAnalysisConfig(cfg *LintConfig) (*analysis.Config, error)
- func CollectFormals(formals *lisp.LVal, defs map[string]bool)
- func FormatJSON(w io.Writer, diags []Diagnostic) error
- func FormatText(w io.Writer, diags []Diagnostic)
- func HeadSymbol(sexpr *lisp.LVal) string
- func ShouldFail(diags []Diagnostic, threshold Severity) bool
- func SourceOf(v *lisp.LVal) *lisp.LVal
- func UserDefined(exprs []*lisp.LVal) map[string]bool
- func Walk(exprs []*lisp.LVal, fn func(node *lisp.LVal, parent *lisp.LVal, depth int))
- func WalkSExprs(exprs []*lisp.LVal, fn func(sexpr *lisp.LVal, depth int))
- type Analyzer
- type Diagnostic
- type LintConfig
- type Linter
- func (l *Linter) LintFile(source []byte, filename string) ([]Diagnostic, error)
- func (l *Linter) LintFileWithAnalysis(source []byte, filename string, cfg *analysis.Config) ([]Diagnostic, error)
- func (l *Linter) LintFileWithContext(source []byte, filename string, semantics *analysis.Result) ([]Diagnostic, error)
- func (l *Linter) LintFiles(cfg *LintConfig, files []string) ([]Diagnostic, error)
- type Pass
- type Position
- type RelatedInformation
- type Severity
Constants ¶
This section is empty.
Variables ¶
var AnalyzerBuiltinArity = &Analyzer{ Name: "builtin-arity", Severity: SeverityError, Doc: "Check argument counts for calls to known builtin functions and special forms.\n\nELPS builtin functions have well-defined argument signatures. This check catches calls with too few or too many arguments before runtime. User-defined functions that shadow builtin names are automatically excluded, including names bound by let/let*/flet/labels/macrolet. Binding lists, formals lists and threading macro children are also excluded.", Run: func(pass *Pass) error { userDefs := UserDefined(pass.Exprs) skipNodes := aritySkipNodes(pass.Exprs) WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if skipNodes[sexpr] { return } head := HeadSymbol(sexpr) if head == "" { return } if userDefs[head] { return } spec, ok := builtinArityTable[head] if !ok { return } argc := ArgCount(sexpr) helpNote := fmt.Sprintf("see (help '%s) or `elps doc %s` for usage", head, head) headNode := sexpr.Cells[0] if argc < spec.min { pass.Report(Diagnostic{ Message: fmt.Sprintf("%s requires at least %d argument(s), got %d", head, spec.min, argc), Pos: posFromSource(astutil.SourceLoc(headNode)), EndPos: endPosFromNode(headNode), Notes: []string{helpNote}, }) } if spec.max >= 0 && argc > spec.max { pass.Report(Diagnostic{ Message: fmt.Sprintf("%s accepts at most %d argument(s), got %d", head, spec.max, argc), Pos: posFromSource(astutil.SourceLoc(headNode)), EndPos: endPosFromNode(headNode), Notes: []string{helpNote}, }) } }) return nil }, }
AnalyzerBuiltinArity checks for wrong argument counts to known builtin functions.
var AnalyzerComparatorMutation = &Analyzer{ Name: "comparator-mutation", Severity: SeverityError, Doc: "Report a mutating call inside a stable-sort or insert-sorted predicate.\n\n" + "A comparator runs an unspecified number of times in an unspecified order, " + "so writes to shared state or input elements are unsafe. This conservative " + "check also reports writes to callback-local scratch values. Every mutating " + "builtin is reported whatever it writes to: " + "assoc!, dissoc!, append!, append-bytes!, set! and stable-sort itself, which " + "sorts in place despite carrying no `!`.\n\n" + "Two spellings of the predicate are followed: an inline lambda, and a plain " + "symbol naming a defun in the same file. The symbol hop is ONE level deep -- " + "the named defun's own body is scanned, but a call it makes to a third " + "function is not followed, so a comparator that mutates two hops away is not " + "reported.\n\n" + "Data is skipped whole, in all three spellings: a reader-quoted form, an " + "explicit (quote ...) form, and a quasiquote template -- except for the " + "(unquote ...) and (unquote-splicing ...) subtrees inside a template, which " + "are evaluated where they stand and so are still checked. That applies to the " + "sort form itself as much as to its predicate's body, and a defun written " + "inside data defines nothing, so a symbol naming one resolves to no callback.", Run: func(pass *Pass) error { run := newMutationRun(pass) walkEvaluatedSExprs(pass.Exprs, func(sexpr *lisp.LVal) { form := mutationHead(sexpr) idx, ok := comparatorPredicateArg[form] if !ok || idx >= len(sexpr.Cells) { return } cb := run.resolveCallback(sexpr.Cells[idx]) if cb == nil { return } run.query(mutationQuery{form: form}, cb.sites) }) run.reportQueries() return nil }, }
AnalyzerComparatorMutation reports a call to a mutating builtin inside a sort comparator.
A comparator is called an unspecified number of times and in an unspecified order. This conservative policy rejects every mutating builtin, including writes to callback-local scratch values; it does not prove impurity.
var AnalyzerCondMissingElse = &Analyzer{ Name: "cond-missing-else", Severity: SeverityInfo, Doc: "Warn when a cond expression has no default clause.\n\nWithout an else or (true ...) clause, cond returns nil when no condition matches. This is a common source of unexpected nil values. Add (else ...) or (true ...) as the last clause to handle the default case.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) != "cond" { return } if ArgCount(sexpr) == 0 { return } last := sexpr.Cells[len(sexpr.Cells)-1] if last.Type != lisp.LSExpr || len(last.Cells) == 0 { return } head := last.Cells[0] if head.Type == lisp.LSymbol && isCondDefault(head.Str) { return } src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: "cond has no default (else) clause", Pos: posFromSource(astutil.SourceLoc(src)), EndPos: endPosFromNode(src), Notes: []string{"add (else ...) or (true ...) as the last clause to handle unmatched cases"}, }) }) return nil }, }
AnalyzerCondMissingElse warns when a cond has no default (else or true) clause.
var AnalyzerCondStructure = &Analyzer{ Name: "cond-structure", Severity: SeverityError, Doc: "Check for malformed `cond` clauses.\n\nEach `cond` clause must be a non-empty list. The `else` clause, if present, must be last. Common mistakes include bare values instead of lists, or misplaced `else`.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) != "cond" { return } src := SourceOf(sexpr) last := len(sexpr.Cells) - 1 for i := 1; i < len(sexpr.Cells); i++ { clause := sexpr.Cells[i] clauseSrc := SourceOf(clause) if loc := astutil.SourceLoc(clauseSrc); loc == nil || loc.Line == 0 { clauseSrc = src } if clause.Type != lisp.LSExpr { pass.Report(Diagnostic{ Message: fmt.Sprintf("cond clause %d is not a list", i), Pos: posFromSource(astutil.SourceLoc(clauseSrc)), EndPos: endPosFromNode(clauseSrc), Notes: []string{"cond clauses must be lists: (cond ((test1) body1) ((test2) body2) (else default))"}, }) continue } if len(clause.Cells) == 0 { pass.Reportf(astutil.SourceLoc(clauseSrc), "cond clause %d is empty", i) continue } if clause.Cells[0].Type == lisp.LSymbol && isCondDefault(clause.Cells[0].Str) { if i != last { pass.Reportf(astutil.SourceLoc(clauseSrc), "cond else clause must be last (is clause %d of %d)", i, last) } } } }) return nil }, }
AnalyzerCondStructure checks for malformed `cond` clauses.
var AnalyzerDefunStructure = &Analyzer{ Name: "defun-structure", Severity: SeverityError, Doc: "Check for malformed `defun`/`defmacro` definitions.\n\nA `defun` requires a symbol name and a formals list. An empty body (no-op) is valid. Common mistakes include non-symbol names or a non-list formals argument.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { head := HeadSymbol(sexpr) if head != "defun" && head != "defmacro" { return } headNode := sexpr.Cells[0] argc := ArgCount(sexpr) if argc < 2 { pass.Report(Diagnostic{ Message: fmt.Sprintf("%s requires at least a name and formals list (got %d argument(s))", head, argc), Pos: posFromSource(astutil.SourceLoc(headNode)), EndPos: endPosFromNode(headNode), }) return } name := sexpr.Cells[1] if name.Type != lisp.LSymbol { pass.Report(Diagnostic{ Message: fmt.Sprintf("%s name must be a symbol, got %s", head, name.Type), Pos: posFromSource(astutil.SourceLoc(headNode)), EndPos: endPosFromNode(headNode), }) } formals := sexpr.Cells[2] if formals.Type != lisp.LSExpr { pass.Report(Diagnostic{ Message: fmt.Sprintf("%s formals must be a list, got %s", head, formals.Type), Pos: posFromSource(astutil.SourceLoc(headNode)), EndPos: endPosFromNode(headNode), }) } }) return nil }, }
AnalyzerDefunStructure checks for malformed `defun` and `defmacro` forms.
var AnalyzerDeprecated = &Analyzer{ Name: "deprecated", Severity: SeverityWarning, Semantic: true, Doc: "Report uses of symbols marked deprecated by their docstring.\n\nRequires semantic analysis (--workspace flag). A symbol is deprecated when a paragraph of its docstring begins with \"Deprecated:\", the same convention Go doc comments use; the rest of that paragraph is reported as the notice. Definitions are never flagged, only uses, and a use inside the body of a definition that is itself deprecated is not reported — deprecated code may call deprecated code.", Run: func(pass *Pass) error { if pass.Semantics == nil { return nil } // Source spans of the definitions that are themselves deprecated. Go's // rule is that deprecated code may use deprecated code, so references // from inside those bodies are exempt. Built lazily: almost every file // has no deprecated reference at all, and the LSP runs this on each // keystroke. var exempt byteSpans exemptBuilt := false passFile := analysis.NormalizePath(pass.Filename) reported := make(map[int]bool) for _, ref := range pass.Semantics.References { if ref == nil || ref.Symbol == nil || ref.Source == nil { continue } notice, ok := lisp.DeprecationNotice(ref.Symbol.DocString) if !ok { continue } if analysis.NormalizePath(ref.Source.File) != passFile { continue } if !exemptBuilt { exempt = deprecatedBodySpans(pass.Exprs) exemptBuilt = true } if exempt.contains(ref.Source.Pos) { continue } if ref.Source.Pos >= 0 { if reported[ref.Source.Pos] { continue } reported[ref.Source.Pos] = true } name := ref.Symbol.Name if ref.Node != nil && ref.Node.Type == lisp.LSymbol && ref.Node.Str != "" { name = ref.Node.Str } msg := fmt.Sprintf("use of deprecated %s '%s'", deprecatedKind(ref.Symbol.Kind), name) if notice != "" { msg += ": " + notice } decl := ref.Symbol.Source if decl != nil && decl.Pos < 0 { decl = nil } // No hand-written nolint note: the CLI appends the suppression // hint to every diagnostic (cmd/diagnostic.go), and no other // analyzer duplicates it. var notes []string if decl != nil { notes = []string{"deprecated at " + sourceString(decl)} } pass.Report(Diagnostic{ Message: msg, Pos: posFromSource(ref.Source), EndPos: endPosFromNode(ref.Node), Notes: notes, Related: relatedFromSource(decl, "deprecated declaration here"), Deprecated: true, }) } return nil }, }
AnalyzerDeprecated reports uses of symbols whose docstring marks them deprecated, following the convention Go doc comments use: a docstring paragraph beginning with "Deprecated:" (or "DEPRECATED:") deprecates the symbol and the rest of the paragraph says what to use instead. lisp.DeprecationNotice is the canonical detector.
Every symbol kind semantic analysis records a docstring for is covered: same-file defun/defmacro, workspace-scanned definitions, the compiled-in builtins, special operators and macros, and the builtins an embedder registers through a lisp.PackageRegistry (LintConfig.Registry).
Requires semantic analysis (pass.Semantics != nil).
var AnalyzerDuplicateDefinition = &Analyzer{ Name: "duplicate-definition", Severity: SeverityWarning, Semantic: true, Doc: "Warn when a symbol is defined more than once at the top level.\n\nRequires semantic analysis (--workspace flag). Detects same-file duplicates (two defun with the same name) and cross-file duplicates (a local defun that shadows an imported definition). Only checks defun and defmacro — repeated set is handled by set-usage.", Run: func(pass *Pass) error { if pass.Semantics == nil { return nil } isDefKind := func(k analysis.SymbolKind) bool { return k == analysis.SymFunction || k == analysis.SymMacro } // Collect local (non-external) root-scope definitions by (name, package). type defKey struct { name string pkg string } groups := make(map[defKey][]*analysis.Symbol) for _, sym := range pass.Semantics.Symbols { if sym.Scope != pass.Semantics.RootScope { continue } if sym.External { continue } if !isDefKind(sym.Kind) { continue } if sym.Source == nil { continue } key := defKey{name: sym.Name, pkg: sym.Package} groups[key] = append(groups[key], sym) } // Build an index of ExtraGlobals for cross-file duplicate checking. // This avoids relying on scope lookups which may have been // overwritten by local definitions during prescan. type extKey struct { name string pkg string } extIndex := make(map[extKey]*analysis.ExternalSymbol) for i := range pass.Semantics.ExtraGlobals { ext := &pass.Semantics.ExtraGlobals[i] if !isDefKind(ext.Kind) || ext.Source == nil { continue } pkg := ext.Package if pkg == "" { pkg = "user" } ek := extKey{name: ext.Name, pkg: pkg} if _, exists := extIndex[ek]; !exists { extIndex[ek] = ext } } cleanFilename := analysis.NormalizePath(pass.Filename) for key, syms := range groups { first := syms[0] for _, sym := range syms[1:] { pass.Report(Diagnostic{ Message: fmt.Sprintf("duplicate definition: %s '%s' already defined", sym.Kind, sym.Name), Pos: posFromSource(sym.Source), EndPos: endPosFromNode(sym.Node), Notes: []string{"first defined at " + sourceString(first.Source)}, Related: relatedFromSource(first.Source, "first defined here"), }) } localPkg := key.pkg if localPkg == "" { localPkg = "user" } if ext, ok := extIndex[extKey{name: key.name, pkg: localPkg}]; ok { if cleanFilename != "" && analysis.NormalizePath(ext.Source.File) == cleanFilename { continue } pass.Report(Diagnostic{ Message: fmt.Sprintf("duplicate definition: %s '%s' is also defined externally", first.Kind, first.Name), Pos: posFromSource(first.Source), EndPos: endPosFromNode(first.Node), Notes: []string{"also defined at " + sourceString(ext.Source)}, Related: relatedFromSource(ext.Source, "also defined here"), }) } } return nil }, }
AnalyzerDuplicateDefinition warns when the same symbol is defined more than once at the top level (e.g. two defun with the same name). Only flags defun/defmacro duplicates — repeated set is already covered by set-usage. Cross-file duplicates are detected when an External symbol matches a local definition. Requires semantic analysis.
var AnalyzerIfArity = &Analyzer{ Name: "if-arity", Severity: SeverityError, Doc: "Check that `if` has exactly 3 arguments: condition, then-branch, else-branch.\n\nA missing else branch is a common source of subtle nil-return bugs. Extra arguments are silently ignored at parse time but indicate a structural error.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) != "if" { return } argc := ArgCount(sexpr) if argc == 3 { return } head := sexpr.Cells[0] if argc < 3 { pass.Report(Diagnostic{ Message: fmt.Sprintf("if requires 3 arguments (condition, then, else), got too few (%d)", argc), Pos: posFromSource(astutil.SourceLoc(head)), EndPos: endPosFromNode(head), Notes: []string{"use cond for multi-branch conditionals, or provide an else branch"}, }) } else { pass.Report(Diagnostic{ Message: fmt.Sprintf("if requires 3 arguments (condition, then, else), got too many (%d)", argc), Pos: posFromSource(astutil.SourceLoc(head)), EndPos: endPosFromNode(head), Notes: []string{"if takes exactly (condition then-expr else-expr); use progn to group multiple expressions"}, }) } }) return nil }, }
AnalyzerIfArity checks that `if` has exactly 3 arguments (condition, then, else).
var AnalyzerInPackageToplevel = &Analyzer{ Name: "in-package-toplevel", Severity: SeverityWarning, Doc: "Warn when `in-package` is used inside nested expressions.\n\n`in-package` only has meaningful effect at the top level of a file. Using it inside a `defun`, `let`, `lambda`, or other nested form is almost certainly a mistake.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) == "in-package" && depth > 0 { src := SourceOf(sexpr) pass.Reportf(astutil.SourceLoc(src), "in-package should only be used at the top level") } }) return nil }, }
AnalyzerInPackageToplevel warns when `in-package` is used inside nested expressions (function bodies, let forms, etc.) where it has no useful effect.
var AnalyzerIterationMutation = &Analyzer{ Name: "iteration-mutation", Severity: SeverityWarning, Doc: "Report a callback that mutates the collection a higher-order builtin is iterating.\n\n" + "Covers map, foldl, foldr, select, reject, all? and any?. A mutating call " + "(assoc!, dissoc!, append!, append-bytes! or stable-sort) is reported when " + "the value it writes through is the collection argument itself -- which " + "must be a plain symbol for the check to see it -- or one of the callback's " + "own parameters, which holds an element of that collection.\n\n" + "set! is NOT on that list, unlike in comparator-mutation: it rebinds a name " + "rather than writing through the value the name held, so a callback's " + "(set! x 1) leaves the element alone and a (set! xs ...) leaves the sequence " + "the builtin is already walking alone.\n\n" + "A fold's accumulator is neither: not the collection, and not an element, " + "even though the callback receives it as a parameter -- so the usual " + "(foldl (lambda (acc x) (assoc! acc k v)) (sorted-map) xs) idiom is clean, and " + "so is a mutation of any unrelated binding. Both an inline lambda and a plain " + "symbol naming a same-file defun are followed, one hop deep.\n\n" + "Known blind spots: the check is syntactic and keeps no scope of its own, so a " + "callback parameter that an inner let rebinds is still treated as the element; " + "a collection passed as an expression rather than a symbol is invisible; and " + "zip is not covered because it takes no callback at all.\n\n" + "Data is skipped whole, in all three spellings: a reader-quoted form, an " + "explicit (quote ...) form, and a quasiquote template -- except for the " + "(unquote ...) and (unquote-splicing ...) subtrees inside a template, which " + "are evaluated where they stand and so are still checked.", Run: func(pass *Pass) error { run := newMutationRun(pass) walkEvaluatedSExprs(pass.Exprs, func(sexpr *lisp.LVal) { form := mutationHead(sexpr) spec, ok := iterationForms[form] if !ok || spec.callback >= len(sexpr.Cells) { return } cb := run.resolveCallback(sexpr.Cells[spec.callback]) if cb == nil { return } collections := make(map[string]bool) for _, idx := range spec.collections { if idx < len(sexpr.Cells) && sexpr.Cells[idx].Type == lisp.LSymbol { name := sexpr.Cells[idx].Str collections[name] = true run.query(mutationQuery{form: form, target: name}, cb.sites) } } run.iterationScope(form, cb, collections) }) run.elementQueries() run.reportQueries() return nil }, }
AnalyzerIterationMutation reports a callback that mutates the very collection it is iterating, or an element that collection handed it.
var AnalyzerLetBindings = &Analyzer{ Name: "let-bindings", Severity: SeverityError, Doc: "Check for malformed `let`/`let*` binding lists.\n\nThe first argument to `let` or `let*` must be a list of (symbol value) pairs. Common mistakes include forgetting the outer list: `(let (x 1) ...)` instead of `(let ((x 1)) ...)`.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { head := HeadSymbol(sexpr) if head != "let" && head != "let*" { return } headNode := sexpr.Cells[0] if ArgCount(sexpr) < 1 { pass.Report(Diagnostic{ Message: head + " requires a binding list and body", Pos: posFromSource(astutil.SourceLoc(headNode)), EndPos: endPosFromNode(headNode), }) return } bindings := sexpr.Cells[1] src := SourceOf(sexpr) if bindings.Type != lisp.LSExpr { pass.Reportf(astutil.SourceLoc(src), "%s bindings must be a list, got %s", head, bindings.Type) return } for i, binding := range bindings.Cells { if binding.Type != lisp.LSExpr { pass.Report(Diagnostic{ Message: fmt.Sprintf("%s binding %d is not a list (did you forget the outer parentheses?)", head, i+1), Pos: posFromSource(bindingSource(binding, src)), EndPos: endPosFromNode(binding), Notes: []string{"correct form: (let ((x 1) (y 2)) body...)"}, }) continue } if len(binding.Cells) == 0 { pass.Reportf(bindingSource(binding, src), "%s binding %d is empty", head, i+1) continue } if binding.Cells[0].Type != lisp.LSymbol && HeadSymbol(binding.Cells[0]) != "unquote" { pass.Reportf(bindingSource(binding, src), "%s binding %d: first element must be a symbol, got %s", head, i+1, binding.Cells[0].Type) continue } if len(binding.Cells) != 2 { pass.Reportf(bindingSource(binding, src), "%s binding %d (%s): expected 2 elements (symbol value), got %d", head, i+1, binding.Cells[0].Str, len(binding.Cells)) } } }) return nil }, }
AnalyzerLetBindings checks for malformed `let` and `let*` binding lists.
var AnalyzerQuoteCall = &Analyzer{ Name: "quote-call", Severity: SeverityWarning, Doc: "Warn when set is called with an unquoted symbol argument.\n\nThe first argument to set should be a quoted symbol: (set 'x 42). Writing (set x 42) evaluates x first, which is rarely intended. This check does not flag set!, which takes an unquoted symbol by design, nor defconst, which quotes its own name argument.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { head := HeadSymbol(sexpr) if head != "set" { return } if ArgCount(sexpr) < 1 { return } arg := sexpr.Cells[1] if arg.Type == lisp.LSymbol && !arg.IsQuoted() { src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: fmt.Sprintf("%s first argument should be quoted: (set '%s ...) not (set %s ...)", head, arg.Str, arg.Str), Pos: posFromSource(astutil.SourceLoc(src)), EndPos: endPosFromNode(src), Notes: []string{fmt.Sprintf("did you mean (%s '%s ...)?", head, arg.Str)}, }) } }) return nil }, }
AnalyzerQuoteCall warns when set is called with an unquoted symbol as the first argument, which is almost always a mistake.
NOTE: defconst is deliberately NOT checked. It is a macro that quotes its own name argument — (defconst x 42) expands to (set 'x 42) — so the correct spelling is the unquoted one. Flagging it reported every correct use, and the suggested "fix" of (defconst 'x 42) makes the program fail at runtime with "lisp:set: first argument is not a symbol: quote".
var AnalyzerRethrowContext = &Analyzer{ Name: "rethrow-context", Severity: SeverityError, Doc: "Warn when `rethrow` is used outside a `handler-bind` form.\n\n`rethrow` re-raises the current error being handled by handler-bind, preserving the original stack trace. Calling it outside any handler-bind always produces an error at runtime.", Run: func(pass *Pass) error { walkRethrowContext(pass.Exprs, 0, func(sexpr *lisp.LVal) { src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: "rethrow used outside handler-bind", Pos: posFromSource(astutil.SourceLoc(src)), EndPos: endPosFromNode(src), Notes: []string{"rethrow can only be called from within a handler-bind handler"}, }) }) return nil }, }
AnalyzerRethrowContext warns when `rethrow` is used outside of a `handler-bind` form. At runtime, rethrow can only be called from within a handler-bind handler; calling it elsewhere always produces an error.
var AnalyzerSetUsage = &Analyzer{ Name: "set-usage", Severity: SeverityWarning, Doc: "Warn when `set` is used to reassign an already-bound symbol.\n\nThe first `set` creating a new binding is fine — ELPS has no `defvar`, so `set` is the standard way to create top-level bindings. However, subsequent `set` calls on the same symbol should use `set!` to clearly signal mutation intent.", Run: func(pass *Pass) error { seen := make(map[string]bool) WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) == "in-package" && depth == 0 { seen = make(map[string]bool) return } if HeadSymbol(sexpr) != "set" { return } if ArgCount(sexpr) < 1 { return } arg := sexpr.Cells[1] name := "" if arg.Type == lisp.LSymbol { name = arg.Str } else if arg.Type == lisp.LSExpr && arg.IsQuoted() && len(arg.Cells) > 0 && arg.Cells[0].Type == lisp.LSymbol { name = arg.Cells[0].Str } if name == "" { return } if seen[name] { src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: fmt.Sprintf("use set! instead of set to mutate '%s (already bound)", name), Pos: posFromSource(astutil.SourceLoc(src)), EndPos: endPosFromNode(src), Notes: []string{"set creates a new binding; set! mutates an existing one"}, }) } seen[name] = true }) return nil }, }
AnalyzerSetUsage warns when `set` is used to reassign a symbol that was already bound by a prior `set` in the same file. The first `set` creating a binding is fine (ELPS has no `defvar`), but subsequent mutations of the same symbol should use `set!` to signal intent.
var AnalyzerShadowing = &Analyzer{ Name: "shadowing", Severity: SeverityInfo, Semantic: true, Doc: "Report when a local binding shadows a name from an enclosing scope.\n\n" + "Requires semantic analysis (--workspace flag). Severity follows what is being " + "hidden: shadowing a builtin, special operator or macro is a WARNING, because a " + "later call to that name silently means something else; shadowing another local " + "is INFO.\n\n" + "A binding whose initialiser references the name it shadows is NOT reported \u2014 " + "(let* ([ctx (default ctx (sorted-map))]) refines one value rather than " + "introducing a second meaning, and ELPS offers no other way to default an " + "&optional argument (elps#559).", Run: func(pass *Pass) error { if pass.Semantics == nil { return nil } for _, sym := range pass.Semantics.Symbols { if sym.Scope == nil || sym.Scope == pass.Semantics.RootScope { continue } if sym.Scope.Parent == nil { continue } outer := sym.Scope.Parent.Lookup(sym.Name) if outer == nil { continue } if outer.External { continue } if sym.Kind == analysis.SymParameter && (outer.Kind == analysis.SymSpecialOp || outer.Kind == analysis.SymBuiltin) { continue } if !hidesCallable(outer.Kind) && refinesShadowed(sym) { continue } severity := SeverityInfo note := fmt.Sprintf("rename '%s' to avoid confusion with the outer %s", sym.Name, outer.Kind) if hidesCallable(outer.Kind) { severity = SeverityWarning note = fmt.Sprintf("rename '%s': while this binding is in scope, a call to %s "+ "resolves to it rather than to the %s", sym.Name, sym.Name, outer.Kind) } pass.Report(Diagnostic{ Message: fmt.Sprintf("%s '%s' shadows %s from enclosing scope", sym.Kind, sym.Name, outer.Kind), Severity: severity, Pos: posFromSource(sym.Source), EndPos: endPosFromNode(sym.Node), Notes: []string{note}, }) } return nil }, }
AnalyzerShadowing reports when a local binding shadows a name from an enclosing scope. This is informational — shadowing is valid but can cause confusion. Requires semantic analysis (pass.Semantics != nil).
var AnalyzerUndefinedSymbol = &Analyzer{ Name: "undefined-symbol", Severity: SeverityError, Semantic: true, Doc: "Report symbols that cannot be resolved in any enclosing scope.\n\nRequires semantic analysis (--workspace flag). Keywords and qualified symbols are excluded. Builtins, special operators, and macros are pre-populated.", Run: func(pass *Pass) error { if pass.Semantics == nil { return nil } for _, u := range pass.Semantics.Unresolved { sev := SeverityError notes := []string{fmt.Sprintf("'%s' is not defined in any enclosing scope; did you mean a different name?", u.Name)} if u.InsideMacroCall { sev = SeverityWarning notes = append(notes, "inside a macro call — the macro may introduce this binding at expansion time") } pass.Report(Diagnostic{ Severity: sev, Message: "undefined symbol: " + u.Name, Pos: posFromSource(u.Source), EndPos: endPosFromNode(u.Node), Notes: notes, }) } return nil }, }
AnalyzerUndefinedSymbol reports symbols that could not be resolved in any enclosing scope. Requires semantic analysis (pass.Semantics != nil).
var AnalyzerUnnecessaryProgn = &Analyzer{ Name: "unnecessary-progn", Severity: SeverityInfo, Doc: "Warn when `progn` wraps the body of a form that already supports multiple expressions.\n\nForms like `defun`, `defmacro`, `lambda`, `let`, and others evaluate their body as an implicit progn. Wrapping the body in an explicit `(progn ...)` is redundant. This does not flag `progn` inside `if` branches, where it is needed.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { head := HeadSymbol(sexpr) bodyStart, ok := implicitPrognForms[head] if !ok { return } bodyExprs := len(sexpr.Cells) - bodyStart if bodyExprs != 1 { return } body := sexpr.Cells[bodyStart] if HeadSymbol(body) != "progn" { return } src := SourceOf(body) var msg string if head == "progn" { msg = "nested progn is redundant" } else { msg = fmt.Sprintf("progn is unnecessary in %s body (it supports multiple expressions)", head) } pass.Report(Diagnostic{ Message: msg, Pos: posFromSource(astutil.SourceLoc(src)), EndPos: endPosFromNode(src), Notes: []string{fmt.Sprintf("remove the progn and move its contents directly into the %s body", head)}, }) }) WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) != "cond" { return } for i := 1; i < len(sexpr.Cells); i++ { clause := sexpr.Cells[i] if clause.Type != lisp.LSExpr || len(clause.Cells) < 2 { continue } if len(clause.Cells) == 2 && HeadSymbol(clause.Cells[1]) == "progn" { src := SourceOf(clause.Cells[1]) pass.Report(Diagnostic{ Message: "progn is unnecessary in cond clause body (it supports multiple expressions)", Pos: posFromSource(astutil.SourceLoc(src)), EndPos: endPosFromNode(src), Notes: []string{"remove the progn and move its contents directly into the cond clause"}, }) } } }) return nil }, }
AnalyzerUnnecessaryProgn warns when progn is used as the sole body expression in a form that already supports multiple body expressions.
var AnalyzerUnusedFunction = &Analyzer{ Name: "unused-function", Severity: SeverityWarning, Semantic: true, Doc: "Warn about top-level functions and macros that are defined but never referenced.\n\nRequires semantic analysis (--workspace flag). Exported symbols and functions with underscore prefix are excluded.", Run: func(pass *Pass) error { if pass.Semantics == nil { return nil } for _, sym := range pass.Semantics.Symbols { if sym.References > 0 { continue } if sym.Kind != analysis.SymFunction && sym.Kind != analysis.SymMacro { continue } if sym.Scope != pass.Semantics.RootScope { continue } if sym.Exported { continue } if len(sym.Name) > 0 && sym.Name[0] == '_' { continue } if pass.Semantics.WorkspaceRefs != nil { key := analysis.SymbolToKey(sym).String() if refs, ok := pass.Semantics.WorkspaceRefs[key]; ok { hasExternal := false for _, ref := range refs { if ref.File != pass.Filename { hasExternal = true break } } if hasExternal { continue } } } pass.Report(Diagnostic{ Message: fmt.Sprintf("unused %s: %s", sym.Kind, sym.Name), Pos: posFromSource(sym.Source), EndPos: endPosFromNode(sym.Node), Notes: []string{"if this is a public API, add it to an (export ...) form"}, Unnecessary: true, }) } return nil }, }
AnalyzerUnusedFunction warns about functions and macros that are defined at the top level but never referenced. Requires semantic analysis.
var AnalyzerUnusedVariable = &Analyzer{ Name: "unused-variable", Severity: SeverityWarning, Semantic: true, Doc: "Warn about variables and parameters that are defined but never referenced.\n\nRequires semantic analysis (--workspace flag). Skips variables with underscore prefix (conventional \"ignored\" marker) and top-level (global scope) variables.", Run: func(pass *Pass) error { if pass.Semantics == nil { return nil } for _, sym := range pass.Semantics.Symbols { if sym.References > 0 { continue } if sym.Kind != analysis.SymVariable && sym.Kind != analysis.SymParameter { continue } if sym.Scope == pass.Semantics.RootScope { continue } if len(sym.Name) > 0 && sym.Name[0] == '_' { continue } pass.Report(Diagnostic{ Message: fmt.Sprintf("unused %s: %s", sym.Kind, sym.Name), Pos: posFromSource(sym.Source), EndPos: endPosFromNode(sym.Node), Notes: []string{fmt.Sprintf("if '%s' is intentionally unused, prefix it with '_'", sym.Name)}, Unnecessary: true, }) } return nil }, }
AnalyzerUnusedVariable warns about variables and parameters that are defined but never referenced. Requires semantic analysis (pass.Semantics != nil).
var AnalyzerUserArity = &Analyzer{ Name: "user-arity", Severity: SeverityError, Semantic: true, Doc: "Check argument counts for calls to user-defined functions and macros.\n\nRequires semantic analysis (--workspace flag). Only checks calls to functions with known signatures (Source != nil). Complements builtin-arity which covers builtins.", Run: func(pass *Pass) error { if pass.Semantics == nil { return nil } skipNodes := aritySkipNodes(pass.Exprs) locallyShadowed := make(map[string]bool) for _, sym := range pass.Semantics.Symbols { if sym.Scope == nil || sym.Scope == pass.Semantics.RootScope { continue } rootSym := pass.Semantics.RootScope.LookupLocal(sym.Name) if rootSym != nil && rootSym.Signature != nil && (rootSym.Kind == analysis.SymFunction || rootSym.Kind == analysis.SymMacro) { locallyShadowed[sym.Name] = true } } WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if skipNodes[sexpr] { return } head := HeadSymbol(sexpr) if head == "" { return } sym := pass.Semantics.RootScope.Lookup(head) if sym == nil || sym.Signature == nil || sym.Source == nil { return } if sym.External { return } if sym.Kind != analysis.SymFunction && sym.Kind != analysis.SymMacro { return } if locallyShadowed[head] { return } argc := ArgCount(sexpr) minArity := sym.Signature.MinArity() maxArity := sym.Signature.MaxArity() if argc < minArity { src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: fmt.Sprintf("%s requires at least %d argument(s), got %d", head, minArity, argc), Pos: posFromSource(astutil.SourceLoc(src)), EndPos: endPosFromNode(src), Notes: []string{"defined at " + sourceString(sym.Source)}, Related: relatedFromSource(sym.Source, "defined here"), }) } if maxArity >= 0 && argc > maxArity { src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: fmt.Sprintf("%s accepts at most %d argument(s), got %d", head, maxArity, argc), Pos: posFromSource(astutil.SourceLoc(src)), EndPos: endPosFromNode(src), Notes: []string{"defined at " + sourceString(sym.Source)}, Related: relatedFromSource(sym.Source, "defined here"), }) } }) return nil }, }
AnalyzerUserArity checks argument counts for calls to user-defined functions and macros whose signatures are known from the same file. Requires semantic analysis (pass.Semantics != nil).
var AnalyzerWithCleanupForms = &Analyzer{ Name: "with-cleanup-forms", Severity: SeverityWarning, Doc: "Warn about a with-cleanup spec list that is empty or holds a bare symbol.\n\nAn empty list guarantees nothing, so the form is indistinguishable from its body alone. A bare symbol is the missing-paren mistake -- `(with-cleanup (release h) ...)` runs neither `release` nor `h`, so the cleanup silently never happens.", Run: func(pass *Pass) error { WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) { if HeadSymbol(sexpr) != "with-cleanup" { return } if ArgCount(sexpr) < 1 { return } spec := sexpr.Cells[1] if spec.Type != lisp.LSExpr { return } if len(spec.Cells) == 0 { src := SourceOf(sexpr) pass.Report(Diagnostic{ Message: "with-cleanup has no cleanup forms, so it guarantees nothing", Pos: posFromSource(astutil.SourceLoc(src)), EndPos: endPosFromNode(src), Notes: []string{ "cleanup forms go in the first argument: (with-cleanup ((release h)) body...)", }, }) return } for _, form := range spec.Cells { if form.Type != lisp.LSymbol { continue } src := SourceOf(form) pass.Report(Diagnostic{ Message: fmt.Sprintf("cleanup form %q is a bare symbol and does nothing"+ " (missing parentheses?)", form.Str), Pos: posFromSource(astutil.SourceLoc(src)), EndPos: endPosFromNode(src), Notes: []string{ "the spec is a LIST of forms: (with-cleanup ((release h)) body...)", "written as (with-cleanup (release h) ...) the cleanup never runs", }, }) } }) return nil }, }
AnalyzerWithCleanupForms warns about two degenerate spellings of the with-cleanup spec list, both of which run without complaint.
An EMPTY list makes the form a no-op wrapper around its body: it still runs and still returns the same value, so nothing at runtime distinguishes it from the body alone.
A BARE SYMBOL in the list is the missing-paren mistake that let-bindings catches for let, and it is the more dangerous of the two:
(with-cleanup (release h) (work))
parses as a spec list of two forms -- the symbol `release` and the symbol `h` -- neither of which does anything. The release never happens, and the program behaves exactly as if the cleanup had been written correctly right up until the body signals. A real cleanup form is a call; a bare symbol as one is always either this mistake or dead code.
Functions ¶
func AnalyzerDoc ¶
func AnalyzerDoc() string
AnalyzerDoc returns a formatted documentation string for all analyzers.
func AnalyzerNames ¶
func AnalyzerNames() []string
AnalyzerNames returns a sorted list of all default analyzer names.
func ArgCount ¶
ArgCount returns the number of arguments in an s-expression (excluding the head). A nil sexpr yields 0.
func BuildAnalysisConfig ¶ added in v1.17.0
func BuildAnalysisConfig(cfg *LintConfig) (*analysis.Config, error)
BuildAnalysisConfig constructs an analysis.Config from a LintConfig. It scans the workspace, extracts stdlib exports, and merges embedder registry symbols. This is exported for callers (like the CLI stdin path) that need to build the config separately from file linting.
func CollectFormals ¶ added in v1.49.0
CollectFormals extracts symbol names from a formals list into defs, skipping the &rest, &optional and &key markers.
func FormatJSON ¶
func FormatJSON(w io.Writer, diags []Diagnostic) error
FormatJSON writes diagnostics as JSON.
func FormatText ¶
func FormatText(w io.Writer, diags []Diagnostic)
FormatText writes diagnostics in go vet text format.
func HeadSymbol ¶
HeadSymbol returns the symbol name at the head of an s-expression, or "". A nil sexpr yields "".
func ShouldFail ¶ added in v1.19.0
func ShouldFail(diags []Diagnostic, threshold Severity) bool
ShouldFail returns true if the diagnostics contain at least one finding at or above the given severity threshold.
func SourceOf ¶
SourceOf returns the best source location for a node. Prefers the node's own source, falls back to first child's source. Returns nil for a nil node.
func UserDefined ¶
UserDefined returns the set of names defined or bound in the source that shadow builtins. This includes:
- Function/macro names from defun/defmacro
- Parameter names from defun/defmacro/lambda formals lists
The result is file-global (not scope-aware), which is conservative: it may suppress a valid finding but will never produce a false positive.
Types ¶
type Analyzer ¶
type Analyzer struct {
// Name is a short identifier for this check (e.g. "set-usage").
Name string
// Doc is a human-readable description. The first line is a short summary.
Doc string
// Severity is the default severity for diagnostics from this analyzer.
Severity Severity
// Semantic indicates that this analyzer requires semantic analysis
// (pass.Semantics != nil) to produce diagnostics. When true and
// semantic analysis is not available, nolint directives targeting
// this analyzer are treated as conditionally valid rather than unused.
Semantic bool
// Run executes the check. It should call pass.Report() for each finding.
Run func(pass *Pass) error
}
Analyzer defines a single lint check.
func DefaultAnalyzers ¶
func DefaultAnalyzers() []*Analyzer
DefaultAnalyzers returns the built-in set of lint checks.
type Diagnostic ¶
type Diagnostic struct {
// Pos is the source location of the problem.
Pos Position `json:"pos"`
// EndPos is the end of the diagnostic range. When zero, editors
// treat the diagnostic as zero-width (a single point). When set,
// the range [Pos, EndPos) is highlighted.
EndPos Position `json:"end_pos,omitempty"`
// Message is a human-readable description of the problem.
Message string `json:"message"`
// Analyzer is the name of the check that found this problem.
Analyzer string `json:"analyzer"`
// Severity is the severity level of the diagnostic.
Severity Severity `json:"severity"`
// Notes are optional hint text lines for the user.
Notes []string `json:"notes,omitempty"`
// Related points to additional source locations that help explain the
// diagnostic, such as the original definition that conflicts with the
// current one.
Related []RelatedInformation `json:"related,omitempty"`
// Unnecessary marks the diagnostic as "unnecessary" code (e.g., unused
// variables). Editors may render these with faded text.
Unnecessary bool `json:"unnecessary,omitempty"`
// Deprecated marks the diagnostic as a use of deprecated code. Editors
// may render the flagged range with strikethrough text.
Deprecated bool `json:"deprecated,omitempty"`
}
Diagnostic is a single reported problem.
func (Diagnostic) String ¶
func (d Diagnostic) String() string
String returns the diagnostic in go vet style: file:line: message (analyzer) with optional note lines appended.
type LintConfig ¶ added in v1.17.0
type LintConfig struct {
// Workspace is the root directory for cross-file scanning.
// Empty string disables workspace scanning.
Workspace string
// Registry provides Go-registered symbols (builtins, special ops, macros)
// from an embedder's environment. These are merged with stdlib and
// workspace symbols for semantic analysis. When nil, only stdlib symbols
// are used.
Registry *lisp.PackageRegistry
// Excludes are glob patterns for files to skip during workspace scanning.
Excludes []string
// IncludeDirs are directory names that override ShouldSkipDir during
// workspace scanning. A directory matching any entry will be walked
// even if it would normally be skipped (e.g. "_examples").
IncludeDirs []string
// StdlibExports provides pre-extracted stdlib package exports. When nil,
// LintFiles uses the default stdlib (loaded via lisplib.LoadLibrary).
// Embedders that already have a configured env can pass
// analysis.ExtractPackageExports(env.Runtime.Registry) here to avoid
// the overhead of creating a temporary environment.
//
// The map and its slices are copied before use, so a map shared across
// several runs is neither mutated nor read after the call returns.
StdlibExports map[string][]analysis.ExternalSymbol
// MacroExpander optionally expands user-macro calls at analysis time.
// Embedders that boot a full environment can pass
// &analysis.EnvMacroExpander{Env: env} to enable accurate symbol
// resolution inside macro bodies.
//
// For most embedders, setting Env is simpler — it automatically creates
// the expander and loads workspace macros. Use MacroExpander only when
// you need a custom implementation.
MacroExpander analysis.MacroExpander
// Env is an optional runtime environment for macro expansion. When set
// alongside a Workspace, workspace-defined macros are loaded into the
// env and a MacroExpander is created automatically. This is the
// recommended way for embedders to enable macro expansion — just pass
// the env and elps handles the rest.
//
// If MacroExpander is also set, it takes precedence over Env.
Env *lisp.LEnv
}
LintConfig configures the linter for a single run.
type Linter ¶
type Linter struct {
Analyzers []*Analyzer
}
Linter runs a set of analyzers over source files.
func (*Linter) LintFile ¶
func (l *Linter) LintFile(source []byte, filename string) ([]Diagnostic, error)
LintFile analyzes a single source file and returns all diagnostics. Semantic analyzers are no-ops because no analysis.Result is provided.
func (*Linter) LintFileWithAnalysis ¶ added in v1.17.0
func (l *Linter) LintFileWithAnalysis(source []byte, filename string, cfg *analysis.Config) ([]Diagnostic, error)
LintFileWithAnalysis parses, analyzes, and lints a source file in one call. This is a convenience that runs semantic analysis and passes the result to all analyzers.
cfg is not modified. The filename is stamped on a shallow copy (analysis.ConfigForFile), so one config may be reused across files and shared between goroutines. Issue #444: this used to assign cfg.Filename in place, which left the caller's config naming whichever file ran last and raced two goroutines linting different files through one config. A nil cfg is analysed as a zero config.
func (*Linter) LintFileWithContext ¶ added in v1.17.0
func (l *Linter) LintFileWithContext(source []byte, filename string, semantics *analysis.Result) ([]Diagnostic, error)
LintFileWithContext analyzes a source file with optional semantic analysis results. When semantics is nil, semantic analyzers (undefined-symbol, unused-variable, etc.) are no-ops. When non-nil, they use the scope and reference data for deeper checks.
func (*Linter) LintFiles ¶ added in v1.17.0
func (l *Linter) LintFiles(cfg *LintConfig, files []string) ([]Diagnostic, error)
LintFiles analyzes source files with full workspace + embedder context. It handles workspace scanning, stdlib/registry symbol extraction, and semantic analysis configuration. The files slice contains resolved file paths (no glob expansion — callers handle that).
When cfg is nil, all files are linted with syntactic checks only.
type Pass ¶
type Pass struct {
// Analyzer is the currently running check.
Analyzer *Analyzer
// Filename is the source file being analyzed.
Filename string
// Exprs are the top-level parsed expressions.
Exprs []*lisp.LVal
// Semantics holds the result of semantic analysis, if available.
// Nil when semantic analysis has not been run (e.g. no --workspace flag).
// Semantic analyzers should check for nil and return early.
Semantics *analysis.Result
// contains filtered or unexported fields
}
Pass provides context to a running analyzer.
func (*Pass) ReportNode ¶ added in v1.26.0
ReportNode records a diagnostic spanning a node's source range. It extracts both start and end positions from the node. If the node lacks end position information, it falls back to using the symbol name length as a heuristic.
func (*Pass) ReportWithNotes ¶ added in v1.16.12
func (p *Pass) ReportWithNotes(d Diagnostic, notes ...string)
ReportWithNotes records a diagnostic with additional hint text.
type Position ¶
type Position struct {
File string `json:"file"`
Line int `json:"line"`
Col int `json:"col,omitempty"`
}
Position identifies a location in source code.
type RelatedInformation ¶ added in v1.47.1
type RelatedInformation struct {
Location Position `json:"location"`
Message string `json:"message"`
}
RelatedInformation describes an additional source location associated with a diagnostic.
type Severity ¶ added in v1.17.0
type Severity int
Severity indicates the severity level of a lint diagnostic.
const ( SeverityError Severity SeverityWarning SeverityInfo )
func MaxSeverity ¶ added in v1.19.0
func MaxSeverity(diags []Diagnostic) Severity
MaxSeverity returns the most severe level found in the given diagnostics. Returns SeverityInfo if diags is empty (i.e., least severe / no problems). Severity ordering: SeverityError > SeverityWarning > SeverityInfo.
func ParseSeverity ¶ added in v1.19.0
ParseSeverity converts a string to a Severity value. Valid inputs: "error", "warning", "info".
func (Severity) MarshalJSON ¶ added in v1.17.0
MarshalJSON serializes the severity as a JSON string. An unset severity (zero value) is marshaled as "warning".
This delegates to String rather than repeating the unset default, so the two cannot drift apart again the way they did in #461.
func (Severity) String ¶ added in v1.17.0
String renders the severity in the same vocabulary MarshalJSON emits and ParseSeverity/UnmarshalJSON accept: "error", "warning" or "info".
The unset zero value renders as "warning", the documented default (#461). It used to render as "unknown" — a word MarshalJSON never emitted and UnmarshalJSON and ParseSeverity both refuse — so the same diagnostic was a "warning" down the JSON path and an "unknown" down every path that called String (mcpserver builds its wire struct from String), and "unknown" could not be filtered for or read back under either name.
A value outside the enum still renders as "unknown": that is a corrupt Severity rather than an unset one, and there is no default it could be taken to mean.
func (*Severity) UnmarshalJSON ¶ added in v1.17.0
UnmarshalJSON deserializes a severity from a JSON string.