Skip to content

fix(lint): stop two Emacs Lisp rules rewriting quoted symbol lists - #124

Merged
takeokunn merged 1 commit into
mainfrom
fix/elisp-quoted-lambda-and-cl-alias
Aug 4, 2026
Merged

fix(lint): stop two Emacs Lisp rules rewriting quoted symbol lists#124
takeokunn merged 1 commit into
mainfrom
fix/elisp-quoted-lambda-and-cl-alias

Conversation

@takeokunn

Copy link
Copy Markdown
Collaborator

Survey of the non-Common-Lisp dialect rules — a dimension never audited before. Enumerated by a probe crate iterating the shipped REGISTRY: 358 rules, 76 name a non-CL dialect (58 pure, 18 mixed) = 133 (rule, dialect) sweeps over 9,811 raw → 9,586 SHA-256-deduped files across nine dialects. 0 partial batches; every sweep asserted scanned == list length. 14,579,895 candidate nodes, 19,383 findings.

Rank 1 — elisp-quoted-lambda: Error + Fixable + Destructive, 15/15 false

The predicate was head-only: a list, with a Quote prefix, whose first child is lambda. That matches a symbol list as readily as a quoted function. Demonstrated with the shipped binary against byte-opt.el:

(memq head '(lambda internal-make-closure length cons))
                    ↓ after --fix
(memq head  (lambda internal-make-closure length cons))

A membership test rewritten into a call, automatically, in GNU Emacs's own source. Same shape in bind-key.el, cus-start.el, elint.el, calc-map.el. Two more were ',(lambda …)menu-bar.el's idiom where the unquote evaluates the lambda so the quote applies to the closure; two were ''(…) / '#'(…).

Fix: require a lambda list ((…) or nil) in second position, and require the prefix to be exactly [Quote]. 15 → 6.

Rank 2 — elisp-obsolete-cl-alias: Error, no context check at all, 25/25 sampled false

150 findings. All binding pairs, lambda lists, or quoted data:

shape example
binding pair (dolist (block blocks) …), (let (ll (do t)) …)
lambda list (defun mail-comma-list-regexp (labels) …)
parameter (mapcar (lambda (case) …))
quoted data (memq word '(do doing)), (doctor-type '(do you know Stallman \?))
indent table cl-indent.el's own spec list

None was a call to a removed macro — unsurprising, since Emacs 31 wouldn't compile if it were.

Fix: require ≥2 arguments; require the first argument to match each macro's real lambda list (List for do/flet, Symbol for block); skip node-local quotes; then ask binding_table().resolve(). 150 → 35 → 14.

A premise in the brief was wrong

I stated that non-CL dialects have an empty binding_table(). Emacs Lisp does notpackages/core/semantics/src/semantics/binding/service/emacs_lisp.rs models named-let, and that check alone removed 21 of 35 residual findings. It holds for LFE (whose rules say so in their own docs), not here.

The residual 14

1 true positive — a genuine unprefixed (case command …) in a chibi-scheme company backend, previously buried in noise. 1 arguable (cl.el's own shim). 12 false, all one class: quote/quasiquote on an ancestor. is_unevaluated_at in lint-form-shape/src/support.rs solves exactly that and #119/#120 established the pattern — but adopting it means copying the two-counter model into a fifth package, which is a separate decision.

Mutation testing earned its place

The second harness first reported 5 survivors — because the new arity guard masked every other guard in the test cases. Rebuilding those controls around real GNU Emacs shapes took it to 7/7 killed (and 6/6 on the first rule). Without it, four guards would have shipped with no coverage.

Controls: 14 asserting every alias still fires in its real call shape; 4 asserting elisp-quoted-lambda still fires on '(lambda (n) n), '(lambda nil 1), '(lambda () 1), &optional forms, and inside a backquote.

All three briefed hypotheses were refuted

hypothesis result
CL-tuned quote guard on a ,-divergent dialect not found — the broken rules had no guard, not a wrong one
heads never firing due to case-folding refuted — 22 rules fired zero times, but all 22 had non-zero candidates, so no false-cleans
firing inside unmodelled Hy quasiquote refutedreader_policy.rs:748 documents ~ as deliberately not a prefix, so the effect is suppression; Hy's coverage is understated

Reported, not fixed

  • leftover-print-debug — Warning + Fixable, 6,560 findings. Treats Scheme's display and Janet's print as debug leftovers when they are those languages' primary output primitives: (display line out) in an echo server, (print (slurp path)) in an amalgamation tool. --fix deletes them.
  • elisp-defcustom-missing-group — fires on 3,668 of 8,476 defcustoms. Correct, but relies on documented file-level defgroup inheritance; belongs in RuleTag::Pedantic.

Verification

Re-running all 133 sweeps against the patched build produced a diff of exactly two lines, both reductions, nothing added.

cargo build / test -p paredit-feature-emacs-lisp (43) / clippy --all-targets -- -D warnings / fmt --check / test -p paredit-cli --lib (334) / test --test cli (3,085 passed) — all exit 0, read unpiped. No golden moved, no pinned count moved.

Known gap: two subagents adjudicating mid-tier rules (elisp-interactive-arity-mismatch, the Scheme/Racket Fixable set, duplicate-test-name, racket-match-unreachable-clause) never returned. Those are surveyed but not adjudicated.

`elisp-quoted-lambda` ships at `Severity::Error` with a destructive
autofix, and over 1751 GNU Emacs 31.0.91 and package files every one of
its fifteen findings was false. Its predicate was head-only -- a list,
carrying a `Quote` prefix, whose first child is `lambda` -- which matches
a *symbol list* just as readily as a quoted function. Demonstrated with
the shipped binary against `byte-opt.el`:

    (memq head '(lambda internal-make-closure length cons))
    -- after --fix -->
    (memq head  (lambda internal-make-closure length cons))

A membership test rewritten into a call, automatically, in GNU Emacs's
own source. The same shape appears in `bind-key.el`, `cus-start.el`,
`elint.el` and `calc-map.el`. Two further findings were `',(lambda ...)`,
`menu-bar.el`'s idiom where the unquote evaluates the lambda so the quote
applies to the resulting closure, and two were `''(...)` or `'#'(...)`.

It now requires a lambda list -- `(...)` or `nil` -- in the second
position, and requires the prefix to be exactly `[Quote]`. Fifteen
findings become six.

`elisp-obsolete-cl-alias` ships at `Severity::Error` with no context
check at all: 150 findings, and 25 of 25 sampled at random were false.
They are `(dolist (block blocks) ...)` and `(let (ll (do t)) ...)`
binding pairs, `(defun mail-comma-list-regexp (labels) ...)` lambda
lists, `(mapcar (lambda (case) ...))`, quoted data such as
`(memq word '(do doing))` and `(doctor-type '(do you know Stallman \?))`,
and `cl-indent.el`'s own indent-spec table. None was a call to a removed
macro, which is what you would expect: Emacs 31 would not compile if it
were.

It now requires at least two arguments, requires the first argument to
match each macro's real lambda list -- a list for `do` and `flet`, a
symbol for `block` -- skips a node carrying its own quote, and finally
asks `binding_table().resolve()` whether the head is a local binding.
That last check removed 21 of the remaining 35 on its own: contrary to
what the other dialects suggest, Emacs Lisp *does* have a modelled
binding table, in `semantics/binding/service/emacs_lisp.rs`, which knows
`named-let`. 150 findings become 14.

Of those 14, one is a genuine unprefixed `(case command ...)` in a
chibi-scheme company backend that was previously buried in noise, one is
`cl.el`'s own shim, and twelve are a single remaining class: a quote or
quasiquote on an *ancestor* rather than the node. `is_unevaluated_at` in
`lint-form-shape/src/support.rs` solves exactly that, and PRs #119 and
#120 established the pattern, but adopting it here means copying the
two-counter model into a fifth package. That is a separate decision and
is left alone.

Mutation testing earned its place twice over. The second harness first
reported five survivors, because the new arity guard masked every other
guard in the test cases I had written; rebuilding those controls around
the real GNU Emacs shapes took it to 7 of 7 killed. Without it this would
have shipped four guards with no coverage.

Reported and not fixed: `leftover-print-debug` is `Fixable` with 6560
findings and treats Scheme's `display` and Janet's `print` as debug
leftovers, when they are those languages' primary output primitives --
`--fix` deletes them. And `elisp-defcustom-missing-group` fires on 3668
of 8476 `defcustom` forms; it is correct, but it relies on documented
file-level `defgroup` inheritance and belongs in `RuleTag::Pedantic`.
@takeokunn
takeokunn merged commit 6fa6d18 into main Aug 4, 2026
10 checks passed
@takeokunn
takeokunn deleted the fix/elisp-quoted-lambda-and-cl-alias branch August 4, 2026 08:08
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