chore(lint): move the never-throw rule to a Biome GritQL plugin - #404
Conversation
File size check0 over a hard cap (fails), 32 warning(s).
Split the file, wrap the line, shorten or exempt the comment, or list the path in 5 managed file(s) skipped; repo-platform owns them. |
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The migration is internally consistent, well tested, and has no identified unresolved defects.
Review effort: Balanced
Findings: None
What changed in this PR
Moves never-throw enforcement from the architecture scanner into the standard Biome lint pipeline.
Changes:
- Adds and configures the GritQL never-throw plugin.
- Removes throw tracking from architecture linting.
- Adds focused plugin tests and updates documentation.
| File | Description |
|---|---|
lint/never-throw.grit |
Implements the never-throw rule. |
biome.json |
Registers and scopes the plugin. |
architecture.yml |
Removes obsolete throw configuration. |
.github/scripts/arch-lint.ts |
Removes throw scanning and census logic. |
test/lint/never-throw.test.ts |
Tests plugin behavior and exclusions. |
test/architecture/architecture.test.ts |
Removes obsolete throw-lint tests. |
AGENTS.md |
Documents Biome-based enforcement. |
docs/reference/architecture.md |
Updates architecture documentation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
5c13829 to
3ff7f5d
Compare
3ff7f5d to
76f3a79
Compare
76f3a79 to
992912b
Compare
992912b to
e29c83e
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The unanchored invariant regex allows non-BUG messages containing an inner quote followed by BUG: to bypass the rule.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (1)
Resolved since last review (1)
e29c83e to
4abcf7a
Compare
4abcf7a to
19a98c5
Compare
ebc744f to
ad336e9
Compare
ad336e9 to
e827213
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The plugin treats type-only TypeScript bindings as value shadows, rejecting valid catch rethrows.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Resolved since last review (2)
e827213 to
3fb0bc6
Compare
3fb0bc6 to
95a5bd6
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Reassigning a catch binding bypasses the new rule and permits throwing a newly created error.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (1)
Resolved since last review (1)
95a5bd6 to
65a3522
Compare
65a3522 to
6e74bed
Compare
lint/never-throw.grit flags every throw under src/ that is neither a BUG: invariant nor a bare rethrow of the catch binding, and biome.json wires it with the one exempt file, src/cli/inputs.ts, where commander's argParser has no value form. The TypeScript walk in arch-lint.ts that carried the rule, its census, its empty ratchet, and the throws section of architecture.yml go; a declaration still holding that section fails as an unknown key. A fixture test lints a tree with the repository's own biome.json, so every throw class, both exemptions, and the exempt file still holding a throw outside the rule are pinned there. The compat-marker gate stays in TypeScript: Biome's GritQL cannot match comments, since they are trivia to it (biomejs/biome#10474). The generated gaps index is linted now, so an override turns the organizeImports assist off for it and it keeps its generator's import order. The shadow check also counts a namespace or an import-equals declaration named after the catch binding, and skips a name inside a type alias, an interface, or a type annotation, since nothing there holds a value.
6e74bed to
a607798
Compare


