Skip to content

feat(lint): add 4 Hy and LFE lint rules - #93

Merged
takeokunn merged 1 commit into
mainfrom
feat/lint-batch-r11
Aug 3, 2026
Merged

feat(lint): add 4 Hy and LFE lint rules#93
takeokunn merged 1 commit into
mainfrom
feat/lint-batch-r11

Conversation

@takeokunn

Copy link
Copy Markdown
Collaborator

Stacked on #92. RULE_COUNT 316 → 320, registry-only, no new commands.

This completes dialect coverage — every one of the ten dialects now has at least one rule written for it specifically. Before this session's batches, 13 of 286 rules ran on anything but Common Lisp.

rule dialect severity notes
hy-mutable-default-argument Hy Error
hy-identity-comparison-with-literal Hy Warning
hy-bare-except Hy Warning
lfe-catch-swallows-exit LFE Warning pedantic

Premises were run, not argued

Both interpreters were obtainable — Hy 1.3.1 and LFE 2.2.0 / Erlang 27.3.4.15.

The Hy mutable default is the mirror image of a rule this project already refuted for Common Lisp. CL re-evaluates an &optional init form per call, so the shared-mutable-default defect does not exist there; Python evaluates a default once, at definition time, so the identical shape is a real bug in Hy. Confirmed: three calls return [1], [1 1], [1 1 1]; {} and #{} behave identically, None does not.

