name: add-lint-rule description: Add a new built-in lint rule to the Panache linter — wire it into the registry, gate it on the right extension/flavor, add a regression fixture with focused assertions, and document it.
Use this skill when asked to add a new built-in lint rule (warning, error, or info), regardless of whether it ships with an auto-fix.
Scope boundaries
- Built-in lint rules only. External-linter integrations (black, flake8, etc.)
live in
src/linter/external_linters*and are out of scope here. - Rule logic walks the parser CST/AST. Do not add parser- or formatter-side
workarounds. If the rule needs information the CST does not expose, surface it
through a typed wrapper in
crates/panache-parser/src/syntax/rather than re-parsing inside the rule. - LSP and CLI consume diagnostics through the same
LintRunner. The rule must not emit CLI-formatted strings; it producesDiagnosticvalues and the shared rendering paths handle presentation.
Key files
src/linter/rules.rs—Ruletrait (note the requiredmetadata()method), theRuleMeta/DiagnosticCode/Requirementtypes,RuleRegistry, and thepub modlist. Every new rule module is declared here.src/linter/rules/<rule_name>.rs— one file per rule. Contains thepub struct <Name>Ruleplus itsimpl Rule(includingmetadata()) and unit tests.src/linter.rs—all_rules()lists every rule once;default_registry()is data-driven: it filtersall_rules()by each rule'sRuleMeta::{requires, default_on}andconfig.lint. There is no per-ruleifguard to add.builtin_rule_metadata()exposes the metadata for tests.src/linter/diagnostics.rs—Diagnostic,Severity,Location,Edit,Fix,DiagnosticNoteKind. The full builder API for diagnostics.src/syntax.rs— re-exportsSyntaxKind,SyntaxNode, and typed AST wrappers frompanache_parser::syntax.tests/linting.rs+tests/linting/<rule_name>.{md,qmd,Rmd}— integration test fixtures. Pattern: a focused fixture file plus a#[test]that filters diagnostics bycodeand asserts count, span, and (if present) fix shape.docs/reference/linter-rules.qmd— the per-rule catalogue. Every rule needs a### \` {# } section.tests/linter_rules_docs.rscross-checks this file againstbuiltin_rule_metadata()` and fails the build if a rule, code, severity, auto-fix flag, default, or requirement drifts.docs/guide/linting.qmd— user-facing prose guide; links to the reference and lists the default[lint.rules]keys. Update the example key list there too.
Workflow
Pick the rule name (kebab-case) — this is the diagnostic
code, the config key under[lint.rules], and the slug used in URLs/help text. It must be unique and stable: renaming it is a breaking config change. Match tone of existing names (heading-hierarchy,duplicate-reference-labels,adjacent-footnote-refs).Decide gating before writing code — these become fields on the rule's
RuleMeta, the single source of truth for both registration and the docs:- Severity:
Warningis the default;Erroronly for genuinely broken output;Infois reserved. A rule with several codes can mix severities; declare each inRuleMeta::codes. requires: theRequirementvariant the rule needs (Always,Footnotes,Citations,Emoji,FencedDivs,FencedCodeAttributes,HeaderAttributes,TexMath, orChunkFlavor). Add a new variant (and itsis_satisfied/doc-token mapping intests/linter_rules_docs.rs) only if no existing one fits.default_on:truefor rules that run unless disabled;falsefor opt-in rules (registered only viais_rule_explicitly_enabled, documented with aDefault: Offfield).- Auto-fix: only ship a
Fixwhen the replacement is unambiguous and preserves intent. If multiple resolutions are valid (rename vs delete vs merge), omit the fix and explain why in the docs. SetRuleMeta::auto_fixaccordingly.
- Severity:
Write a failing test first (TDD per
AGENTS.md). Either:- a unit test inside the new module under
#[cfg(test)] mod tests, usingcrate::parser::parse(input, Some(config.clone()))and callingRule::check_tree(&tree, input, &config, metadata).check_treeis the default trait method that builds a one-offLintIndexfor just this rule's declared interests and runs it — tests use it becauseRule::checkitself takes a&LintContext, which the runner (not tests) constructs. or - an integration fixture under
tests/linting/<rule_name>.{md,qmd,Rmd}and a#[test]intests/linting.rsthat callslint_file(...)and filters byd.code == "<rule-name>". Cover the positive case, the negative ("should not flag") case, and any edge case the rule explicitly handles.
- a unit test inside the new module under
Implement the rule in
src/linter/rules/<rule_name>.rs:- Rules do not walk the tree themselves. The runner does one shared
tree.preorder_with_tokens()pass and buckets nodes bySyntaxKind; declare which kinds you want vianode_interests()and read your bucket withcx.nodes(KIND)instead oftree.descendants(). This keeps a lint pass at one traversal no matter how many rules exist. - Cast bucket nodes to typed wrappers where available
(
cx.nodes(SyntaxKind::LINK).iter().cloned().filter_map(Link::cast)) — typed wrappers are preferred wherever they exist. For multi-kind rules, list every kind innode_interests()and iterate each bucket. - To scan
TEXTtokens (e.g. byte-pattern checks), returntruefromwants_text_tokens()and iteratecx.text_tokens(). - Salsa-index-backed rules (those that use
symbol_usage_index_from_tree(.., cx.tree, ..)and never read a bucket) leavenode_interests()at its empty default. - Build
LocationwithLocation::from_range(range, input)orLocation::from_node(node, input). - For auto-fixes, prefer insertions (zero-width
TextRange::new(p, p)) and replacements over a precise span rather than rewriting whole nodes. Multi-edit fixes are allowed but must be independent — they are applied in source order. - Honor the trait shape exactly. Implement
metadata()(required), declare interests, then take a&LintContext(which bundlestree/input/config/metadata/index):fn metadata(&self) -> RuleMeta { RuleMeta { name: "<rule-name>", default_on: true, requires: Requirement::Always, auto_fix: false, codes: const { &[DiagnosticCode::warning("<rule-name>")] }, } } fn node_interests(&self) -> &'static [SyntaxKind] { &[SyntaxKind::LINK] // omit (defaults to &[]) for index-backed rules } fn check(&self, cx: &LintContext) -> Vec<Diagnostic> { let input = cx.input; // also cx.tree / cx.config / cx.metadata as needed // ... iterate cx.nodes(SyntaxKind::LINK) ... }codesis&'static [DiagnosticCode]; wrap the array in aconst { … }block (the::warning/::error/::infoconst constructors are not rvalue-promotable on their own). Import the new types alongside the trait:use crate::linter::rules::{DiagnosticCode, LintContext, Requirement, Rule, RuleMeta};.
- Rules do not walk the tree themselves. The runner does one shared
Wire it up (no
if-guard — registration is data-driven frommetadata()):- Add
pub mod <rule_name>;tosrc/linter/rules.rs(alphabetical, with the rest of thepub modlist). - Add one
Box::new(rules::<rule_name>::<Name>Rule)entry toall_rules()insrc/linter.rs.default_registry()filters that list by the rule'sRuleMeta::{requires, default_on}andconfig.lint, so the gating you declared in step 2 takes effect automatically — there is nothing else to edit. (Opt-out via[lint.rules]is handled centrally for every rule.)
- Add
Document in
docs/reference/linter-rules.qmd(enforced bytests/linter_rules_docs.rs):- New
### \` {# } section under "Rules", placed near thematically related rules. Use the existing definition-list shape:Severity,Auto-fix,Requirements(ifrequiresis notAlways), optionalDefault(sayOffwhendefault_onisfalse),Diagnostic codes,Description, then an**Example violation:**block, and (if auto-fixable) an**Auto-fix output:**` block. - Every
DiagnosticCodein the rule'smetadata()must appear in the section, the Severity field must name each severity emitted, and the Requirements field must mention the gating token — otherwise the consistency test fails. Multi-code rules get a#### \`` subsection per code. - If you reference the rule in the
docs/guide/linting.qmdexample[lint.rules]key list, keep that list in sync too (it is illustrative, not exhaustive, so this is optional).
- New
Validate in this order:
- Targeted:
cargo test --lib <rule_name>,cargo test --test linting <test_name>, andcargo test --test linter_rules_docs(catches docs/metadata drift). - CLI smoke check on a copy of the fixture:
cargo run --quiet -- lint /tmp/<fixture>.mdandcargo run --quiet -- lint --fix /tmp/<fixture>.md(verify the file contents after--fix). - Full:
cargo check --workspace,cargo test --workspace,cargo clippy --workspace --all-targets --all-features -- -D warnings,cargo fmt -- --check.
- Targeted:
Dos and don'ts
- Do keep diagnostic spans tight (point at the offending construct, not the whole line/paragraph) — this drives both the CLI caret and LSP underlines.
- Do put rule logic in the rule module. Shared cross-rule helpers belong
in
src/linter/(e.g. viacrate::salsa::symbol_usage_index_from_tree), not duplicated. - Do respect ignore directives implicitly —
LintRunner::run_with_metadataalready filters by ignored ranges, so the rule emits unconditionally. - Don't emit CLI strings, ANSI codes, or
eprintln!from a rule. ReturnDiagnosticvalues and let the renderer handle output. - Don't rely on lexically scanning
input. Walk the CST/AST. - Don't add a fix that changes prose semantics. If the user's intent is ambiguous, omit the fix.
- Don't rename an existing rule code to fix a typo without a migration plan — the code is part of the user-facing config surface.
Report-back format
When done, report:
- Rule name (code), severity, and whether it ships an auto-fix.
- The
Requirementanddefault_onit declares inRuleMeta. - New files (rule module, fixture) and updated files (
rules.rs,linter.rsall_rules(),linting.rs,linter-rules.qmd). - Targeted test names (including
linter_rules_docs) and CLI fix smoke-test outcome. - Full-suite validation results (
cargo test --workspace, clippy, fmt).