| 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 produces Diagnostic values and the
shared rendering paths handle presentation.
Key files
src/linter/rules.rs — Rule trait (note the required metadata() method),
the RuleMeta/DiagnosticCode/Requirement types, RuleRegistry, and the
pub mod list. Every new rule module is declared here.
src/linter/rules/<rule_name>.rs — one file per rule. Contains the
pub struct <Name>Rule plus its impl Rule (including metadata()) and unit
tests.
src/linter.rs — all_rules() lists every rule once; default_registry() is
data-driven: it filters all_rules() by each rule's
RuleMeta::{requires, default_on} and config.lint. There is no per-rule
if guard 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-exports SyntaxKind, SyntaxNode, and typed AST
wrappers from panache_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 by code and 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:
Warning is the default; Error only for genuinely broken
output; Info is reserved. A rule with several codes can mix severities;
declare each in RuleMeta::codes.
requires: the Requirement variant the rule needs
(Always, Footnotes, Citations, Emoji, FencedDivs,
FencedCodeAttributes, HeaderAttributes, TexMath, or ChunkFlavor).
Add a new variant (and its is_satisfied/doc-token mapping in
tests/linter_rules_docs.rs) only if no existing one fits.
default_on: true for rules that run unless disabled; false for opt-in
rules (registered only via is_rule_explicitly_enabled, documented with a
Default: Off field).
- Auto-fix: only ship a
Fix when 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. Set RuleMeta::auto_fix
accordingly.
-
Write a failing test first (TDD per AGENTS.md). Either:
- a unit test inside the new module under
#[cfg(test)] mod tests, using
crate::parser::parse(input, Some(config.clone())) and calling
Rule::check_tree(&tree, input, &config, metadata). check_tree is the
default trait method that builds a one-off LintIndex for just this
rule's declared interests and runs it — tests use it because
itself takes a , which the runner (not tests) constructs.
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. via crate::salsa::symbol_usage_index_from_tree),
not duplicated.
- Do respect ignore directives implicitly —
LintRunner::run_with_metadata
already filters by ignored ranges, so the rule emits unconditionally.
- Don't emit CLI strings, ANSI codes, or
eprintln! from a rule. Return
Diagnostic values 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
Requirement and default_on it declares in RuleMeta.
- New files (rule module, fixture) and updated files (
rules.rs, linter.rs
all_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).