Add a new built-in lint rule to the arity linter—implement it against the single-walk dispatch, register it in the one source of truth, add TDD fixtures plus the autofix-correctness (parse-clean) case, and wire up the snapshot-pinned generated docs and the two hand-maintained book indexes.
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
Add a new built-in lint rule to the arity linter—implement it against the single-walk dispatch, register it in the one source of truth, add TDD fixtures plus the autofix-correctness (parse-clean) case, and wire up the snapshot-pinned generated docs and the two hand-maintained book indexes.
Use this skill when asked to add a new built-in lint rule (correctness,
suspicious, readability, performance, ...), whether or not it ships an auto-fix.
The roadmap of planned rules lives in TODO.md under "Rule roadmap"—when
the request names a roadmap item, follow its category/cost/safety annotation and
check the item off when done.
Tenets that constrain a rule (from AGENTS.md)
Tenet 1—formatting is the formatter's job. Pure layout (quotes,
leading zeros, spacing, indentation, semicolons, trailing whitespace) is out
of scope for the linter. If the "rule" is really a formatting preference, it
does not belong here.
Tenet 3—parsing is the parser's job. Do not paper over parser mistakes
in a rule. If the CST does not expose what you need, add a typed wrapper in
crates/arity-parser/src/ast/nodes.rs (re-exported from the parser crate's
ast module, reachable as crate::ast) rather than re-lexing or
re-parsing inside the rule.
Autofix correctness (Tenet 1 corollary)—a fix is a textual edit, never a
formatter. The bar is correctness, not formatting: applying the fix must
leave code that still parses and stays lossless (no misbinding
!a + b, no dropped comments). Make the edit correct by construction (tight
span, atom-guarded, whitespace-preserving), or withhold the fix for that
shape (still report the finding). A fix does not owe line-width—layout
is the formatter's job and the pipeline is fix-then-format; never run the
formatter inside --fix. Enforced by
tests/lint.rs::fixed_output_is_parseable_and_clean (the curated cases are
width-safe, so they also stay format-clean).
Cost model (drives which infra you may touch)
A rule is cheap (syn) when it only needs CST + AST + literal inspection;
medium (ns) when it must confirm a callee resolves to base R (not a user
redefinition) via RuleContext::resolves_to_base; expensive (sem) when it
needs the SemanticModel (scopes/flow). Anything needing R evaluation or type
inference is out of scope—arity stays static. Prefer the cheapest tier
the rule's correctness actually requires.
Key files
src/linter/rules.rs—the Rule trait, RuleContext, and
rules_by_category(), the single source of truth. all_rules() flattens
it, all_rule_ids() derives from that, and the generated rule reference takes
its category sections from the same grouping—so there is no second list to
sync. Every new rule is added here exactly once, wrapped in AnyRule::R.
A DESCRIPTION rule implements DcfRule instead (interests/check
over dcf::SyntaxKind, or check_file; DcfRuleContext carries the parsed
Document, the DescriptionFacts folded from it, and the enclosing package's
PackageUsage), registers as AnyRule::Dcf in that same list, and is
tested in tests/lint_description.rs. run_dcf_rules is the driver twin.
Never add a second registry.
src/linter/rules/<category>.rs—the category module
(correctness/suspicious/readability/performance/documentation/
packaging/meta). Holds mod <id>; + pub use <id>::<Name>;.
src/linter/rules/<category>/<id>.rs—one file per rule: a unit
pub struct implementing Rule. (File name may differ from the rule id when
the id is a Rust keyword, e.g. repeat lives in suspicious/repeat_loop.rs.)
src/linter/rules/matchers.rs—reusable CST/AST shape matchers
(call_named, callee_name, args/nth_arg/named_arg, binary_parts,
literal classifiers is_true/is_false/is_na/is_null/is_bool_symbol,
element_text, is_atom). Add a
matcher here when a shape recurs across rules.
Workflow
Pick the rule id (kebab-case). This is the public id(), the --select/
--ignore key, and the docs slug—unique and stable; renaming is a
breaking change. Match existing tone (redundant-ifelse,
true-false-symbol). Public ids stay flat kebab-case even though the file
lives under a category dir.
Decide gating and safety before writing code:
Category = directory (correctness/suspicious/readability/...); it
does not appear in the id.
Severity—set per-Diagnostic (rules push severity: Severity::...
directly; Warning is the norm, Error only for genuine bugs).
default_enabled()—default true; override to false for noisy
opt-in rules (undesirable-function, unused-function, ...).
Dispatch shape—node-shape rules declare interests() (a slice of
SyntaxKind) and implement check(el, ctx, sink), called once per
matching element in the one shared descendants_with_tokens() walk.
Model-/ comment-driven rules leave interests() empty and override
check_file(ctx, sink) (a once-per-file pass). Never walk the CST
yourself from a node-shape rule.
Fix safety—ship Fix::safe only when the edit is unambiguous and
correct (parse-clean, lossless) by construction; otherwise Fix::unsafe_
(applied only under --unsafe-fixes), or withhold the fix entirely and
still report (autofix correctness). A negating/dropping rewrite needs an
atom operand—guard with matchers::is_atom (see redundant-ifelse,
redundant-equals).
Write the failing test first (TDD per AGENTS.md) in tests/lint.rs:
Positive case via fixed_output/fixed_output_all (asserts the fix
output) or filtered by .
Dos and don'ts
Do reach for matchers::* and typed AST wrappers before raw CST walking;
add a matcher when a shape recurs.
Do keep spans tight and fixes parse-clean/lossless by construction, or
withhold.
Do make examples() snippets that genuinely trigger the rule—the docs
tests reject a plausible-but-inert example.
Don't add a second registration list or an if-guard;
rules_by_category() is the only list.
Don't implement a formatting/layout preference as a lint rule (Tenet 1).
Don't work around a parser/CST gap inside the rule (Tenet 3)—fix or
extend the parser/AST instead.
Don't run the formatter inside a fix (Tenet 1: the formatter is the sole
layout authority), or ship a fix that produces broken or lossy code (autofix
correctness). A fix needn't satisfy line-width—that's the formatter's job.
Don't hand-edit docs/src/reference/rules.md—it is generated whole by
docgen; edit description()/examples() and regenerate.
Report-back format
When done, report:
Rule id, category, severity, and whether it ships a safe/unsafe fix (or
none).
Cost tier (syn/ns/sem) and default_enabled.
New files (rule module) and updated files (<category>.rs, rules.rsrules_by_category(), tests/lint.rs, the regenerated rules.md +
accepted snapshot, TODO.md).
Targeted test names plus the autofix-correctness case added.
crates/arity-parser/src/ast/nodes.rs—typed AST wrappers (CallExpr,
IfExpr, WhileExpr, BinaryExpr, ...);
crates/arity-parser/src/syntax.rs—SyntaxKind/SyntaxElement (both
re-exported at crate::ast/crate::syntax in the root crate).
src/semantic.rs—SemanticModel (idents(), resolve_local,
loaded_packages) for sem-tier rules.
tests/lint.rs—integration tests + helpers: diagnostics(src),
fixed_output(src, rule) (single safe fix applied),
fixed_output_all(src, rule) (all of a rule's fixes), and the
autofix-correctness harness assert_fixed_output_is_clean and
fixed_output_is_parseable_and_clean.
examples/docgen.rs—generates the whole rule reference
docs/src/reference/rules.md from render_rules_page (and stamps
version.md). Run with cargo run --example docgen. The page is generated
end to end—index included—so there is no index to hand-edit.
tests/rule_docs.rs—pins each rule's rendered section via an insta
snapshot (rule_docs_render), checks the assembled page covers every rule
in registry order, and asserts every rule has a non-empty
description() + at least one examples() entry, and that each example
actually triggers its own rule. Accept new snapshots with
cargo insta accept. A rule whose subject is a package-level fact is silent
on the single-file path the renderer uses, so it declares the synthetic
package its examples live in via doc_package.
docs/src/reference/rules.md—generated; never hand-edit it, and never
add a SUMMARY.md entry for a rule (the reference is one page).
TODO.md—the live roadmap. Check off the rule's item (with a one-line
note on scope/safety, mirroring the other landed entries).
diagnostics(src)
d.rule == "<id>"
Negative ("should not flag") case, and any edge the rule explicitly handles
(e.g. fix withheld on a complex operand/commented clause—assert
d.fix.is_none()).
Add the canonical shape(s) to the cases array in
fixed_output_is_parseable_and_clean so autofix correctness is checked
(keep the shapes width-safe). Run it and watch it fail before implementing.
Implement the rule in src/linter/rules/<category>/<id>.rs:
Module doc comment explaining what it flags, why, and any safety reasoning.
Cast the dispatched element with the typed wrapper (el.as_node() →
CallExpr::cast/WhileExpr::cast, or el.as_token()), then use
matchers::* for the shape checks.
Keep the diagnostic span tight—point at the offending construct,
not the whole statement (it drives the CLI caret and LSP underline).
For ns-tier rules, gate the bare-name rewrite behind
ctx.resolves_to_base(&call) so a user redefinition, namespaced, or
shadowed callee is not rewritten.
Required for docs to pass: implement description() (non-empty) and
examples() (≥1 Example whose source actually triggers the rule).
mod <id>; + pub use <id>::<Name>; in src/linter/rules/<category>.rs.
One Box::new(<category>::<Name>) line in its category's list in
rules_by_category() (src/linter/rules.rs), in roadmap order. Nothing
else—all_rules(), --select/--ignore validation, and the reference
page's category sections all derive from that list.
Generate and pin the docs:
cargo run --example docgen → rewrites docs/src/reference/rules.md
with the new rule's section and index entry in place.
cargo test --test rule_docs will fail on the new snapshot; eyeball the
.snap.new (the example must show the finding and, if fixable, the
after-fix block), then cargo insta accept.
Update TODO.md—check off the item and add a one-line scope/safety
note matching the other landed entries.
Validate in order:
Targeted: cargo test --test lint <name>, cargo test --test rule_docs.