Skip to content
Back to skills

Add Linter Check

ASecurity

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.

  • 36 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 12, 2026
developmentgobashnodeexpresstesting

Works with

  • cli

Security analysis

A100/100

Scanned September 29, 2026

npx -y skills add luthersystems/elps --skill add-linter-check --agent claude-code

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.

Security grade badge for Add Linter Check
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/luthersystems-add-linter-check/badge)](https://www.skillsdirectory.com/skills/luthersystems-add-linter-check)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
# /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

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.