@berachain/biome-config
v0.1.0
Published
Shared Biome configuration (config.json) and custom GritQL plugins that enforce this repo's coding standards.
Keywords
Readme
@berachain/biome-config
Custom GritQL plugins for Biome plus the
shared Biome configuration that this repo and its siblings extend. The config
(config.json, exported as @berachain/biome-config/biome)
carries the formatter, import ordering, the tuned linter rules and the plugin
registrations, so a repo picks all of it up from a single extends entry.
Each plugin is a .grit file under plugins/ that matches an AST
pattern, reports a diagnostic through Biome's linter, and — on Biome ≥ 2.5 — can
attach an autofix.
Requires Biome ≥ 2.5 (this repo pins
2.5.14). Below that the config is rejected outright —linter.rules.presetdoes not exist in 2.4 — and plugin fixes (the rewrite operator +fix_kind) are a 2.5 feature.@biomejs/biome >=2.5.0is declared as a peer dependency, so a consumer below the floor gets an unmet peer warning from pnpm.
prefer-function-declarations is registered by the shared config at Grit
severity error, so it applies repo-wide as soon as a config extends it. The
package is private and ships no build output; it exists to hold the shared
configuration, the .grit files and their tests.
Shared configuration
config.json is the reusable half of a repo's Biome setup:
formatter, import ordering (assist.organizeImports), the tuned linter rules,
the plugin registrations and the framework-file filename exemption. It is
exported as @berachain/biome-config/biome, so any repo can extend it instead of
copying settings around:
// consumer-repo/biome.json
{
"$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
"root": true,
"extends": ["@berachain/biome-config/biome"]
}That is the whole setup: the plugins come with the config. The root biome.json
in this repo is the reference consumer — it extends the shared config and keeps
only what is local to it (the files.includes excludes, the vcs block,
repo-specific noRestrictedImports paths and local overrides).
Four things make this work, all verified against Biome 2.5.14:
- The shared config sets
"root": false. Biome errors with "Found a nested root configuration, but there's already a root configuration" otherwise. - The shared config is not named
biome.json. Biome's scanner discovers anybiome.jsonin the tree as a nested config for that folder and resolves its own relative paths from there, so a./node_modules/...plugin entry inside a file namedbiome.jsondies with "Cannot read file".config.jsonstays out of the scanner. - A plugin path is resolved from the config that extends the shared one, so
./node_modules/@berachain/biome-config/plugins/<name>.gritis the form that works — own-relative./plugins/<name>.gritand bare package specifiers both fail. This assumes the package is installed next to the extending config (i.e. in the repo root'snode_modules). - A shared config cannot extend anything in turn, so keep it dependency-free.
Everything else merges as you would expect: the consumer's linter.rules and
overrides are deep-merged on top of the shared ones (a consumer rule turns
one rule off; it does not replace the block).
Which rules actually run
The rules come from the consumer's Biome binary, not from this config. The
config only turns rules on and off by name, so preset: "recommended" means
"whatever recommended is in the version that is running" — a newer Biome
enables its newer recommended rules on top of these settings. Verified against
2.4.16, 2.5.0, 2.5.8 and 2.5.14:
- Unknown keys are fatal. A Biome older than a rule key in this config (or a
newer one that renamed or dropped a key) exits with Found an unknown key
<key>, lists the known keys and lints nothing. 2.4.16 against this config dies onpreset. That is also the maintenance rule: only use keys that exist at the peer floor (2.5.0), otherwise every consumer below the newer version breaks. $schemais editor metadata. The URL is never fetched — a nonsense URL or a wrong-major one changes nothing. The version string in the config that is entered (the consumer's root, not an extended one) is compared with the CLI at major.minor level: a mismatch prints an info plus abiome migratesuggestion, and nothing fails. Patch drift is not reported at all.- Patch drift still changes results. The same config on 2.5.0 reported 16
errors where 2.5.14 reported 0 on an identical tree, so the version check is
not a behaviour guarantee. Repos get deterministic output by pinning an exact
Biome themselves; the
>=2.5.0peer is a compatibility floor, not a contract.
What stays in the consuming repo
Nothing repo-specific belongs in the shared config: files.includes excludes,
vcs, extra noRestrictedImports entries and local overrides all live in
the consuming repo's biome.json. The @berachain/* entries in the import
groups are harmless in repos that do not use those packages: the groups simply
never match.
Consuming the package from another repo
The package is private, so today a consumer picks one of:
- Publish it (drop
"private": true, bump the version) andpnpm add -D @berachain/biome-config, like@berachain/graphql/config. - Link the local clone for local work:
pnpm add -D @berachain/biome-config@file:../<clone>/packages/biome-config.
Biome resolves @berachain/biome-config/biome from the working directory it
is invoked in (node_modules lookup, climbing parent directories), so running
the CLI from a nested workspace folder also works.
Plugins
| Plugin | Skill rule | Severity | Fix | What it flags |
| --- | --- | --- | --- | --- |
| prefer-function-declarations.grit | function-declarations | error | unsafe (partial) | An arrow function assigned to a const (a named function should be a function declaration). |
prefer-function-declarations
Prefer function declarations over arrow functions. Arrow functions should only be used as inline callbacks.
Flagged (arrow assigned to a const):
const foo = () => {}; // -> function foo() {}
const ret = (x: number): number => x; // typed / return-typed forms too
const id = <T,>(value: T): T => value; // generics
const load = async () => fetchThing(); // async
export const Comp = () => <div />; // -> export function Comp() { return <div />; }Allowed (the rule only targets named function definitions):
function foo() {} // a real function declaration
items.map((x) => x * 2); // inline callback
useEffect(() => {}, []); // inline callback
const onClick = useCallback(() => {}, []); // value is a call, not an arrow
const handlers = { onClick: () => {} }; // object-property arrowThe detection matches the arrow node directly ($value <: arrow_function()), so
typed, return-typed, generic and async arrow forms are all caught.
Fix (unsafe, Biome ≥ 2.5): the common forms are rewritten into a function
declaration — block bodies are kept, expression bodies are wrapped in
{ return … }, and async is preserved:
const c = (z) => z * 2; // -> function c(z) { return z * 2; }
const b = async () => { await x(); }; // -> async function b() { await x(); }The fix is unsafe (applied only with biome check --write --unsafe) because
arrow → function changes this/arguments binding and hoisting. Typed
(const x: T = …), generic (<T,>() => …) and return-typed ((): T => …)
arrows are detection-only — still flagged, but not auto-fixed, because the
rewrite patterns can't decompose them and a naive fix would drop the type.
Known limitation: only const declarations are matched. let/var arrow
assignments are not flagged because GritQL treats let/var as keywords
inside a code pattern, so a `let $name = $value` pattern never matches.
Assigning an arrow to let/var for a named function is rare and discouraged
anyway. This is pinned by a test so the behaviour is noticed if a future Biome
release changes it.
Adding a plugin
The bundled plugins are registered by the shared config.json, so they apply
wherever it is extended. A new plugin is added the same way — put the .grit
file in plugins/ and add its ./node_modules/@berachain/biome-config/plugins/<name>.grit
path to the shared plugins array:
{
"plugins": [
"./node_modules/@berachain/biome-config/plugins/prefer-function-declarations.grit",
"./node_modules/@berachain/biome-config/plugins/<new-plugin>.grit"
]
}A consumer that wants a plugin only on part of its tree registers it in its own
config with includes instead:
{
"plugins": [
{
"path": "./node_modules/@berachain/biome-config/plugins/prefer-function-declarations.grit",
"includes": ["src/actions/**"]
}
]
}The Grit files register diagnostics at error severity, so new violations fail
the --diagnostic-level=error pre-commit/CI gate.
Tests
Tests live in test/ and run as the biome-config
project in the root vitest config. Run them from the repo root — the
project's include globs are root-relative, so pnpm --filter (which changes
the working directory) finds no test files:
pnpm test packages/biome-config # path filter, from the repo root
pnpm exec vitest run --project biome-config # this project only, single runEach test calls lintSource / fixSource, which
write the source to a temporary .tsx file and run the workspace-pinned Biome
CLI against it with only the plugin under test enabled (every built-in rule
disabled). lintSource returns the parsed plugin diagnostics; fixSource runs
--write --unsafe and returns the rewritten file. Both exercise the real Biome
GritQL engine rather than mocking it.
Adding a new plugin
- Write
plugins/<name>.grit. Start the file with a comment block describing the rule, the cases it flags/allows, and any limitations. - Add a
test/<name>.unit.test.tstest that callslintSource({ source, plugin: "<name>.grit" })with both positive (flagged) and negative (allowed) cases. - Document it in the Plugins table above.
Notes on Biome's GritQL subset
Biome supports a limited subset of GritQL. Verified against Biome 2.5.14
while building prefer-function-declarations.
Works:
- Backtick code patterns:
`const $name = $value`. - Bare node matchers in
snake_case(arrow_function()) or Biome CSTPascalCase(JsArrowFunctionExpression()). or { … }alternations (first matching branch wins — no duplicate diagnostics), the<:match operator,predicatedefinitions, andregister_diagnostic(span = …, message = …, severity = …, fix_kind = …).- The rewrite operator (
=>) for fixes — butfix_kindand applicable plugin fixes need Biome ≥ 2.5 (on 2.4.xfix_kindfails to compile and rewrites are silently no-ops). The default fix kind isunsafe.
Does not work, and shaped the implementation:
- Custom diagnostic names/categories:
register_diagnosticaccepts nocategory/name/codeargument (even on 2.5.14), so every plugin diagnostic is reported under the hardcodedplugincategory (biome(plugin)). The rule name is put at the front of the message instead. - Named field access such as
JsVariableDeclarator(initializer = $init)fails to compile. `let …`/`var …`code patterns silently match nothing (keyword clash with GritQL's ownlet).
Why only one function rule is enforced here
The coding standards list three function rules. Only function-declarations is
mechanically enforceable as a GritQL plugin:
- The
object-argumentsrule ("use a single object argument instead of many positional params") is already covered by Biome's built-incomplexity/useMaxParamsrule (configured tomax: 3in the sharedconfig.json). A dedicated plugin would duplicate it, and counting parameters in GritQL is awkward, so it is intentionally not reimplemented here. - The
callback-namingrule (onXxxfor props,handleXxxfor local handlers) requires semantic knowledge — whether an identifier is a prop, and whether a handler wraps anonXxxcallback — that cannot be expressed in Biome's pattern-only GritQL subset. It remains a review-time convention.
Agent-oriented details: AGENTS.md.
