feat(lint): add 8 Clojure and Scheme lint rules - #88
Merged
Conversation
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
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`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
RULE_COUNT295 → 303. Two new packages, eight standaloneinspectcommands. All eight are scoped away from Common Lisp, taking the dialect-specific report list 6 → 14.lint-clojure-idiom(CLOJURE_ONLY, report-only)with-open-returns-lazy-seq,def-inside-function-body,single-key-nested-path,apply-with-literal-collectionlint-scheme-idiom(all fixable)scheme-begin-single-form,scheme-let-star-independent-bindings,scheme-memq-assq-literal-key(Scheme only),scheme-named-let-never-recursBoth packages were audited against real third-party code, and both lost a rule to it
Corpora: 1714
.clj/.cljcfrom 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-seqswept 162 realwith-openscopes for its zero.scheme-eq-on-number-or-char-literaldropped — 462 candidates, 15 findings, zero true positives. All 15 were characters or small fixnums, inside the range Racket normatively guaranteeseq?for. After removing the guaranteed cases nothing syntactically detectable remained. Replaced byscheme-memq-assq-literal-key, which R7RS §6.4 settles outright ((memq 101 '(100 101 102))is unspecified wherememvis defined) and which found a real latent portability bug in Guile's ownpunify.scm:65.count-compared-to-zerodropped on stronger evidence — its recommended repair throws.countaccepts anythingCounted;seq/empty?need seqability, and core.async'sFixedBuffer/DroppingBuffer/SlidingBufferimplementCountedand nothing else, so 12 findings recommended code that does not run.clojure.core:6433then 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
casetest-constant list ((case tag (def defonce ...) ...)) read as code, and(comment ...)bodies read as code. Two Scheme false positives likewise fixed — a namedletinside asyntax-rulestemplate, and(begin ,form)at quasiquote depth 0. Re-audit: 10 findings, 10 true positives, 0 false positives.def-inside-function-bodyhas an independent oracle: clj-kondo's:inline-deffixture 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-recurscomputed afix_spancovering a comment in the(let <gap> loop ...)gap. Verified by building a probe driving the real dispatcher andapply_byte_span_edits— the same applierworkflow.rs:1186calls — 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.rspins 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_enginedrove only Common Lisp and Emacs Lisp fixtures, so the four rules declaredFixableemitted no fix at all — a--fixthat silently does nothing. Added a Scheme fixture rather than relaxing the assertion;fix plannow reportsfix_count: 4.a_dialect_specific_report_is_supported_for_exactly_one_dialecthad 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_dialectsasserting(1..DIALECTS.len())— the lower bound still catches an under-supported report, the upper bound a general one. Deriving the expectation fromlint_rule_dialect_scopewould have been circular, sincesupport_statusreads 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
defmethodfromdef-inside-function-body'sHeadsleft 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 everydefmethod. 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 alland the standaloneinspect <rule>command, one finding apiece. The.rktcolumn is the load-bearing one — it confirms the three two-dialect scopes actually reach Racket and thatmemq-assqis correctly silent there. Zero new findings sweepingtests/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). Aparedit-core-syntaxreader gap, untouched by this PR.