Skip to content

chore(lint): move the never-throw rule to a Biome GritQL plugin - #404

Merged
Vivswan merged 1 commit into
mainfrom
wt/biome-plugins
Sep 22, 2026
Merged

Vivswan merged 1 commit into
mainfrom
wt/biome-plugins

Conversation

@Vivswan

@Vivswan Vivswan commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Before / After

Before: lint:arch walked every throw in src/ with oxc-parser, judged it against the throws block of architecture.yml (a contracts list and a per-file count ratchet), and printed a census.

After: a Biome GritQL plugin, lint/never-throw.grit, flags a throw under src/ that is neither a BUG: invariant nor a bare rethrow of the catch binding. bun run lint carries the rule; lint:arch keeps only the import layering.

$ bun run lint        # with a fixture src/zz.ts holding `throw new Error("x")`
src/zz.ts:2:3 plugin
  x throw outside the never-throw rule: not a BUG: invariant (an Error whose message starts with BUG:)
    and not a bare rethrow of the catch binding; return a Result instead
Found 1 error.

How

  • lint/never-throw.grit: throw $arg is legal when $arg is new X("BUG: ...") (string or template literal as the first argument), or when $arg is 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 nested try between the throw and the clause.
  • biome.json: the plugin is an overrides entry with includes: ["src/**", "!src/cli/inputs.ts"], relative to the repository root.
    A top-level plugins[].includes matches the absolute path (🐛 plugins[].includes is matched against the absolute file path, unlike files.includes biomejs/biome#11082), so a checkout under a src/ parent directory would have linted .github/scripts/ too.
    The one exempt file is the third-party contract: commander's argParser has no value form.
  • arch-lint.ts loses lintThrows, the census, the ratchet, and the Throws type; architecture.yml loses its throws block, and a declaration still holding one fails as an unknown key.
  • test/lint/never-throw.test.ts copies the repository's own biome.json and the plugin into a fixture tree under a src/checkout parent, makes that tree a git repository (the config's vcs.useIgnoreFile wants 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.
  • The compat-marker gate (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.

Pattern (never-throw) GritQL expression Matched
throw new Error("BUG: ...") $arg <: JsNewExpression(arguments=JsCallArguments(args=[$first, ...])), $first <: JsStringLiteralExpression(), $first <: r"[\"']BUG:[\s\S]*"` no
throw new RangeError(`BUG: ${x}`) same, with $first <: JsTemplateExpression() no
throw new Error("BUG: x", { cause }, 3) same; args=[$first, ...] ignores the rest no
catch (error) { run(); throw error; } $arg <: JsIdentifierExpression(), $throw <: within JsCatchClause(declaration=JsCatchDeclaration(binding=$arg), body=$body) until <try and function nodes> no
the same rethrow with a finally, inside an arrow function's catch, or from an inner catch rethrowing its own binding same no
catch (error) { const failure = new Error(...); throw failure; } binding=$arg does not unify yes
rethrow after a shadowing const error, const { error }, for (const error ...), or (error) => in the catch body not $body <: contains JsIdentifierBinding() as $binding where { $binding <: $arg } yes
rethrow from a callback, method, or async arrow inside the catch body until or { JsArrowFunctionExpression(), JsFunctionExpression(), JsMethodObjectMember(), ... } stops the walk yes
catch (outer) { try {} catch { throw outer; } }, or throw error from a try body or finally nested in the catch until or { JsTryStatement(), JsTryFinallyStatement(), ... } stops the walk yes
catch ({ message }) { throw { message }; } and throw message $arg <: JsIdentifierExpression() fails; Biome equates the identically spelled binding pattern and object expression otherwise yes
throw error after the catch clause ended, or from the try body within JsCatchClause(...) fails yes
throw run in a bindingless catch {}; throw "x"; throw new Error(`not BUG: ${1}`) no allowance applies yes
throw new Error("BUG: x".slice(5)), throw new Error("BUG: " + x) first argument is not a literal node yes
any throw in src/cli/inputs.ts, under test/src/, or under .github/scripts/ outside the overrides includes; not linted no

The compat-marker format half (COMPAT(vN): text, digits required) was spiked against a scratch biome.json with one plugin at a time, on a file holding a line-comment marker, a block-comment marker, a digitless COMPAT(v):, and a string literal "COMPAT(v5): ..." as the control.

Pattern (compat marker) GritQL expression Matched
// COMPAT(v3): text `// $c` no; comments are trivia, not nodes (biomejs/biome#10474)
/* COMPAT(v4): text */ `$x` where { $x <: r"[\s\S]*COMPAT\(v\d+\):[\s\S]*" } leaks: the regex sees the comment only through trivia attached to a neighbouring statement, and reports that statement's span, not the comment's
// COMPAT(v): text (digitless, the format violation) `$x` where { $x <: contains r"[\s\S]*COMPAT\(v[^\d)][\s\S]*" } no
"COMPAT(v5): text" string literal (control) the row-2 regex yes; a literal is a node
const $a = $v (loading control) `const $a = $v` yes

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 fixture src/zz-never-throw-fixture.ts holding throw 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

Kind File + -
lint plugin lint/never-throw.grit 55 0
config biome.json 16 6
config architecture.yml 0 11
script .github/scripts/arch-lint.ts 13 214
test test/lint/never-throw.test.ts 380 0
test test/architecture/architecture.test.ts 17 205
docs AGENTS.md 1 1
docs docs/reference/architecture.md 2 4
total 484 441

Reviewer 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 needs package.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.ts under a non-exempt path and expects at least one diagnostic, so the exemption cannot go stale silently.

  • The fixture tree sits under a src/checkout parent, so the glob bug of a top-level plugins[].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 switch or 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 to test/lint/never-throw.test.ts by 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 a TsIdentifierBinding yet holds a value, namespace and import 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 pin namespace error as flagged (red when the match is JsIdentifierBinding only), type error as a legal rethrow (red when the match is every TsIdentifierBinding), and type Handler = (error: unknown) => void plus an inline (error: unknown) => void annotation 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, a declare or 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 pins throw 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

Copilot AI balanced review requested due to automatic review settings September 22, 2026 06:45
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

File size check

0 over a hard cap (fails), 32 warning(s).

File Size Tier Cap
.github/scripts/gen-inputs-table.ts:54 11 comment lines warn 10
.github/scripts/generated.ts:28 200 chars warn 150
.github/scripts/release-pipeline.ts:6 156 chars warn 150
.github/scripts/release-pipeline.ts:453 151 chars warn 150
.github/scripts/release-pipeline.ts:1247 153 chars warn 150
.github/scripts/release-pipeline.ts:1 34 comment lines (header) warn 25
.github/scripts/release-pipeline.ts:449 14 comment lines warn 10
.github/workflows/post-green.yml:29 153 chars warn 150
.github/workflows/update-release-pr.yml:109 14 comment lines warn 10
docs/upgrading/v2-to-v3.md 1276 lines warn 1040
src/engine/layers.ts:217 157 chars warn 150
src/flows/settings-write.ts:142 159 chars warn 150
src/flows/settings-write.ts:30 11 comment lines warn 10
src/flows/snapshot.ts:189 13 comment lines warn 10
src/github/secret-scan.ts:42 11 comment lines warn 10
src/schema.ts:186 153 chars warn 150
src/schema.ts:197 176 chars warn 150
src/sections/contract/errors.ts:14 14 comment lines warn 10
src/sections/contract/module.ts:963 185 chars warn 150
src/sections/contract/module.ts:627 12 comment lines warn 10
src/sections/contract/module.ts:897 12 comment lines warn 10
src/sections/secret_scanning_custom_patterns/compilable-form.ts:382 161 chars warn 150
src/sections/shared/roles.ts:43 13 comment lines warn 10
src/types.ts:16 156 chars warn 150
test/docs/guides.test.ts:451 155 chars warn 150
test/e2e/generators.ts 2633 lines warn 2560
test/e2e/generators.ts:1703 166 chars warn 150
test/e2e/generators.ts:1857 161 chars warn 150
test/e2e/generators.ts:1666 12 comment lines warn 10
test/engine/execute.test.ts:617 152 chars warn 150
test/flows/merge-parity.test.ts:63 164 chars warn 150
test/scripts/auto-fix-allowlist.test.ts:12 12 comment lines warn 10

Split the file, wrap the line, shorten or exempt the comment, or list the path in .file-size-allow.local with a # reason.

5 managed file(s) skipped; repo-platform owns them.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI review requested due to automatic review settings September 22, 2026 07:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The migration is coherent, documented, and comprehensively covered by configuration-level tests.

Review effort: Balanced
Findings: None

Copilot AI review requested due to automatic review settings September 22, 2026 07:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The migration is consistent, narrowly scoped, and covered by configuration-level tests.

Review effort: Balanced
Findings: None

@Vivswan
Vivswan marked this pull request as ready for review September 22, 2026 08:52
Copilot AI review requested due to automatic review settings September 22, 2026 09:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The global Biome exclusion leaves one generated source file outside never-throw enforcement.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Comment thread biome.json
@Vivswan Vivswan added the merge-when-green Owner approved: merge once every gate is green label Sep 22, 2026
Copilot AI review requested due to automatic review settings September 22, 2026 09:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity

Open (1)
Resolved since last review (1)

Comment thread lint/never-throw.grit Outdated
Copilot AI review requested due to automatic review settings September 22, 2026 09:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The plugin, configuration scope, migration, documentation, and regression coverage are consistent and complete.

Review effort: Balanced
Findings: 1 High severity

Open (1)

Copilot AI review requested due to automatic review settings September 22, 2026 09:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

TypeScript enum shadowing can bypass the new never-throw rule.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)

Comment thread lint/never-throw.grit Outdated
Copilot AI review requested due to automatic review settings September 22, 2026 09:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The migration is consistent, narrowly scoped, and comprehensively exercised by configuration-level tests.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)

Copilot AI review requested due to automatic review settings September 22, 2026 10:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

Open (1)
Resolved since last review (2)

Comment thread lint/never-throw.grit Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The rule migration is complete, consistently configured, and covered by focused integration tests.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Copilot AI review requested due to automatic review settings September 22, 2026 10:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

Open (1)
Resolved since last review (1)

Comment thread lint/never-throw.grit
Copilot AI review requested due to automatic review settings September 22, 2026 10:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The new enforcement preserves the intended policy with comprehensive configuration and fixture coverage.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Copilot AI review requested due to automatic review settings September 22, 2026 11:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The new enforcement is comprehensively tested and the obsolete architecture-lint implementation is consistently removed.

Review effort: Balanced
Findings: None

Resolved since last review (1)

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.
@Vivswan
Vivswan merged commit d6f023e into main Sep 22, 2026
29 checks passed
@Vivswan
Vivswan deleted the wt/biome-plugins branch September 22, 2026 11:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-when-green Owner approved: merge once every gate is green

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants