Skip to content

fix(lint): suppress the eq comparison rules inside a hard quote - #119

Merged
takeokunn merged 1 commit into
mainfrom
fix/eq-comparison-quote-guard
Aug 4, 2026
Merged

fix(lint): suppress the eq comparison rules inside a hard quote#119
takeokunn merged 1 commit into
mainfrom
fix/eq-comparison-quote-guard

Conversation

@takeokunn

Copy link
Copy Markdown
Collaborator

eql-string-comparison ships at Severity::Error and had no quote-context guard at all, so it reported quoted data as if it were a comparison.

Over 5,388 unique Common Lisp files / 87 MB (SBCL 2.6.0/2.6.1/2.6.6 + Quicklisp + nix-store, deduplicated by SHA-256), every one of its findings was a false positive:

file:line form quote opener
hyperspec.lisp:367 (eq function "f_eq") '( at :7
hyperspec.lisp:368 (eql function "f_eql") '( at :7
hyperspec.lisp:369 (eql type "t_eql") '( at :7
hyperspec.lisp:1052 (eql "a_eql") '( at :1040
vm-bridge-io-docs.lisp:345 (eql . "RT-EQL") '( at :330

mgl-pax's HyperSpec index stores three-element lists inside a '(; the sbcl-cl-cc-vm hit is an opcode-to-runtime alist pair. Each quote opener was read, not inferred.

The obvious repair would be worse than the defect

Suppressing every quote also suppresses quasiquote — and in SBCL's own sources most quasiquoted hits are macro templates that genuinely become code: `(eq ,val 0) at hashset.lisp:110, and the same shape in srctran.lisp and ir1tran-lambda.lisp:691.

So the guard reads only the hard half of the two-counter QuoteState. That is precisely why the model has two counters (hard: bool + quasi: u32) rather than one depth. A hard-quoted form is never evaluated, so suppressing it cannot produce a false negative.

The guard lives in the package's existing support.rs, which already carried QuoteState and is_unevaluated_at for four float rules; the descent is now shared as quote_state_at, with is_hard_quoted_at reading .hard alone. No new quote machinery, no i32 counter.

All three eq rules get it — the guard answers "is this node code at all", a precondition for any of them meaning anything.

A second guard: one-argument eq

(eq #\a) in trivia's pattern DSL (test/level2.lisp:909) is unquoted, so the quote guard doesn't reach it. But SBCL already says "The function EQ is called with one argument, but wants exactly two" — a one-argument form is compiler-caught and isn't a comparison. trivia's patterns are one-argument by construction, so this removes the whole DSL class.

single-arg-comparison does not cover it; verified via --explain that it is scoped to < > <= >= = /= only.

Differential: 166 → 156, ten removed, none added

The count deliberately does not fall to zero. eq-number-comparison holds at 144, including the two genuine float bugs at cross-float.lisp:28(eq flonum -0.0f0) and (eq flonum -0.0d0), inside a live (defun flonum-minus-zero-p ...). Those are the case where eq really does return the wrong answer cross-compilation-unit. All 12 surviving eq-char findings are real code (cffi functions.lisp:294, cl-ppcre charset.lisp ×9, SBCL shebang.lisp:181 ×2).

Negative controls, end-to-end through the binary

12 MUST-FIRE all fire, including quasiquote templates `(eq ,val 0), `(eq ,name "done"), `(eq ,ch #\a), a quasiquote without an unquote, and unquoted code inside a defmacro body.

Every MUST-BE-SILENT is silent: hard-quoted tables, dotted alist pairs, (quote ...), '(a ,(eq ...)) (comma inside a hard quote), one-argument patterns, and the defect spelled inside a string.

Mutation testing

Each mutation verified to land via diff before running.

mutation result
guard removed 9 fail — 6 hard-quote engine + 3 support
use is_data() — the obvious wrong fix 3 failexactly the quasiquote tests
arity guard stripped (all 3 domains) 3 fail — exactly the arity tests

The second is the important one: the suite actively blocks the quasiquote-suppressing regression rather than merely tolerating the correct version. All restored byte-identically; 410/410 green after.

Cost

Ordinary file, zero findings, 200→400 defuns: 20.0 ns/inv at both sizes, ratio 1.00 — indistinguishable from untouched self-comparison (30.0) and redundant-quote (17.4/18.2). The guard sits after the head-and-argument check, so root_view() is never reached.

On a pathological all-quoted table: 1.89 — against 1.92 for the shipped mixed-float-precision-arithmetic on identical input, within 2.4%. That super-linear cost is a pre-existing property of is_unevaluated_at rebuilding root_view() per finding, shared by four shipped rules. Not a regression here; worth flagging separately.

Scope

8 files, all under packages/feature/lint-numeric/src/. No rule added, no golden moved, no pinned count moved — so no repo fixture contained a false positive.

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

`eql-string-comparison` ships at `Severity::Error` and had no quote
context guard at all, so it reported quoted *data* as if it were a
comparison. Over 5388 unique Common Lisp files, every one of its findings
was a false positive:

  mgl-pax `hyperspec.lisp:367-369,1052`  a HyperSpec index whose rows are
    three-element lists inside a `'(`, so `(eq function "f_eq")` is a data
    row and not a call
  sbcl-cl-cc-vm `vm-bridge-io-docs.lisp:345`  `(eql . "RT-EQL")`, an
    opcode-to-runtime alist pair

The obvious repair would have been worse than the defect. Suppressing
every quote also suppresses quasiquote, and in SBCL's own sources most
quasiquoted hits are macro templates that really do become code --
`` `(eq ,val 0) `` at `hashset.lisp:110`, and the same shape in
`srctran.lisp` and `ir1tran-lambda.lisp:691`. So the guard reads only the
`hard` half of the two-counter `QuoteState`, which is exactly why that
model has two counters rather than one depth. A hard-quoted form is never
evaluated, so suppressing it cannot produce a false negative.

The guard lives in the package's existing `support.rs`, which already had
`QuoteState` and `is_unevaluated_at` for four float rules; the descent is
now shared as `quote_state_at` with `is_hard_quoted_at` reading `.hard`
alone. No new quote machinery.

All three `eq` rules get it. The guard answers "is this node code at
all", which is a precondition for any of them meaning anything, and
fixing the latent case in the two siblings costs nothing.

A second guard drops a one-argument `eq`. `(eq #\a)` in trivia's pattern
DSL (`test/level2.lisp:909`) is unquoted, so the quote guard does not
reach it, and SBCL already says "The function EQ is called with one
argument, but wants exactly two" -- a one-argument form is compiler-caught
and is not a comparison. trivia's patterns are one-argument by
construction, so this removes the whole DSL class. `single-arg-comparison`
does not cover it; that rule is scoped to `< > <= >= = /=`.

Findings over the corpus go 166 to 156: ten removed, none added. The
count deliberately does not fall to zero -- `eq-number-comparison` holds
at 144, including the two genuine float bugs at `cross-float.lisp:28`,
`(eq flonum -0.0f0)` and `(eq flonum -0.0d0)`, which are the case where
`eq` really does return the wrong answer cross-compilation-unit.

Mutation-tested with each mutation checked by `diff` before running.
Replacing the guard with `is_data()` -- the obvious wrong fix -- fails
exactly the three quasiquote tests, so the suite blocks that regression
rather than merely tolerating the correct version.

Cost is unchanged on ordinary code: 20.0 ns/invocation at both file
sizes, ratio 1.00, because the guard sits after the head-and-argument
check and `root_view()` is never reached. On a pathological all-quoted
table it is 1.89, against 1.92 for the shipped
`mixed-float-precision-arithmetic` on the identical input -- a
pre-existing property of `is_unevaluated_at` rebuilding `root_view()` per
finding, shared by four shipped rules, not a regression here.
@takeokunn
takeokunn merged commit 04f6678 into main Aug 4, 2026
9 of 10 checks passed
@takeokunn
takeokunn deleted the fix/eq-comparison-quote-guard branch August 4, 2026 05:10
takeokunn added a commit that referenced this pull request Aug 4, 2026
…otes (#120)

A survey of all 358 rules found 274 scoped to Common Lisp, 181 of those
with no quote-context guard, and 67 of those misfiring on hard-quoted
data over a 5,556-file corpus. These are the two worst.

`equality-arity` reported 674 false positives, 34% of its output, at
`Severity::Error`. It already had a guard, but `domain.rs:121` reads the
node's *own* `reader_prefixes` and never the ancestor chain -- which is
exactly why its self-quoted count is 0 and its ancestor-quoted count is
674. The findings are quoted CLHS type specifiers such as
`(typep spec '(cons (eql or) ...))`, where a one-argument `eql` is
correct Common Lisp, throughout SBCL's `checkgen.lisp`, `interr.lisp` and
`sexpr.lisp`; mgl-pax's quoted HyperSpec index, the same table that
produced the bug PR #119 fixed; and SBCL's quoted pprint dispatch table.

`one-step-arithmetic` reported 163, and it is `Fixable`, which makes it
the more dangerous of the two: the autofix rewrites source. It would
turn `:cases (("1+" '(1+ n) '(+ n 1)))` -- the expected value of a test
asserting what `1+` expands to -- into `'(1+ n)`, making the assertion
tautological.

Both guards read only the `hard` half of `QuoteState`, following PR #119,
and sit inside the per-item loop so `root_view()` is unreachable unless a
finding already exists.

Three rules were deliberately left alone. `implementation-package-symbol`
has 307 ancestor-quoted findings that are all correct: it fires on
`(import '(sb-sys:sap-ref-16 ...))`, and the symbol really is in an
implementation package whether or not the list is quoted, so a guard
would buy a false negative.

`one-armed-if` and `explicit-nil-return` were left alone for a subtler
reason, and it corrects the premise this work started from. A hard quote
does *not* always mean inert: `#.` read-eval resurrects quoted code, as
in SBCL's `early-extensions.lisp`, where a `(progn (defun ...))` sits
hard-quoted inside `#.(if *profile-hash-cache* '(progn ...))` and is
spliced back as code at read time. `deftransform` templates do the same
by `subst`ing a quoted body into compiled output. Measured, 95 of 1823
hard-ancestor findings (5.2%) sit under `#.`, concentrated in those two
rules -- 9 of 16 and 5 of 23 -- so guarding them would suppress real
findings. The two rules fixed here are clean by that measure:
`one-step-arithmetic` has 0 under `#.`, and all 6 of `equality-arity`'s
are type specifiers regardless of the enclosing `#.`.

Findings go 117,299 to 116,407 over the corpus: 892 removed, 0 added, no
other rule changed. Every removed span was re-probed individually and
confirmed hard-quoted. The two rules retain 238 quasiquote-template
findings and emit zero hard-quoted ones afterwards.

Reported and not fixed: about 970 of `equality-arity`'s remaining 1,307
findings are one-argument `(eql X)` in *unquoted* type contexts -- EQL
specializers in `defmethod` lambda lists, and `typecase`/`deftransform`
clause heads. That rule is likely more than 80% false overall, but
fixing it needs a type-context model rather than a quote guard.
takeokunn added a commit that referenced this pull request Aug 4, 2026
)

`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 added a commit that referenced this pull request Aug 4, 2026
Four PRs took this rule from 1981 findings to 407 by closing three
structural false-positive classes -- hard-quoted data (#119, #120), CLHS
type positions (#121), and case keys and bound variables (#123). The 407
that remain are, on every sample drawn from them, still false: 120 of 120
in one adjudication and 14 of 14 in another.

They cannot be closed the same way, and the investigation into a general
mechanism is what settles the severity question.

Suppressing findings inside any head the engine has never seen defined
would silence 32,886 of 113,979 findings across 186 of the 214 rules that
fire -- 28.9% of the catalogue's output. An adjudicated sample of thirty
of the collateral was 23 genuine, 5 false, 2 ambiguous, which scales to
roughly 25,000 real findings destroyed to remove 405 false ones, about 62
real per false. The premise behind that design is also simply untrue:
`when`, `dolist`, `deftest`, `describe`, `macrolet` and `named-let` are
all macros whose body arguments *are* evaluated, so knowing a head is a
macro says nothing about which of its positions hold data.
`OpacityCauseKind::UnknownHead`'s own documentation reaches the same
conclusion -- "treating `(print x)` as opaque too would be sound as well,
and would prove nothing about any real file" -- and Common Lisp has no
registry of ordinary function heads to distinguish them.

A configured list of unevaluated heads fares no better. `RuleSettings`
carries `i64` values by explicit design, so the list needs
`packages/core/lint-engine` and its config key needs `config_bridge`; a
*name* list is the wrong granularity anyway, since every `deftransform`
finding is at child index 2, its lambda list, while its body at index 3
and beyond is ordinary code. Even a perfect `(head, position)` table
closes 312 of 407, leaving a build-blocking rule that still blocks builds
on correct programs.

Fourteen rules share this false-positive class, 624 findings, seven of
them at `Severity::Error` for 459 build-blocking false positives, so this
is not a problem peculiar to one rule. It is worth fixing properly later.
Until then the honest severity for a rule measured at 407 findings and no
true positives over 5556 files is `Warning`, not `Error`.

Worth recording for whoever picks this up: the 407 live in three projects
-- SBCL's compiler (561 of the 624), `trivia` (62) and `mgl-pax` (1) --
and the corpus holds five SBCL releases that content-hash dedup cannot
collapse, so those 407 findings occupy 88 paths but only 36 canonical
ones. The SBCL bucket is one codebase counted five times.

`warning_count` 255 to 256 and the preset-filtered count 239 to 240; the
rule is untagged, so both move together. `RULE_COUNT` and
`fixable_count` are unchanged. The golden diff is seven lines across six
files, all of them the severity token.
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