Skip to content

feat(lint): add 8 Clojure and Scheme lint rules - #88

Merged
takeokunn merged 2 commits into
mainfrom
feat/lint-batch-r7
Aug 3, 2026
Merged

feat(lint): add 8 Clojure and Scheme lint rules#88
takeokunn merged 2 commits into
mainfrom
feat/lint-batch-r7

Conversation

@takeokunn

Copy link
Copy Markdown
Collaborator

RULE_COUNT 295 → 303. Two new packages, eight standalone inspect commands. All eight are scoped away from Common Lisp, taking the dialect-specific report list 6 → 14.

package rules
lint-clojure-idiom (CLOJURE_ONLY, report-only) with-open-returns-lazy-seq, def-inside-function-body, single-key-nested-path, apply-with-literal-collection
lint-scheme-idiom (all fixable) scheme-begin-single-form, scheme-let-star-independent-bindings, scheme-memq-assq-literal-key (Scheme only), scheme-named-let-never-recurs

Both packages were audited against real third-party code, and both lost a rule to it

Corpora: 1714 .clj/.cljc from 12 cloned projects (clojure/clojure, clj-kondo, leiningen, reitit, malli, ring, core.async, next-jdbc, clj-http, timbre, tools.cli, compojure) and 295 files / 3.0 MB of Guile 3.0.11's stdlib. Candidate counts are reported per rule so a clean sweep can't be a false-clean — e.g. with-open-returns-lazy-seq swept 162 real with-open scopes for its zero.

