Skip to content

feat(lint): add 10 Fennel, Janet and CL declaration rules - #89

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

feat(lint): add 10 Fennel, Janet and CL declaration rules#89
takeokunn merged 2 commits into
mainfrom
feat/lint-batch-r8

Conversation

@takeokunn

Copy link
Copy Markdown
Collaborator

Stacked on #88 — review that first. RULE_COUNT 303 → 313. Both packages are registry-only, so no new commands and the dialect-matrix lists do not move.

package rules
lint-fennel-janet-idiom var-never-set (Fennel and Janet), fennel-deprecated-form, fennel-each-over-non-iterator, janet-empty-loop-body, janet-mutating-immutable-literal
lint-type-declaration declare-not-at-head-of-body, declaim-inside-body, type-declaration-contradicts-initform, the-form-with-impossible-type, type-declaration-on-rest-parameter

Every Fennel/Janet rule is grounded in the language's own tooling rather than in taste: Fennel ships a linter plugin asserting exactly var-never-set (src/linter.fnl:80-82, pinned by its own test-var-never-set), and Janet's boot.janet:626-629 has check-empty-body calling maclintf "empty loop body" on precisely the three heads janet-empty-loop-body uses.

Six proposals dropped against a running implementation

  • A late declaim ftype is not lost. SBCL flagged a later bad call identically to the early-declaim version, so the rule had nothing to detect. The only real effect is the body's own conflict dropping from WARNING to STYLE-WARNING — which SBCL already reports.
  • (declare (special *x*)) with no defvar compiles silently and is the ordinary way to reference a cross-file special. Not soundly decidable at file scope.
  • (optimize (safety 0)) dropped rather than shipped on alarm value — SBCL's own constraint.lisp sets it deliberately in a locally, with a comment explaining why.
  • ignore-declared-variable-then-used was a true duplicate of lint-convention's ignore_declaration_conflict — same diagnosis, same category, same Fixability, literally the same worked example.
  • fennel-global-set-without-global is a compile error, not a lint (compiler.fnl:676 assert-compile … "expected var <x>").
  • janet-def-shadows-core would have been this batch's zero-true-positive rule — 71 core-name definitions across 241 files, the majority inside Janet's own core, which the rule cannot distinguish from shadowing it. janet-string-concat-in-loop was dropped as a duplicate of lint-performance's quadratic-accumulation after reading its body: the same loop-keyed, self-referencing-accumulator analysis, differing only in vocabulary tables. The right fix there is a dialect table on that rule, not a twin here.

One premise was refuted upward: declare-not-at-head-of-body was proposed as a warning, but SBCL errorscaught ERROR: There is no function named DECLARE. Hence Severity::Error and Malformed.

Corpus audits, and what they cost

288 .fnl and 241 .janet files from ten third-party projects; 2217 CL files with 21239 declare and 3979 declaim occurrences. Both found real false positives and both were fixed:

  • var-never-set lost 10 findings to project-local macros that expand to set (tangerine.nvim's append!, jpm's setfn). Now suppressed via a macro-vocabulary read from the file's own macro bindings, guarded by symmetric controls so the suppression can't widen.
  • The type rules lost all 16 of their first-pass findings: CLHS 3.2.3.1 (a top-level locally body is a top-level context), a reader conditional as the head shifting every child index, #. docstrings, and Lisp-2 operator position.

Two measurement traps worth recording. A first sweep reported zero findings because an invalid --emit json made every batch error out — a false clean. And is_unevaluated_at descends from the file root, so a linear scan there cost one pass over every top-level form per finding: 646 ms at 500 reporting definitions, now binary-searched.

Carried forward, not fixed here

  • Janet's backtick long strings are unimplemented in classify_janet (packages/core/syntax/src/sexpr/reader_policy.rs), so ~21% of real Janet files get a wrong tree — 9 fail to parse outright including Janet's own src/boot/boot.janet, and 41 more parse at exit 0 with phantom nodes inside string bodies. Triple-backtick docstrings are the dominant Janet idiom, so this is not an edge case.
  • The shipped ignore-declaration-conflict produces 21 apparent false positives over SBCL's sources (quoted templates, shadowing, Lisp-2 operator position, macro arguments). Corrected guards are already written in lint-type-declaration's support.rs, ready to lift across.

