/add-linter-check — New Lint Analyzer Skill
SkillDev toolsAdds a new static analysis check to the ELPS linter. This is a prescriptive 3-file-touch workflow.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the /add-linter-check — New Lint Analyzer Skill skill
About this capability
An embedded lisp interpreter
What this skill tells your AI
The instructions your AI receives, as published by luthersystems/elps in .claude/skills/add-linter-check/SKILL.md and read by ahel’s review.
Adds a new static analysis check to the ELPS linter. This is a prescriptive 3-file-touch workflow.
Trigger
Use when asked to add a new lint check, analyzer, or static analysis rule.
Workflow
1. Design the Check
Before writing code, answer:
- What pattern does this check detect? (name it with kebab-case, e.g.,
unused-import) - Is it a simple per-expression check (use
WalkSExprs) or context-sensitive (use custom walker)? - What's the diagnostic message? Keep it concise and actionable.
- What are the false positive risks? Plan mitigations.
2. File 1: lint/analyzers.go — Define the Analyzer
Add a new exported variable following this pattern:
var AnalyzerMyCheck = &Analyzer{
Name: "my-check",
Doc: "Check that ... (one-line description used by --list)",
Run: func(pass *Pass) error {
WalkSExprs(pass.Exprs, func(sexpr *lisp.LVal, depth int) {
head := HeadSymbol(sexpr)
if head != "target-form" {
return
}
// Check logic here
argc := ArgCount(sexpr)
if argc < 2 {
src := SourceOf(sexpr)
pass.Report(Diagnostic{
Message: "descriptive error message",
Pos: posFromSource(src.Source),
})
}
})
return nil
},
}
Available helpers:
WalkSExprs(exprs, fn)— Walk all s-expressions, calls fn with each sexpr and depthWalk(exprs, fn)— Walk ALL nodes (not just s-expressions)HeadSymbol(sexpr)— Get the operator name (e.g.,"if","defun")ArgCount(sexpr)— Count arguments (excludes the operator)SourceOf(v)— Get source location, falling back to first child if neededposFromSource(loc)— Convert*token.LocationtoPositionpass.Report(diag)— Report a diagnosticpass.Reportf(source, format, args...)— Printf-style diagnostic
For context-sensitive checks (e.g., checking nesting), write a custom recursive walker:
Run: func(pass *Pass) error {
for _, expr := range pass.Exprs {
walkMyCheck(pass, expr, false)
}
return nil
},
// ...
func walkMyCheck(pass *Pass, v *lisp.LVal, insideTarget bool) {
// Custom traversal with context tracking
}
Do not add a ; nolint:analyzer-name note yourself — the CLI renderer
(cmd/diagnostic.go) appends a suppression hint to every diagnostic
automatically, and a hand-written note renders as a duplicate. Use Notes for
check-specific context (e.g. "first defined at ...").
3. File 2: lint/lint.go — Register in DefaultAnalyzers()
Add the new analyzer to the DefaultAnalyzers() return slice:
func DefaultAnalyzers() []*Analyzer {
return []*Analyzer{
// ... existing analyzers ...
AnalyzerMyCheck,
}
}
4. File 3: lint/lint_test.go — Write Tests
Write at minimum three test categories:
// Positive case: should trigger diagnostic
func TestMyCheck_Positive(t *testing.T) {
diags := lintCheck(t, AnalyzerMyCheck, `(bad-pattern arg)`)
assert.Len(t, diags, 1)
assertHasDiag(t, diags, "descriptive error")
assertDiagOnLine(t, diags, 1, "descriptive error")
}
// Negative case: should NOT trigger diagnostic
func TestMyCheck_Negative(t *testing.T) {
diags := lintCheck(t, AnalyzerMyCheck, `(good-pattern arg1 arg2)`)
assertNoDiags(t, diags)
}
// Nolint suppression: diagnostic should be suppressed
func TestMyCheck_Nolint(t *testing.T) {
diags := lintCheck(t, AnalyzerMyCheck,
`(bad-pattern arg) ; nolint:my-check`)
assertNoDiags(t, diags)
}
Update TestDefaultAnalyzers — increment the expected analyzer count.
Test helpers available:
lintCheck(t, analyzer, source) []Diagnostic— Run single analyzer on sourceassertHasDiag(t, diags, substr)— Assert at least one diagnostic contains substrassertNoDiags(t, diags)— Assert no diagnosticsassertDiagOnLine(t, diags, line, substr)— Assert diagnostic on specific line
5. Verify
Run the verification pipeline:
go test ./lint/... # Linter tests pass
make test # Full test suite passes
./elps lint ./... # Linter runs on codebase without false positives
False Positive Mitigations
Common patterns that cause false positives:
- User-defined functions shadowing builtins: Pre-scan for
defun/defmacrothat shadow the target name - Macro template bodies: Forms inside quasiquote templates aren't real calls
- Formals lists: Parameter lists look like function calls but aren't — use
aritySkipNodes()pattern - Threading macros:
thread-first/thread-lastchildren get an extra arg at expansion time
Checklist
- Analyzer defined in
lint/analyzers.gowith Name, Doc, Run - No hand-written
; nolint:note (the CLI appends the hint automatically) - Registered in
DefaultAnalyzers()inlint/lint.go - Positive, negative, and nolint tests in
lint/lint_test.go -
TestDefaultAnalyzerscount updated - No false positives on existing codebase (
./elps lint ./...) -
make testpasses
Signals
- GitHub stars
- 36
- Forks
- 9
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
add-linter-check- Source
- github.com/luthersystems/elps