Adds a new static analysis check to the ELPS linter (`lint/`, run by `elps lint`, the LSP, and CI). This is a prescriptive workflow touching three Go files plus `docs/lint-checks.md`. For Go-side invariants over elps's own source, use `/elpsvet` instead.
Installs into .claude/skills of the current project.
Are you the author of Add Linter Check?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/luthersystems-add-linter-check)
# /add-linter-check — New Lint Analyzer Skill
Adds a new static analysis check to the ELPS linter (`lint/`, run by `elps lint`, the LSP, and CI). This is a prescriptive workflow touching three Go files plus `docs/lint-checks.md`.
For Go-side invariants over elps's own source, use `/elpsvet` instead.
## Trigger
Use when asked to add a new lint check or analyzer for ELPS source, when a new builtin/op/macro has structural requirements (see `/implement`), or when a behavior change needs a static migration diagnostic.
## 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:
```go
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 depth
- `Walk(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 needed
- `posFromSource(loc)` — Convert `*token.Location` to `Position`
- `pass.Report(diag)` — Report a diagnostic
- `pass.Reportf(source, format, args...)` — Printf-style diagnostic
**For checks that need scope/symbol resolution**, set `Semantic: true` on the Analyzer and read `pass.Semantics` (nil-check it and return early; tests use `lintCheckSemantic`).
**For context-sensitive checks** (e.g., checking nesting), write a custom recursive walker with depth/context tracking — `walkRethrowContext` in `lint/analyzers.go` is the reference example:
```go
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:
```go
func DefaultAnalyzers() []*Analyzer {
return []*Analyzer{
// ... existing analyzers ...
AnalyzerMyCheck,
}
}
```
### 4. File 3: `lint/lint_test.go` — Write Tests
Write at minimum three test categories:
```go
// 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 count in `assert.Len` and add the name to the expected name list.
**Test helpers available:**
- `lintCheck(t, analyzer, source) []Diagnostic` — Run single analyzer on source
- `assertHasDiag(t, diags, substr)` — Assert at least one diagnostic contains substr
- `assertNoDiags(t, diags)` — Assert no diagnostics
- `assertDiagOnLine(t, diags, line, substr)` — Assert diagnostic on specific line
### 5. Document it: `docs/lint-checks.md`
Add a `### \`my-check\`` section under "Checks" with what it flags, a bad and
a good example, and how to suppress it. The LSP links every diagnostic to
`docs/lint-checks.md#<check-name>` (`lsp/diagnostics.go`), so a missing section
is a dead link in editors.
### 6. Verify
```bash
go test ./lint/... # Linter tests pass
go build -o elps . && ./elps lint --workspace=. --exclude 'grammar' --include '_examples' ./... # as CI runs it
```
Then the full `/verify` pipeline. A new check — or a fix that stops an
existing analyzer skipping nodes — often surfaces true positives in the repo's
own `.lisp` files (e.g. `lisp/lisplib/libjson/*_test.lisp`). Fix those in the
same PR; do not suppress them.
## False Positive Mitigations
Common patterns that cause false positives:
- **User-defined functions shadowing builtins**: Pre-scan for `defun`/`defmacro` that 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-last` children get an extra arg at expansion time
## Checklist
- [ ] Analyzer defined in `lint/analyzers.go` with Name, Doc, Run
- [ ] No hand-written `; nolint:` note (the CLI appends the hint automatically)
- [ ] Registered in `DefaultAnalyzers()` in `lint/lint.go`
- [ ] Positive, negative, and nolint tests in `lint/lint_test.go`
- [ ] `TestDefaultAnalyzers` count updated
- [ ] Section added to `docs/lint-checks.md`
- [ ] No false positives on existing codebase (CI's `elps lint` command above)
- [ ] `make test` passes