lint-rule-development
Purpose
Use this skill when creating new lint rules or assist actions for Biome. It provides scaffolding commands, implementation patterns, testing workflows, and documentation guidelines.
Prerequisites
- Install required tools:
just install-tools - Ensure
cargo,just, andpnpmare available - Read
crates/biome_analyze/CONTRIBUTING.mdfor in-depth concepts
Common Workflows
Create a New Lint Rule
Generate scaffolding for a JavaScript lint rule:
just new-js-lintrule useMyRuleName
For other languages:
just new-css-lintrule myRuleName
just new-json-lintrule myRuleName
just new-graphql-lintrule myRuleName
This creates a file in crates/biome_js_analyze/src/lint/nursery/use_my_rule_name.rs
Implement the Rule
Basic rule structure (generated by scaffolding):
use biome_analyze::{context::RuleContext, declare_lint_rule, Rule, RuleDiagnostic};
use biome_js_syntax::JsIdentifierBinding;
use biome_rowan::AstNode;
declare_lint_rule! {
/// Disallows the use of prohibited identifiers.
pub UseMyRuleName {
version: "next",
name: "useMyRuleName",
language: "js",
recommended: false,
}
}
impl Rule for UseMyRuleName {
type Query = Ast<JsIdentifierBinding>;
type State = ();
type Signals = Option<Self::State>;
type Options = ();
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let binding = ctx.query();
// Check if identifier matches your rule logic
if binding.name_token().ok()?.text() == "prohibited_name" {
return Some(());
}
None
}
fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
let node = ctx.query();
Some(
RuleDiagnostic::new(
rule_category!(),
node.range(),
markup! {
"Avoid using this identifier."
},
)
.note(markup! {
"This identifier is prohibited because..."
}),
)
}
}
Using Semantic Model
For rules that need binding analysis:
use crate::services::semantic::Semantic;
impl Rule for MySemanticRule {
type Query = Semantic<JsReferenceIdentifier>;
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();
let model = ctx.model();
// Check if binding is declared
let binding = node.binding(model)?;
// Get all references to this binding
let all_refs = binding.all_references(model);
// Get only read references
let read_refs = binding.all_reads(model);
// Get only write references
let write_refs = binding.all_writes(model);
Some(())
}
}
Add Code Actions (Fixes)
To provide automatic fixes:
use biome_analyze::FixKind;
declare_lint_rule! {
pub UseMyRuleName {
version: "next",
name: "useMyRuleName",
language: "js",
recommended: false,
fix_kind: FixKind::Safe, // or FixKind::Unsafe
}
}
impl Rule for UseMyRuleName {
fn action(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<JsRuleAction> {
let node = ctx.query();
let mut mutation = ctx.root().begin();
// Example: Replace the node
mutation.replace_node(
node.clone(),
make::js_identifier_binding(make::ident("replacement"))
);
Some(JsRuleAction::new(
ctx.metadata().action_category(ctx.category(), ctx.group()),
ctx.metadata().applicability(),
markup! { "Use 'replacement' instead" }.to_owned(),
mutation,
))
}
}
Quick Testing
Use the quick test for rapid iteration:
// In crates/biome_js_analyze/tests/quick_test.rs
// Uncomment #[ignore] and modify:
const SOURCE: &str = r#"
const prohibited_name = 1;
"#;
let rule_filter = RuleFilter::Rule("nursery", "useMyRuleName");
Run the test:
cd crates/biome_js_analyze
cargo test quick_test -- --show-output
Create Snapshot Tests
Create test files in tests/specs/nursery/useMyRuleName/:
tests/specs/nursery/useMyRuleName/
├── invalid.js # Code that triggers the rule
├── valid.js # Code that doesn't trigger the rule
└── options.json # Optional rule configuration
Every test file must start with a top-level comment declaring whether it expects diagnostics. The test runner enforces this — see the testing-codegen skill for full rules. The short version:
valid.js — comment is mandatory (test panics without it):
/* should not generate diagnostics */
const x = 1;
const y = 2;
invalid.js — comment is strongly recommended (also enforced when present):
/* should generate diagnostics */
const prohibited_name = 1;
const another_prohibited = 2;
Run snapshot tests:
just test-lintrule useMyRuleName
Review snapshots:
cargo insta review
Generate Analyzer Code
During development, use the lightweight codegen commands:
just gen-rules # Updates rule registrations in *_analyze crates
just gen-configuration # Updates configuration schemas
These generate enough code to compile and test your rule without errors.
For full codegen (migrations, schema, bindings, formatting), run:
just gen-analyzer
Note: The CI autofix job runs gen-analyzer automatically when you open a PR, so running it locally is optional.
Format and Lint
Before committing:
just f # Format code
just l # Lint code
Adding Configurable Options
When a rule needs user-configurable behavior, add options via the biome_rule_options crate.
For the full reference (merge strategies, design guidelines, common patterns), see
references/OPTIONS.md.
Quick workflow:
Step 1. Define the options type in biome_rule_options/src/<snake_case_rule_name>.rs:
use biome_deserialize_macros::{Deserializable, Merge};
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Serialize, Deserialize, Deserializable, Merge)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
pub struct UseMyRuleNameOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub behavior: Option<MyBehavior>,
}
Step 2. Wire it into the rule:
use biome_rule_options::use_my_rule_name::UseMyRuleNameOptions;
impl Rule for UseMyRuleName {
type Options = UseMyRuleNameOptions;
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let options = ctx.options();
let behavior = options.behavior.unwrap_or_default();
// ...
}
}
Step 3. Test with options.json in the test directory (see references/OPTIONS.md for examples).
Step 4. Run codegen: just gen-rules && just gen-configuration
Key rules:
- All fields must be
Option<T>for config merging to work - Use
Box<[Box<str>]>instead ofVec<String>for collection fields - Use
#[derive(Merge)]for simple cases, implementMergemanually for collections - Only add options when truly needed (conflicting community preferences, multiple valid interpretations)
Tips
- Rule naming: Use
no*prefix for rules that forbid something (e.g.,noVar),use*for rules that mandate something (e.g.,useConst) - Nursery group: All new rules start in the
nurserygroup - Semantic queries: Use
Semantic<Node>query when you need binding/scope analysis - Multiple signals: Return
Vec<Self::State>orBox<[Self::State]>to emit multiple diagnostics - Safe vs Unsafe fixes: Mark fixes as
Unsafeif they could change program behavior - Check for globals: Always verify if a variable is global before reporting it (use semantic model)
- Error recovery: When navigating CST, use
.ok()?pattern to handle missing nodes gracefully - Testing arrays: Use
.jsoncfiles with arrays of code snippets for multiple test cases
Common Query Types
// Simple AST query
type Query = Ast<JsVariableDeclaration>;
// Semantic query (needs binding info)
type Query = Semantic<JsReferenceIdentifier>;
// Multiple node types (requires declare_node_union!)
declare_node_union! {
pub AnyFunctionLike = AnyJsFunction | JsMethodObjectMember | JsMethodClassMember
}
type Query = Semantic<AnyFunctionLike>;
References
- Full guide:
crates/biome_analyze/CONTRIBUTING.md - Rule examples:
crates/biome_js_analyze/src/lint/ - Semantic model: Search for
Semantic<in existing rules - Testing guide: Main
CONTRIBUTING.mdtesting section