Both are recorded in the packages' READMEs and in the registry provenance comment so they don't get lost.

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 10 fire through the real binary, parsing the JSON findings array — var-never-set on both .fnl and .janet, confirming its two-dialect scope reaches both. Sweeping tests/fixtures/** produced exactly one new finding and it is a true positive: tests/fixtures/corpus/janet.janet:41 (var acc @[]), never reassigned — array/push mutates the array object, not the binding, so def is correct. All 12 goldens are insertions only (848 insertions, 0 deletions).

Mutation testing: Fennel/Janet 33 of 34 killed (the survivor is a documented pure-performance guard, and one mutation exposed a missing test, now added); type-declaration 8 of 8 live. One guard kills only its own unit test — traced and documented as correct-but-behaviourally-inert rather than quietly deleted.

@takeokunn
takeokunn force-pushed the feat/lint-batch-r8 branch from dc484a2 to afc6ca2 Compare August 3, 2026 07:19
Base automatically changed from feat/lint-batch-r7 to main August 3, 2026 07:41
RULE_COUNT 303 -> 313. Both packages are registry-only, so no new
commands and the dialect-matrix lists do not move.

lint-fennel-janet-idiom (5): var-never-set (Fennel and Janet),
fennel-deprecated-form, fennel-each-over-non-iterator,
janet-empty-loop-body, janet-mutating-immutable-literal. Every rule
here is grounded in the language's own tooling rather than in taste:
Fennel ships a linter plugin asserting exactly `var-never-set`, and
Janet's boot.janet has `check-empty-body` calling `maclintf` on the
three heads `janet-empty-loop-body` uses.

lint-type-declaration (5): declare-not-at-head-of-body,
declaim-inside-body, type-declaration-contradicts-initform,
the-form-with-impossible-type, type-declaration-on-rest-parameter.

Six proposals were dropped against SBCL 2.6.0 rather than shipped:

- A late `declaim ftype` is *not* lost -- SBCL flagged a later bad call
  identically to the early-declaim version, so the rule had nothing to
  detect.
- `(declare (special *x*))` with no defvar compiles silently and is the
  ordinary way to reference a cross-file special, so it is not soundly
  decidable at file scope.
- `(optimize (safety 0))` was dropped rather than shipped on alarm
  value: SBCL's own constraint.lisp sets it deliberately.
- `ignore-declared-variable-then-used` was a true duplicate of
  lint-convention's ignore-declaration-conflict, down to the same
  worked example.

Two Fennel/Janet proposals died the same way. `fennel-global-set-without
-global` is a compile error, not a lint. `janet-def-shadows-core` would
have been this batch's zero-true-positive rule: 71 core-name definitions
across 241 files, the majority inside Janet's own core, which the rule
cannot distinguish from shadowing it. `janet-string-concat-in-loop` was
dropped as a duplicate of lint-performance's quadratic-accumulation
after reading its body -- same loop-keyed, self-referencing-accumulator
analysis, differing only in vocabulary tables. The right fix there is a
dialect table on that rule, not a twin.

Corpus audits: 288 .fnl and 241 .janet files from ten third-party
projects, and 2217 CL files with 21239 `declare` and 3979 `declaim`
occurrences. Both found real false positives and both were fixed.
`var-never-set` lost 10 findings to project-local macros expanding to
`set`; the type rules lost all 16 of their first-pass findings, to
CLHS 3.2.3.1 (a top-level `locally` body *is* a top-level context),
reader conditionals as the head shifting every index, `#.` docstrings,
and Lisp-2 operator position.

Two measurement traps worth recording. A first sweep reported zero
findings because an invalid `--emit json` made every batch error out --
a false clean. And `is_unevaluated_at` descends from the file root, so a
linear scan there cost one pass over every top-level form per finding:
646ms at 500 reporting definitions, now binary-searched.

Carried forward, not fixed here: Janet's backtick long strings are
unimplemented in classify_janet, so ~21% of real Janet files get a wrong
tree -- 9 fail outright including Janet's own boot.janet, and 41 more
parse at exit 0 with phantom nodes inside string bodies. And the shipped
`ignore-declaration-conflict` produces 21 apparent false positives over
SBCL's sources; corrected guards are in lint-type-declaration's
support.rs ready to lift across. Both are recorded in the packages'
READMEs and in the registry provenance comment.
A later pass fixed the rule and measured it properly: 45 findings, not
21, and 2 of them are true positives, so "all false positives" was
wrong. The four classes recorded here are only 17 of the 43 real ones --
destructuring macro lambda lists (21) and supplied-p variables (5) were
missed entirely, and neither is a body-walk bug. The rule could not read
a lambda list at all.
@takeokunn
takeokunn force-pushed the feat/lint-batch-r8 branch from 6dd3657 to 0c76a08 Compare August 3, 2026 07:41
@takeokunn
takeokunn merged commit 93fda33 into main Aug 3, 2026
10 checks passed
@takeokunn
takeokunn deleted the feat/lint-batch-r8 branch August 3, 2026 08:07
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