Before / After
Before:
lint:archwalked everythrowinsrc/with oxc-parser, judged it against thethrowsblock ofarchitecture.yml(a contracts list and a per-file count ratchet), and printed a census.After: a Biome GritQL plugin,
lint/never-throw.grit, flags athrowundersrc/that is neither aBUG:invariant nor a bare rethrow of the catch binding.bun run lintcarries the rule;lint:archkeeps only the import layering.How
lint/never-throw.grit:throw $argis legal when$argisnew X("BUG: ...")(string or template literal as the first argument), or when$argis a plain identifier and the throw sits directly inside the catch clause whose binding it is, with no second binding of that name in the catch body and no function or nestedtrybetween the throw and the clause.biome.json: the plugin is anoverridesentry withincludes: ["src/**", "!src/cli/inputs.ts"], relative to the repository root.A top-level
plugins[].includesmatches the absolute path (🐛 plugins[].includes is matched against the absolute file path, unlike files.includes biomejs/biome#11082), so a checkout under asrc/parent directory would have linted.github/scripts/too.The one exempt file is the third-party contract: commander's
argParserhas no value form.arch-lint.tsloseslintThrows, the census, the ratchet, and theThrowstype;architecture.ymlloses itsthrowsblock, and a declaration still holding one fails as an unknown key.test/lint/never-throw.test.tscopies the repository's ownbiome.jsonand the plugin into a fixture tree under asrc/checkoutparent, makes that tree a git repository (the config'svcs.useIgnoreFilewants one), and lints it, so every throw class, the exemption, the reach of the globs, and the exempt file's remaining throw are pinned by the real configuration.An empty Biome report fails with Biome's stderr instead of a JSON parse error.
check-compat-markers.ts) stays as it is: neither half moved. See the spike table.Spike table
Matched = the plugin's diagnostic fires on that throw. The allowed classes must read "no". Every row is a case in the fixture test.
throw new Error("BUG: ...")$arg <: JsNewExpression(arguments=JsCallArguments(args=[$first, ...])),$first <: JsStringLiteralExpression(),$first <: r"[\"']BUG:[\s\S]*"`throw new RangeError(`BUG: ${x}`)$first <: JsTemplateExpression()throw new Error("BUG: x", { cause }, 3)args=[$first, ...]ignores the restcatch (error) { run(); throw error; }$arg <: JsIdentifierExpression(),$throw <: within JsCatchClause(declaration=JsCatchDeclaration(binding=$arg), body=$body) until <try and function nodes>finally, inside an arrow function's catch, or from an inner catch rethrowing its own bindingcatch (error) { const failure = new Error(...); throw failure; }binding=$argdoes not unifyconst error,const { error },for (const error ...), or(error) =>in the catch bodynot $body <: contains JsIdentifierBinding() as $binding where { $binding <: $arg }until or { JsArrowFunctionExpression(), JsFunctionExpression(), JsMethodObjectMember(), ... }stops the walkcatch (outer) { try {} catch { throw outer; } }, orthrow errorfrom atrybody orfinallynested in the catchuntil or { JsTryStatement(), JsTryFinallyStatement(), ... }stops the walkcatch ({ message }) { throw { message }; }andthrow message$arg <: JsIdentifierExpression()fails; Biome equates the identically spelled binding pattern and object expression otherwisethrow errorafter the catch clause ended, or from the try bodywithin JsCatchClause(...)failsthrow runin a bindinglesscatch {};throw "x";throw new Error(`not BUG: ${1}`)throw new Error("BUG: x".slice(5)),throw new Error("BUG: " + x)src/cli/inputs.ts, undertest/src/, or under.github/scripts/overridesincludes; not lintedThe compat-marker format half (
COMPAT(vN): text, digits required) was spiked against a scratchbiome.jsonwith one plugin at a time, on a file holding a line-comment marker, a block-comment marker, a digitlessCOMPAT(v):, and a string literal"COMPAT(v5): ..."as the control.// COMPAT(v3): text`// $c`/* COMPAT(v4): text */`$x` where { $x <: r"[\s\S]*COMPAT\(v\d+\):[\s\S]*" }// COMPAT(v): text(digitless, the format violation)`$x` where { $x <: contains r"[\s\S]*COMPAT\(v[^\d)][\s\S]*" }"COMPAT(v5): text"string literal (control)const $a = $v(loading control)`const $a = $v`A comment is where every real marker lives, so neither the format half nor the due half (package.json major >= N, which needs a file read no plugin has) can move. The TypeScript gate stays whole.
Proof
bun run lint: red with a fixturesrc/zz-never-throw-fixture.tsholdingthrow new Error("x")(one plugin diagnostic, exit 1), green after removing it (495 files, no errors).bun run typecheck,bun run knip,bun run lint:arch,bun run build:check: all green.bun test test/lint/never-throw.test.ts test/architecture/architecture.test.ts test/scripts/check-compat-markers.test.ts --timeout 120000: 40 pass, 0 fail.Line accounting by kind
lint/never-throw.gritbiome.jsonarchitecture.yml.github/scripts/arch-lint.tstest/lint/never-throw.test.tstest/architecture/architecture.test.tsAGENTS.mddocs/reference/architecture.mdReviewer note
Stayed hand-rolled:
check-compat-markers.ts, both halves, because GritQL in Biome 2.5.11 has no comment node and the due line needspackage.json.Stayed hand-rolled: the import-layering walk in
arch-lint.ts; it was out of scope here and needs cross-file resolution a per-file plugin cannot do.Known plugin limit, stated in its header: GritQL has no scopes, so a second binding of the caught name anywhere in the catch body flags every rethrow in that body. The fix is a rename, and the fixture pins the behaviour.
The exempt-file test lints a copy of
src/cli/inputs.tsunder a non-exempt path and expects at least one diagnostic, so the exemption cannot go stale silently.The fixture tree sits under a
src/checkoutparent, so the glob bug of a top-levelplugins[].includes(matching the absolute path) fails the test; checked by restoring the old wiring.Gone without a successor, by design: the per-file throw census and the "file does not parse" report. Biome itself rejects a file that does not parse, and the ratchet was already empty.
The old walk also refused a rethrow inside a
switchor loop body within the catch; the plugin admits it as a synchronous rethrow of the catch binding.Exemption existence and staleness are checked only for the hard-coded
src/cli/inputs.ts; a second exemption must be added totest/lint/never-throw.test.tsby hand.Once lint reaches the generated gaps index, Biome's import sorter flags the import order the generator emits (it sorts by binding name, the generator by path). The generated file keeps its generator's order: an override turns the organizeImports assist off for it, so a fourth export or a new gap file never fails lint on a sort.
The shadow check counts every value binding:
JsIdentifierBinding(const, let, var, parameter, class, function, enum, destructuring) plus the two TypeScript declarations whose name is aTsIdentifierBindingyet holds a value,namespaceandimport x = require(). A name inside a type position does not count: a type alias or interface named after the catch binding, and a same-named parameter of a function type inside a type alias, an interface, or a type annotation, since nothing there holds a value. Fixture rows pinnamespace erroras flagged (red when the match isJsIdentifierBindingonly),type erroras a legal rethrow (red when the match is everyTsIdentifierBinding), andtype Handler = (error: unknown) => voidplus an inline(error: unknown) => voidannotation as legal rethrows (red when type positions are not excluded).Recorded, not built: a same-named function-type parameter outside those three containers (a cast
run as (error: unknown) => void, a return type, adeclareor overload signature, a type argument) still counts as a shadow and flags a legal rethrow beside it; no catch body in src/ has one, the header states the scope, and the rename fixes it. The durable form excludes by the owning type node (TsFunctionType,TsConstructorType, method and declare signatures) instead of by container.The
BUG:prefix match is anchored to the start of the literal explicitly, and the fixture pinsthrow new Error('prefix "BUG: hidden')as flagged; a control with the unanchored form also flagged it (GritQL's regex match is a full match), so the anchor states the intent rather than closing a gap. The fixture harness now prefers the plugin's diagnostic when a line also carries a built-in rule's (a throw inside finally trips noUnsafeFinally too).BEGIN_COMMIT_OVERRIDE
chore(lint): move the never-throw rule to a Biome GritQL plugin
lint/never-throw.grit flags every throw under src/ that is neither a BUG: invariant nor a bare rethrow of the catch binding, and biome.json wires it with the one exempt file, src/cli/inputs.ts, where commander's argParser has no value form.
The TypeScript walk in arch-lint.ts that carried the rule, its census, its empty ratchet, and the throws section of architecture.yml go; a declaration still holding that section fails as an unknown key.
A fixture test lints a tree with the repository's own biome.json, so every throw class, both exemptions, and the exempt file still holding a throw outside the rule are pinned there.
The compat-marker gate stays in TypeScript: Biome's GritQL cannot match comments, since they are trivia to it (biomejs/biome#10474).
The generated gaps index is linted now, so an override turns the organizeImports assist off for it and it keeps its generator's import order.
The shadow check also counts a namespace or an import-equals declaration named after the catch binding, and skips a name inside a type alias, an interface, or a type annotation, since nothing there holds a value.
END_COMMIT_OVERRIDE