Adds a new static analysis check to the ELPS linter. This is a prescriptive 3-file-touch workflow.
Scanned 9/12/2026
Install to Claude Code
npx -y skills add luthersystems/elps --skill add-linter-check --agent claude-codeInstalls 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 automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/luthersystems-add-linter-check)More formats (shields.io, HTML) on the badges page.
# /add-linter-check — New Lint Analyzer Skill
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:
```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 context-sensitive checks** (e.g., checking nesting), write a custom recursive walker:
```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 analyzer count.
**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. Verify
Run the verification pipeline:
```bash
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`/`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
- [ ] No false positives on existing codebase (`./elps lint ./...`)
- [ ] `make test` passes
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!