scheme-eq-on-number-or-char-literal dropped — 462 candidates, 15 findings, zero true positives. All 15 were characters or small fixnums, inside the range Racket normatively guarantees eq? for. After removing the guaranteed cases nothing syntactically detectable remained. Replaced by scheme-memq-assq-literal-key, which R7RS §6.4 settles outright ((memq 101 '(100 101 102)) is unspecified where memv is defined) and which found a real latent portability bug in Guile's own punify.scm:65.

count-compared-to-zero dropped on stronger evidence — its recommended repair throws. count accepts anything Counted; seq/empty? need seqability, and core.async's FixedBuffer/DroppingBuffer/SlidingBuffer implement Counted and nothing else, so 12 findings recommended code that does not run. clojure.core:6433 then refutes the premise outright: empty? is defined as (if (counted? coll) (zero? (count coll)) (not (seq coll))) — the rule was flagging the body of the function it recommended. Narrowed to the sound subset, 0 of 55 findings survived over 11091 candidates.

Two further Clojure defects invisible to the package's own tests, both fixed and both checked for over-suppression: a case test-constant list ((case tag (def defonce ...) ...)) read as code, and (comment ...) bodies read as code. Two Scheme false positives likewise fixed — a named let inside a syntax-rules template, and (begin ,form) at quasiquote depth 0. Re-audit: 10 findings, 10 true positives, 0 false positives.

def-inside-function-body has an independent oracle: clj-kondo's :inline-def fixture expects rows 5, 8, 10, 12, 14, 16, 20; we report 5, 8, 20 — a strict subset, so zero false positives and four documented false negatives.

A silent-corruption bug, proven with the real applier

scheme-named-let-never-recurs computed a fix_span covering a comment in the (let <gap> loop ...) gap. Verified by building a probe driving the real dispatcher and apply_byte_span_edits — the same applier workflow.rs:1186 calls — writing real files. With the guard mutated out, all three comment forms lose bytes on disk and the reparse write guard passes every time: the corrupted-Lisp-still-reparses failure mode this repo has been bitten by before. tests/fix_apply.rs pins all three, including the #; datum-comment case that was missing.

Two things the wiring exposed that the rules alone would not have

  • fixable_rules_match_the_fix_engine drove only Common Lisp and Emacs Lisp fixtures, so the four rules declared Fixable emitted no fix at all — a --fix that silently does nothing. Added a Scheme fixture rather than relaxing the assertion; fix plan now reports fix_count: 4.
  • a_dialect_specific_report_is_supported_for_exactly_one_dialect had hardened an accident into a rule. Every scoped rule until now happened to name exactly one dialect; three of these name Scheme and Racket. Renamed to ..._for_a_proper_subset_of_dialects asserting (1..DIALECTS.len()) — the lower bound still catches an under-supported report, the upper bound a general one. Deriving the expectation from lint_rule_dialect_scope would have been circular, since support_status reads that same declaration.

Cost

Mutation testing: Scheme 37/37 killed, zero survivors; Clojure 41 mutations, 36 killed, 5 survivors all documented cost guards. It found a real gap — deleting defmethod from def-inside-function-body's Heads left the entire suite green, because the domain tests walk the tree themselves and never touch the head index. The rule would have silently stopped seeing every defmethod. Now pinned for all four rules.

Doubling ratios are flat (Scheme ~1.07–1.12×, Clojure per-invocation 0.80–1.24×) — nothing superlinear.

Verification

cargo build --workspace, cargo test --workspace, cargo test --test cli (3083 passed), cargo fmt --all --check, cargo clippy --all-targets --all-features -- -D warnings — all exit 0.

All 8 fire: one fixture each through the real binary, via both inspect lint --preset all and the standalone inspect <rule> command, one finding apiece. The .rkt column is the load-bearing one — it confirms the three two-dialect scopes actually reach Racket and that memq-assq is correctly silent there. Zero new findings sweeping tests/fixtures/**. All 12 goldens are insertions only.

Known gap, not introduced here

#:ns{...} namespaced map literals fail to parse, which silently drops whole files from any Clojure lint run (clj-kondo/corpus/nested_namespaced_maps.clj). A paredit-core-syntax reader gap, untouched by this PR.

RULE_COUNT 295 -> 303. Two new packages, eight standalone commands.
All eight are scoped away from Common Lisp, which takes the
dialect-specific report list from 6 to 14.

lint-clojure-idiom (4, CLOJURE_ONLY, all ReportOnly):
with-open-returns-lazy-seq, def-inside-function-body,
single-key-nested-path, apply-with-literal-collection.

lint-scheme-idiom (4, all Fixable): scheme-begin-single-form,
scheme-let-star-independent-bindings, scheme-memq-assq-literal-key
(Scheme only), scheme-named-let-never-recurs.

Both packages were audited against real third-party code rather than
their own fixtures, and both lost a rule to it.

`scheme-eq-on-number-or-char-literal` was dropped: over Guile's stdlib
it produced 15 findings from 462 candidates and **zero** were true
positives. All 15 were characters or small fixnums, inside the range
Racket normatively guarantees `eq?` for. Narrowing left nothing
syntactically detectable. It was replaced by
`scheme-memq-assq-literal-key`, which R7RS 6.4 settles outright --
`(memq 101 '(100 101 102))` is unspecified where `memv` is defined --
and which found a real latent bug in Guile's own punify.scm:65.

`count-compared-to-zero` was dropped on stronger evidence still: its
recommended repair *throws*. `count` accepts anything Counted, while
`seq`/`empty?` need seqability, and core.async's buffer deftypes
implement Counted and nothing else -- 12 findings recommended code that
does not run. clojure.core:6433 then refutes the premise outright, since
`empty?` is *defined* as `(if (counted? coll) (zero? (count coll)) ...)`.
The rule was flagging the body of the function it recommended.

Two further Clojure defects the packages' own tests could not see: a
`case` test-constant list read as code, and `(comment ...)` bodies read
as code. Both fixed, both checked for over-suppression.

The Scheme package's `named-let-never-recurs` had a fix span that
covered a comment in the gap. Verified by building a probe that drives
the real dispatcher and the real applier: with the guard mutated out,
`(let #|why|# loop ...)` loses its comment on disk and the reparse write
guard passes every time -- the corrupted-Lisp-still-reparses failure
mode. `tests/fix_apply.rs` pins all three comment forms.

Wiring turned up two things the rules alone would not have:

- `fixable_rules_match_the_fix_engine` drove only Common Lisp and Emacs
  Lisp fixtures, so four rules declared Fixable emitted no fix at all --
  a `--fix` that silently does nothing. Added a Scheme fixture rather
  than relaxing the assertion; `fix plan` now reports fix_count 4.
- `a_dialect_specific_report_is_supported_for_exactly_one_dialect`
  had hardened an accident into a rule. Every scoped rule until now
  named exactly one dialect; three of these name Scheme and Racket. It
  is now `..._for_a_proper_subset_of_dialects`, asserting
  `(1..DIALECTS.len())` -- the lower bound still catches an
  under-supported report, the upper bound a general one.
treefmt runs `paredit edit format` over every Lisp file in the repo, so
a newly added .scm/.rkt fixture must already be in that shape. Tests
still pass: the fixtures' assertions are on findings and candidate
counts, not byte offsets.
@takeokunn
takeokunn merged commit 6604035 into main Aug 3, 2026
10 checks passed
@takeokunn
takeokunn deleted the feat/lint-batch-r7 branch August 3, 2026 07:41
takeokunn added a commit that referenced this pull request Aug 3, 2026
RULE_COUNT 320 -> 321. This completes dialect coverage in the strict
sense: every one of the ten dialects now has a rule written for it
specifically. (Carp was not previously absent from the catalogue --
`self-recursive-tail-call` and the macro-hygiene rules already list it
-- but nothing targeted it.)

Only one rule, and the reason is the interesting part: **the Carp
compiler already rejects almost everything worth linting.** Ownership,
`@`/`&` misuse, move-after-use and dangling references are each walked
through in `docs/Memory.md`, and every one ends "the memory management
system detects this and reports an error". `fmt` specifier/argument
mismatch raises `macro-error` at expansion (`core/Format.carp:8-22`).
Named holes `?x` generate a type error. A rule for any of them would
duplicate the compiler.

`carp-deprecated-thread-macro` survives precisely because it is the one
that **builds silently**. `core/ControlMacros.carp:27,31` declares
`=>` and `==>` deprecated, but `deprecated` (`core/Macros.carp:174`)
expands to `meta-set!` and nothing else, and that key is read in exactly
two places -- `src/Primitives.hs:355` for the REPL's `(info ...)` and
`src/RenderDocs.hs:197-219` for HTML docs. No compilation path touches
it. Carp's own `core/Binary.carp:68,77` still uses `==>`, which is the
proof that nothing warns.

Fixable, because `=>` and `->` are byte-identical macro bodies in Carp's
stdlib -- the repair is a rename. The fix is withheld (finding still
reported) when the file defines its own `->`/`-->`.

Four candidate rules were dropped on zero or all-deliberate corpus
occurrences rather than shipped and labelled unproven.

**The batch's larger result is four Carp reader defects**, recorded in
the package README:

- `@` and `&` are not reader prefixes, though `docs/LanguageGuide.md`
  defines them under a literal "Reader Macros" heading. `@(f x)` splits
  into a bare `@` atom plus a sibling, **inflating the enclosing call's
  arity** -- 1493 such atoms across 116 of 248 files (47%). Byte spans
  survive, so round-trips are lossless; what breaks is structure, which
  makes any argument-counting analysis unsound for Carp.
- `@"..."` silently splits string literals, because `@` glues to the
  next token and swallows the opening quote. 46 split atoms across 10
  files that otherwise parse cleanly.
- Character literals are unrecognized, so `\{ \} \[ \] \( \) \"` are
  read as real delimiters.
- `#"..."` Pattern literals are unrecognized.

Six of 248 files fail to parse outright, four of them in `core/`, each
attributed by repairing one defect at a time. Same class as the Hy, LFE
and Janet gaps; a repair belongs in `core/syntax`.

Wiring hit the same trap PR #88 did: `fixable_rules_match_the_fix_engine`
drives one fixture per dialect, so a `Fixable` rule with no fixture is a
`--fix` that silently does nothing. Added a Carp fixture rather than
relaxing the assertion; `fix plan` reports fix_count 1.
takeokunn added a commit that referenced this pull request Aug 3, 2026
RULE_COUNT 334 -> 345. Four standalone commands (the Clojure rules);
Racket and Emacs Lisp are registry-only.

lint-racket-depth (5): racket-match-unreachable-clause (Error),
racket-for-comprehension-value-discarded, racket-begin0-single-form
(fixable), racket-case-lambda-single-clause (fixable),
racket-parameterize-empty-bindings.

lint-elisp-depth (2): elisp-process-filter-assumes-whole-output,
elisp-repeating-timer-handle-discarded.

lint-clojure-depth (4, all Error): go-block-blocking-channel-op,
parking-op-outside-go-machinery, contains-on-non-associative,
reference-type-operator-mismatch.

All three batches dropped most of what they proposed, and the
refutations are the useful part.

**Racket** got both a corpus and a toolchain -- 4492 .rkt files and
Racket v9.2 -- so every premise was executed. Typed Racket's `Any`
does the **opposite** of what was proposed: `(needs-number (takes-any
5))` is a type error, `expected: Number given: Any`, so `Any`
maximally *constrains* consumers and `(-> _ Any)` is idiomatic for
side-effecting functions. That is the second time a Typed Racket
shape-match premise has been refuted here. `module+` reads a *later*
`define` fine, because it is `module*` with `#f`, declared after the
body. `define/contract` on a non-exported function still guarantees
every internal caller -- the boundary is definition-vs-rest-of-module,
verified by a same-module call raising while the recursive call did
not.

`racket-contract-out-arity-mismatch` was built, tested, corpus-audited
and then **deleted on measurement**: 1.25ms for one realistic file,
~40000x the control, super-linear -- and zero findings over 214
`contract-out` occurrences, so the cost bought nothing.

**Emacs Lisp shipped 2 of 8, and that is the honest number.** A bulk
writer proved the process-filter bug: 10000 lines in one write reached
the filter as 8 invocations with **7 mid-line splits**, because
`read-process-output-max` is 65536 -- so the defect passes every
small-output test and corrupts data past 64 KiB. Refuted: `goto-char`
*clamps* rather than signalling; `advice-remove` compares with `equal`
so a lambda **is** removable; and `setq` on a buffer-local variable
creates a buffer-local binding, which had already been refuted once in
this project and was re-proposed anyway.

Two more elisp rules were built, audited and deleted rather than
shipped at 11% and 20% fire rates on GNU Emacs's own tree: narrowing
(112/1044 -- rmail, gnus and ediff narrow as their *display model*) and
display properties (500/2679 -- `font-lock-ensure` leaves
`buffer-modified-p` nil, so `'face` writes during fontification are
correct).

**Clojure**'s `go`-block rule survived a check that could have killed
it: core.async *does* ship a blocking-op detector, but
`check-blocking-in-dispatch` fires only under a system property read
once at namespace load, which its own docs call development-only and
"covers only part of the problem". `go-impl` does no static inspection.
Conversely `defrecord-implements-protocol-method-not-in-protocol` **is**
a compile error -- `Compiler.java:9128` throws "Can't define method not
in interfaces" -- so that rule died. And a no-init `reduce` rule died on
measurement: 35 of 172 corpus occurrences pass a literal fn with no
0-arity, all working code.

`dynamic-scope-returns-lazy-seq` was built, corpus-run at 978
candidates / 1 finding / **0 true positives**, and withdrawn.

Wiring notes:

- **`RULE_DOCS` had to become `static`.** The 345th rule crossed
  clippy's default 16 KiB `array-size-threshold` (345 x 48 = 16560 B;
  at 334 it was 16032, just under). Took clippy's own suggestion rather
  than an `#[allow]`: nothing reads it in a const context, so `static`
  is also the substantive fix, since a `const` is substituted into every
  use site. That threshold is now permanently crossed.
- The two `Fixable` Racket rules needed a **Racket** fixture in
  `fixable_rules_match_the_fix_engine` -- the Scheme fixture does not
  reach them, because `lint-scheme-idiom` declares `[Scheme, Racket]`
  while these declare `[Racket]` alone. Proved by removing the line and
  watching exactly those two go missing. Third time this test has caught
  a `--fix` that would silently do nothing.
- treefmt reformatted `lint-racket-depth`'s own fixtures: the
  `tests/fixtures/*` exclusion is root-anchored and does not cover
  `packages/feature/*/tests/fixtures/`. Same trap as PR #88.
- `docs/src/reference/architecture.md` had drifted across PRs #97, #99,
  #101 and #102 -- it claimed 320 rules and 66 packages. Rewritten with
  measured values.

Two parser gaps found and reported, not fixed. **Racket: 1777 of 4492
files (39.6%) fail to parse** on `#rx`/`#px`, `#%kernel`, `#'`/`` #` ``,
`#<<` here-strings, `#hash`, `#"..."` -- which caps what any Racket rule
in this workspace can see. **Emacs Lisp: 191 of 1674 (11.4%)**, 153 of
them radix literals (`#x010101`, `#b01111110`, `#o777`, `#24r1k`) in
ordinary files like `bookmark.el`, `ansi-color.el` and `calc.el`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant