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 , and pnpm are available
-
Read crates/biome_analyze/CONTRIBUTING.md for 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 of Vec<String> for collection fields
-
Use #[derive(Merge)] for simple cases, implement Merge manually 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 nursery group
-
Semantic queries: Use Semantic<Node> query when you need binding/scope analysis
-
Multiple signals: Return Vec<Self::State> or Box<[Self::State]> to emit multiple diagnostics
-
Safe vs Unsafe fixes: Mark fixes as Unsafe if 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 .jsonc files 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.md testing section