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/
[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
for config merging to work Use Box<[Box ]> instead of Vec 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
- query when you need binding/scope analysis
- Multiple signals
-
- Return
- Vec
- 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