Also confirmed: CPython emits SyntaxWarning: "is" with 'int' literal through Hy; (except []) catches KeyboardInterrupt and SystemExit while (except [e Exception]) catches neither; and (catch (exit 'boom)), (tuple 'EXIT 'boom) and (catch (tuple 'EXIT 'boom)) are the identical term, so an LFE caller cannot tell failure from success.

Refuted and dropped: (if 'false 'a) returns false in LFE rather than raising, so if-without-else is not a defect; ! to a dead pid returns the message with no error and isn't statically detectable; case/receive without a catch-all is "let it crash" and would be mass false positives.

Also stale in the original brief: Hy 1.3.1 rejects #@ decorators and with-decorator (the current form is (defn [decorator] name …)), and mangling is foo?hyx_fooXquestion_markX, not _is_.

The corpus audit changed three of five candidates

2825 .hy across 284 repos, 2701 .lfe across 195 — nobody involved wrote any of it.

rule candidates findings repos
hy-mutable-default-argument 884 1 1
hy-identity-comparison-with-literal 712 2 2
hy-bare-except 607 32 11
lfe-catch-swallows-exit 321 146 9
  • hy-mutable-class-attribute was killed — 33 findings over 251 candidates, 0 genuine defects. The premise is true and I measured it ((is a.items b.items) really is True), but in real Hy a mutable class attribute is a declaration: __slots__, __all__, Django ModelAdmin fields, Textual BINDINGS, Gym metadata. A test pins the decision so it isn't reintroduced.
  • hy-mutable-default-argument was narrowed from "mutable default" to "mutable default the body mutates", taking it from 88 findings to 1. The dropped ones were hand-checked — [notes [0 5 7]], [breaks ["amen.wav" …]] are read-only lookup tables. The survivor is genuine: [known-ids #{}] with (.add known-ids id) in a body whose own docstring shows the author expected a fresh set per call.
  • lfe-catch-swallows-exit was tagged pedantic rather than killed. 146 findings, but only 9 of ~143 repos and two of the three heaviest are LFE's own implementation.

Both dialect gates were proven on the corpus, not just in unit tests: 38 catch candidates in Hy files → 0 findings; 5 is candidates in LFE files → 0.

The pedantic tag makes the preset arithmetic non-obvious

The divisor moves too, so none of these is "previous + 4":

  • recommended rule_count 301 → 304 = 320 − 16 (was 316 − 15). Rises by 3.
  • recommended warnings 217 → 219: suite warnings rise by 3 to 235, but one of those 3 is itself pedantic, so 235 − 16 = 219. Rises by 2.
  • fixable count stays 103 — this batch is the first to actually exercise that assertion's claim, adding a pedantic rule while the number holds, because the new pedantic rule is ReportOnly.

Confirmed independently in the regenerated goldens: broad.json.golden gained exactly three rule rows, not four.

A latent contract bug this exposed, flagged not fixed

feature_dependency_contract scans manifests as whole text rather than parsing them, so the package's comment explaining a removed dev-dependency read as two declared feature→feature edges. Rewording it to name the packages by directory fixed the build — no allowlist entry was added, since the edges don't exist and that would have weakened the contract.

But an intermediate wording containing the bare marker paredit-feature- made the scanner emit an empty-string dependency name and fail differently. declared_feature_dependencies (tests/cli/feature_dependency_contract.rs:53) cannot tolerate the marker appearing without a name after it. Pre-existing; nothing in the tree triggered it before.

Reader limitations recorded, not worked around silently

In the package README, with measured counts: Hy's interpolated f-strings unimplemented (390 of 2825 files fail to parse), #! shebangs not stripped (393 files, exit 0 with junk atoms), Hy bracket strings #[[…]] parsed as code (33 files — the package defends against this explicitly, since a rule could otherwise fire on text that isn't code, and a test pins it), Hy's ~ not a ReaderPrefix so QuoteState.quasi never counts down for Hy, and LFE #B(…)/#M(…) orphaned from their list in 243 files, inflating arity at exit 0. Plus two more found in passing: LFE |quoted atoms| split at whitespace (95 files) and #\(/#\) restructuring the tree.

Same class as the Janet backtick defect fixed in #91, with the same disposition — a reader repair belongs in core/syntax.

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 four fire through the real binary, parsing the JSON findings array, and the pedantic gate is confirmed in both directions: lfe-catch-swallows-exit fires under --preset all and is correctly absent under recommended. Zero findings sweeping tests/fixtures/**, where the two relevant fixtures are real negative controls rather than empty — corpus/hy.hy has an immutable default and a typed except, both correctly unreported.

Base automatically changed from feat/lint-batch-r10 to main August 3, 2026 08:51
RULE_COUNT 316 -> 320, registry-only, no new commands. This completes
dialect coverage: every one of the ten dialects now has at least one
rule written for it specifically.

lint-hy-lfe-idiom: hy-mutable-default-argument (Error),
hy-identity-comparison-with-literal, hy-bare-except, and
lfe-catch-swallows-exit (pedantic).

Both interpreters were available, so every premise was run rather than
argued. Hy 1.3.1 and LFE 2.2.0 / Erlang 27.3.4.15.

Hy's mutable default is the mirror image of a rule this project already
refuted for Common Lisp: CL re-evaluates an `&optional` init form per
call, but Python evaluates a default **once**, at definition time, so
the same shape that is fine in CL is a real bug here. Confirmed: three
calls return [1], [1 1], [1 1 1]; `{}` and `#{}` behave identically and
`None` does not.

Also confirmed: CPython emits `SyntaxWarning: "is" with 'int' literal`
through Hy; `(except [])` catches KeyboardInterrupt and SystemExit while
`(except [e Exception])` catches neither; and `(catch (exit 'boom))`,
`(tuple 'EXIT 'boom)` and `(catch (tuple 'EXIT 'boom))` are the
**identical term**, so an LFE caller cannot tell failure from success.

Refuted and dropped: `(if 'false 'a)` returns false in LFE rather than
raising, so if-without-else is not a defect; `!` to a dead pid returns
the message with no error and is not statically detectable; and
case/receive without a catch-all is "let it crash", which would be mass
false positives.

The corpus audit -- 2825 .hy across 284 repos, 2701 .lfe across 195 --
changed three of the five candidates:

- `hy-mutable-class-attribute` was **killed**: 33 findings, 251
  candidates, 0 genuine defects. The premise is true and measured, but
  in real Hy a mutable class attribute is a *declaration* (`__slots__`,
  Django ModelAdmin fields, Textual BINDINGS), not accidental sharing.
- `hy-mutable-default-argument` was narrowed from "mutable default" to
  "mutable default the body mutates", taking it from 88 findings to 1.
  The survivor is a genuine bug whose own docstring shows the author
  expected a fresh set per call.
- `lfe-catch-swallows-exit` was tagged pedantic rather than killed:
  146 findings but only 9 of ~143 repos, and two of the three heaviest
  are LFE's own implementation.

Both dialect gates were proven on the corpus, not just in unit tests:
38 `catch` candidates in Hy files produced 0 findings, and 5 `is`
candidates in LFE files produced 0.

The wiring exposed a latent bug in feature_dependency_contract, flagged
not fixed: it scans manifests as whole text, so the package's *comment*
explaining a removed dev-dependency read as two declared feature edges.
Rewording it to name the packages by directory fixed the build -- but an
intermediate wording containing the bare marker `paredit-feature-` made
the scanner emit an empty-string dependency name and fail differently.
It cannot tolerate the marker appearing without a name after it.

Reader limitations found while building this are recorded in the
package README rather than worked around silently: Hy's interpolated
f-strings are unimplemented (390 of 2825 files fail to parse), `#!`
shebangs are not stripped (393 files, exit 0 with junk atoms), Hy
bracket strings `#[[...]]` are parsed as *code* (33 files -- the package
defends against this explicitly, since a rule could otherwise fire on
text that is not code), Hy's `~` is not a ReaderPrefix so QuoteState
never counts down, and LFE `#B(...)`/`#M(...)` are orphaned from their
list in 243 files, inflating arity at exit 0. Same class as the Janet
backtick defect fixed in #91, and a repair belongs in core/syntax.
@takeokunn
takeokunn force-pushed the feat/lint-batch-r11 branch from 34cb4b9 to c2905c1 Compare August 3, 2026 08:53
@takeokunn
takeokunn merged commit e57c520 into main Aug 3, 2026
10 of 14 checks passed
@takeokunn
takeokunn deleted the feat/lint-batch-r11 branch August 3, 2026 09:22
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