Biome 23 🧹 Plugins with GritQL — Custom rules and diagnostics
The GritQL plugin is the mechanism that lets a project add its own lint rules without waiting for the Biome team to add them. The plugin is a .grit file that declares a structural pattern, and the pattern matches against the syntax tree, not the raw text. The Biome documentation states the model plainly: the plugin “can match specific code patterns, report customized diagnostics, and suggest fixable rewrites” . The GritQL was created by Grit, and Biome adopted the language as the plugin engine in Biome 2.0 . The plugin is the escape hatch for the domain-specific rule—the internal API restriction, the design-system convention, the project-specific pattern—that the built-in rules do not cover.
The plugin’s structure has the two required headers and the pattern. The engine biome(1.0) tells the Grit engine to use Biome’s syntax tree rather than the Tree-sitter’s, and the language js(jsx) or language css sets the target language . The register_diagnostic() function is the only Biome-specific addition, and the function is the mechanism that turns the matched node into the reported diagnostic. The fix_kind argument is the optional, and the => rewrite operator is the mechanism that provides the automatic fix.
Key point: A GritQL plugin is a .grit file with the engine biome(1.0) and the language headers, a structural pattern, and the register_diagnostic() call. The plugin is registered in the plugins array of the biome.json. The includes option scopes the plugin to the specific files. The fix_kind and the => operator provide the automatic fix, and the // biome-ignore lint/plugin: reason comment suppresses the diagnostic.
Why the GritQL plugins exist
The custom-rule problem. The Biome built-in rules cover the common cases, and the project has the specific conventions that the built-in rules cannot express—the internal API that must not be imported, the design-system component that must be used, the project-specific pattern. The GritQL plugin is the mechanism that adds the custom rule without the fork of the Biome source, and the plugin is the reason the project can enforce its conventions with the same tool .
The structural-matching problem. The regex-based lint rules match the text, and the text matching is fragile—the formatting change breaks the regex, the nested structure is the hard to express, and the semantic meaning is the invisible. The GritQL matches the syntax tree, and the AST matching is the mechanism that the rule is resilient to the formatting and the precise to the structure . The `console.log($msg)` is the code snippet that matches structurally, and the $msg is the metavariable that captures the argument.
The fix problem. The plugin can suggest the rewrite with the => operator, and the rewrite is the mechanism that the diagnostic is the actionable. The $call => console.info($msg)“ is the mechanism that replaces the console.log with the console.info, and the fix_kind = "safe" is the mechanism that the biome check --write applies the fix . The fix_kind omitted is the unsafe, and the unsafe requires the --write --unsafe flag.
The distribution problem. The plugin is the single .grit file, and the file is the mechanism that the rule is the shareable. The @aliou/biome-plugins package is the collection of the GritQL plugins, and the package is the mechanism that the community shares the rules . The package’s plugins are the no-inline-imports, the no-interpolated-classname, the phosphor-icon-suffix, and the others, and the collection is the reason the plugin ecosystem is the growing.
The experimental problem. The Biome documentation states the plugin system is the experimental. The limitations include the no automatic npm package resolution—the plugin paths must be the explicit relative paths to the node_modules . The range patterns are not implemented, and the $program and the $body do not include the comments . The limitations are the reason the plugin is the careful choice, and the choice is the trade-off against the built-in rule.
a. The plugin structure and the register_diagnostic
The .grit file starts with the two headers, and the headers are the requirement. The engine biome(1.0) and the language js(jsx) are the mechanism that the Grit engine uses the Biome AST.
// The plugin structure
engine biome(1.0)
language js(jsx)
// The pattern matches the Object.assign call.
`$fn($args)` where {
// The $fn must be the Object.assign.
$fn <: `Object.assign`,
// The diagnostic is registered on the matched node.
register_diagnostic(
span = $fn,
message = "Prefer object spread instead of `Object.assign()`"
)
}
The engine biome(1.0) is the mechanism that the Grit engine uses the Biome’s syntax tree . The `$fn($args)` is the code snippet that matches the call expression, and the $fn <: Object.assign“ is the constraint that the callee is the Object.assign . The register_diagnostic(span = $fn, message = "...") is the mechanism that the diagnostic is reported on the $fn node.
The severity argument is the optional. The severity = "warn" is the mechanism that the diagnostic is the warning rather than the error . The fix_kind is the second optional, and the fix_kind = "safe" is the mechanism that the biome check --write applies the fix.
// The plugin with the severity and the fix
engine biome(1.0)
language js(jsx)
`console.log($msg)` as $call where {
register_diagnostic(
span = $call,
message = "Use console.info instead of console.log.",
severity = "warn",
fix_kind = "safe"
),
// The rewrite replaces the console.log with the console.info.
$call => `console.info($msg)`
}
The severity = "warn" is the mechanism that the diagnostic is the warning . The $call => console.info($msg)“ is the mechanism that the fix replaces the call, and the fix_kind = "safe" is the mechanism that the --write applies it. The rewrite is the separate from the diagnostic, and the separate is the reason the fix is the optional.
The includes option in the biome.json is the mechanism that the plugin is scoped to the specific files. The includes: ["src/components/**"] is the mechanism that the plugin runs only on the components, and the scope is the reason the rule is the targeted .
// biome.json — the plugin with the includes
{
"plugins": [
{
"path": "./plugins/no-console.grit",
"includes": ["src/components/**"]
}
]
}
The includes: ["src/components/**"] is the mechanism that the plugin is the scoped. The !src/**/*.test.ts is the mechanism that the test files are the excluded, and the exclusion is the reason the rule does not fire on the tests .
b. The TypeScript plugins and the limitations
The Biome 2.x added the TypeScript plugin support, and the plugin is the .ts file that exports the defineRule object. The query and the run are the two fields, and the run is the function that the custom logic runs.
// The TypeScript plugin
import { defineRule, grit, registerDiagnostic } from "@biomejs/runtime/plugin";
export const noFooThenBar = defineRule({
// The query selects the nodes.
query: grit("`foo(); bar()`"),
// The run is the custom logic.
run(node) {
registerDiagnostic(node, "error", "Don't call foo() immediately before bar().");
},
});
The defineRule is the mechanism that the plugin is the object, and the query is the mechanism that the nodes are the selected . The run(node) is the mechanism that the custom logic runs on the matched node, and the registerDiagnostic(node, "error", "message") is the mechanism that the diagnostic is the reported.
The semantic: true option is the mechanism that the ctx.model is the available. The ctx.model is the semantic model, and the model is the mechanism that the rule can inspect the bindings and the scopes .
// The semantic query
export const noConsole = defineRule({
query: grit("`console.log($args)`", { semantic: true }),
run(node, { model }) {
// The model is the semantic model.
// The model is the mechanism that the rule can inspect the bindings.
registerDiagnostic(node, "error", "Don't use console.log.");
},
});
The { semantic: true } is the mechanism that the ctx.model is the available . The model is the semantic model, and the model is the reason the rule can do the binding-aware checks.
The limitations are the separate concern. The range patterns are not implemented, and the $program and the $body do not include the comments . The fix_kind and the => are the supported, and the plugin distribution is the explicit path—the no automatic npm package resolution . The limitations are the reason the plugin is the careful choice.
Complete Example Session
// ============================================
// PART 1: THE BASIC PLUGIN
// ============================================
engine biome(1.0)
language js(jsx)
`$fn($args)` where {
$fn <: `Object.assign`,
register_diagnostic(
span = $fn,
message = "Prefer object spread instead of `Object.assign()`"
)
}
// The plugin reports the Object.assign usage.
// ============================================
// PART 2: THE FIXABLE PLUGIN
// ============================================
engine biome(1.0)
language js(jsx)
`console.log($msg)` as $call where {
register_diagnostic(
span = $call,
message = "Use console.info instead of console.log.",
severity = "warn",
fix_kind = "safe"
),
$call => `console.info($msg)`
}
// The plugin suggests the console.info fix.
// ============================================
// PART 3: THE CSS PLUGIN
// ============================================
language css;
`$selector { $props }` where {
$props <: contains `color: $color` as $rule,
not $selector <: r"\.color-.*",
register_diagnostic(
span = $rule,
message = "Don't set explicit colors. Use `.color-*` classes instead."
)
}
// The plugin reports the explicit colors in the CSS.
// ============================================
// PART 4: THE PLUGIN REGISTRATION
// ============================================
// biome.json
{
"plugins": ["./plugins/no-object-assign.grit"]
}
// The plugin runs on every file.
// ============================================
// PART 5: THE SCOPED PLUGIN
// ============================================
// biome.json
{
"plugins": [
{
"path": "./plugins/no-console.grit",
"includes": ["src/components/**"]
}
]
}
// The plugin runs only on the components.
// ============================================
// PART 6: THE NEGATIVE INCLUDES
// ============================================
// biome.json
{
"plugins": [
{
"path": "./plugins/no-console.grit",
"includes": ["src/**/*.ts", "!src/**/*.test.ts"]
}
]
}
// The plugin runs on the ts files except the tests.
// ============================================
// PART 7: THE SUPPRESSION
// ============================================
// The plugin diagnostic is suppressed with the lint/plugin comment.
// biome-ignore lint/plugin: the console.log is the intentional
console.log("debug");
// ============================================
// PART 8: THE TYPESCRIPT PLUGIN
// ============================================
import { defineRule, grit, registerDiagnostic } from "@biomejs/runtime/plugin";
export const noFooThenBar = defineRule({
query: grit("`foo(); bar()`"),
run(node) {
registerDiagnostic(node, "error", "Don't call foo() immediately before bar().");
},
});
// The TypeScript plugin is the alternative to the GritQL.
// ============================================
// PART 9: THE LIMITATION
// ============================================
// The range patterns are not implemented.
// The $program and the $body do not include the comments.
// The npm package resolution is the explicit path.
// ============================================
// PART 10: THE CHECKLIST
// ============================================
// [ ] The .grit file has the engine and the language headers.
// [ ] The pattern matches the target code.
// [ ] The register_diagnostic has the span and the message.
// [ ] The fix_kind is set for the fixable rule.
// [ ] The plugin is in the biome.json plugins array.
// [ ] The includes scopes the plugin if needed.
The ten parts covered the basic plugin, the fixable plugin, the CSS plugin, the plugin registration, the scoped plugin, the negative includes, the suppression, the TypeScript plugin, the limitation, and the checklist. The through-line is that the .grit file is the rule, and the register_diagnostic is the report.
Quick Reference
The plugin headers
| Header | Purpose |
|---|---|
engine biome(1.0) | Use the Biome AST |
language js(jsx) | The target language |
The register_diagnostic arguments
| Argument | Required | Purpose |
|---|---|---|
span | Yes | The node to underline |
message | Yes | The diagnostic text |
severity | No | hint, info, warn, error |
fix_kind | No | safe or unsafe |
The plugin registration
| Form | Example |
|---|---|
| The simple path | "plugins": ["./plugins/rule.grit"] |
| The scoped | "plugins": [{ "path": "./rule.grit", "includes": ["src/**"] }] |
The limitations
| Limitation | Detail |
|---|---|
| The range patterns | Not implemented |
| The comments | Not included in $program |
| The npm resolution | The explicit path |
Best Practices
✅ Do This:
// Use the engine and the language headers
engine biome(1.0)
language js(jsx) // ✅
// Use the register_diagnostic with the span and the message
register_diagnostic(span = $node, message = "Message") // ✅
// Use the fix_kind for the fixable rule
fix_kind = "safe" // ✅
// Scope the plugin with the includes
{ "path": "./rule.grit", "includes": ["src/**"] } // ✅
❌ Don’t Do This:
// Omit the headers
`$fn($args)` where { register_diagnostic(...) } // ❌
// Use the range pattern
range(start_line = 2, end_line = 2) // ❌ (not implemented)
// Use the npm package name
"plugins": ["@scope/plugin"] // the explicit path is required // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The plugin fails to load | The headers are missing | Add the engine and the language |
| The diagnostic is not reported | The span is missing | Add the span to the register_diagnostic |
| The fix is not applied | The fix_kind is unsafe | Use the fix_kind = "safe" and the --write |
| The plugin runs on the test files | The includes is missing | Add the includes with the negative |
Real-World Examples
1. The Object.assign plugin
`$fn($args)` where { $fn <: `Object.assign`, register_diagnostic(span = $fn, message = "Prefer object spread") }
2. The console.log plugin
`console.log($msg)` as $call where { register_diagnostic(span = $call, message = "Use console.info", fix_kind = "safe"), $call => `console.info($msg)` }
3. The CSS color plugin
language css; `$selector { $props }` where { $props <: contains `color: $color`, not $selector <: r"\.color-.*", register_diagnostic(span = $color, message = "Use .color-* classes") }
4. The plugin registration
{ "plugins": ["./plugins/no-object-assign.grit"] }
5. The scoped plugin
{ "plugins": [{ "path": "./plugins/no-console.grit", "includes": ["src/components/**"] }] }
6. The negative includes
{ "includes": ["src/**/*.ts", "!src/**/*.test.ts"] }
7. The suppression
// biome-ignore lint/plugin: the intentional
console.log("debug");
8. The TypeScript plugin
export const noFoo = defineRule({ query: grit("`foo()`"), run(node) { registerDiagnostic(node, "error", "Don't call foo()"); } });
9. The semantic query
query: grit("`console.log($args)`", { semantic: true })
10. The severity
register_diagnostic(span = $node, message = "Message", severity = "warn")
Visual
The Plugin Structure
┌──────────────────────────────────────────────────────────────┐
│ THE PLUGIN STRUCTURE │
│ │
│ The .grit file │
│ ┌─────────────────────┐ │
│ │ engine biome(1.0) │ The header │
│ │ language js(jsx) │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ The pattern │ The structural match │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ register_diagnostic │ The report │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The Plugin Registration
┌──────────────────────────────────────────────────────────────┐
│ THE PLUGIN REGISTRATION │
│ │
│ The biome.json │
│ ┌─────────────────────┐ │
│ │ plugins: │ The plugin path │
│ │ ["./rule.grit"] │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ The biome lint │ The plugin runs │
│ │ or the biome check │ │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The Fix
┌──────────────────────────────────────────────────────────────┐
│ THE FIX │
│ │
│ The plugin with the => rewrite │
│ ┌─────────────────────┐ │
│ │ fix_kind = "safe" │ The safe fix │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ biome check --write │ The fix is applied │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| The plugin file | The .grit extension |
| The headers | The engine biome(1.0) and the language |
| The report function | The register_diagnostic |
| The severity | The hint, the info, the warn, the error |
| The fix | The => rewrite and the fix_kind |
| The registration | The plugins array |
| The scope | The includes option |
| The suppression | The // biome-ignore lint/plugin |
Key takeaways:
- The GritQL plugin is the
.gritfile with the structural pattern. Theengine biome(1.0)and thelanguageheaders are the requirement, and the pattern matches against the AST rather than the raw text . The`console.log($msg)`is the code snippet that matches the call expression. - The
register_diagnostic()is the report mechanism. Thespanis the node to underline, and themessageis the diagnostic text. Theseverityand thefix_kindare the optional, and thefix_kindis relevant only with the=>rewrite . - The plugin is registered in the
pluginsarray. The simple path string is the workspace-wide, and the object with theincludesis the scoped. Theincludesglobs are the mechanism that the plugin runs only on the matched files . - The fix is the
=>rewrite with thefix_kind. Thefix_kind = "safe"is applied by thebiome check --write, and the omittedfix_kindis the unsafe, which requires the--write --unsafe. - The limitations are the experimental status. The range patterns are not implemented, and the
$programdoes not include the comments. The npm package resolution is the explicit path, and the plugin distribution is the ongoing discussion .
Remember: The Biome GritQL plugin is the custom rule. The .grit file has the engine biome(1.0) and the language headers, the structural pattern, and the register_diagnostic. The plugin is registered in the plugins array, and the includes scopes it. The fix_kind and the => provide the fix. The limitations are the experimental, and the explicit path is the distribution.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!