Skip to content

feat(lint): ban try/catch and non-null assertions, clean packages/ and lib/ - #687

Open
Makisuo wants to merge 1 commit into
mainfrom
claude/oxlint-try-catch-ban-a83eb2
Open

feat(lint): ban try/catch and non-null assertions, clean packages/ and lib/#687
Makisuo wants to merge 1 commit into
mainfrom
claude/oxlint-try-catch-ban-a83eb2

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

What

Two new lint bans, plus the burndown that makes them land green in shared code.

  • maple/no-try-catch — a new rule in scripts/oxlint-plugins/maple.mjs, firing on TryStatement. oxlint has no no-restricted-syntax, so banning a statement kind takes a plugin rule. The message names the primitive per case: Effect.try/tryPromise, Schema.fromJsonString, Schema.decodeUnknown{Effect,Option,Sync}, Effect.catch/catchTag/catchDefect, Effect.ensuring for a finally.
  • typescript/no-non-null-assertion — the oxlint built-in, no plugin needed.

packages/* and lib/* source is now at zero for both: 38 try/catch blocks and ~110 assertions converted.

Why

A thrown exception is invisible to the type system, so it escapes the typed error channel the codebase relies on, and one catch block flattens every failure into a single branch — discarding exactly the distinction the tagged-error convention exists to preserve.

Scope, and why it is staged

I measured before wiring anything up. Repo-wide there are ~660 try blocks and 2060 !, of which 1429 assertions are in tests. Both rules are "error" with three overrides:

  1. Tests off for both. A test asserts on a fixture it just built, so ! there documents the fixture; a test driving a throwing boundary needs catch to observe it.
  2. packages/browser, packages/browser-session, packages/clickhouse-clino-try-catch off. These carry no Effect dependency at all; the two browser SDKs ship to customers under a bundle budget, so try/catch is the only error handling available to them. Worth knowing before anyone widens the rule: check package.json first.
  3. Burndown listapps/**, scripts/**, **/scripts/**, examples/**, alchemy.run.ts off for both. ~460 try/catch and ~500 assertions remain there. Leaving them on would land bun run lint with ~960 errors; the comment in the config says to shrink the list, not widen it.

How the fixes were done

Rather than 38 inline conversions, the repeated shapes got a seam:

  • packages/ui/src/lib/try-sync.ts and packages/effect-sdk/src/shared/try-sync.tsEffect.try + Effect.option behind an Option, for host calls that throw instead of returning (JSON.parse, localStorage, execCommand, Intl, BigInt, decodeURIComponent).
  • packages/ui/src/lib/local-storage.ts — the read and the write of a preference behind one guard.
  • guardFlush in packages/effect-sdk/src/shared/flush-core.ts — replaces the identical never-reject wrapper the client, server, and Cloudflare presets each kept a copy of.
  • Parsing moved to Schema.fromJsonString / Schema.URLFromString / Schema.decodeUnknownOption wherever a schema was the honest answer.

Almost every prod assertion turned out to be a noUncheckedIndexedAccess artifact (arr[i]!, map.get(k)!, match[1]!) rather than an unsafe cast, so the fixes make the invariant real instead of adding Option plumbing:

  • Drain held two mutually-exclusive nullable stores; now one union field, which also let a test drop a cast into internals in favour of a public clusters().
  • pipe-dispatch's int(key, def?) returned number | undefined for every defaulted call — overloading it removed six assertions with zero call-site edits.
  • soleValue() in query-helpers.ts for the eight length === 1 ? values[0]! : … branches; childAt() for the nine has-then-get pairs in the prefix-tree walk.
  • The LRU's entryAt() names a broken slot (LruSlotError) instead of surfacing undefined two frames later.

Two latent bugs closed on the way

  • traces-shared.ts: an attribute filter over an empty key list built its index prefilter from keys[0]!, emitting has(…, NULL). It now falls back to the exact predicate.
  • execution/executor.ts: the capability-aware compile passed capabilities! through; it now falls back to baselineWarehouseCapabilities(), which generates the widest SQL.

Verification

  • bun run lint clean (both .oxlintrc.json and the type-aware .oxlintrc.effect.json).
  • bun run typecheck — 39/39.
  • Every touched package's suite green: ui 638, domain 622, query-engine 1352, effect-sdk 122, widgets 186, query-engine-integrations 314, clickhouse-builder 244.

Note for the reviewer

packages/domain's project-manifest.test.ts > keeps a stable project revision for unchanged inputs flakes roughly 1 run in 3 under the full suite and passes in isolation. It is not in this change's import graph and reproduces without it — pre-existing, and worth a separate look.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…d lib/

A thrown exception is invisible to the type system, so it escapes the typed
error channel the codebase relies on, and one `catch` block flattens every
failure into a single branch. `maple/no-try-catch` makes that a lint error;
oxlint has no `no-restricted-syntax`, so banning a statement kind takes a
plugin rule. `typescript/no-non-null-assertion` is the built-in twin.

Both land at `error` with three overrides:

- Tests are off for both. A test asserts on a fixture it just built, and a
  test driving a throwing boundary needs `catch` to observe it.
- `packages/browser`, `packages/browser-session`, and `packages/clickhouse-cli`
  are off for `no-try-catch`: they carry no Effect dependency at all (the two
  browser SDKs ship to customers under a bundle budget), so the primitive the
  rule points at does not exist there.
- `apps/**`, `scripts/**`, `**/scripts/**`, and `examples/**` are off for both
  as a burndown list — ~460 `try`/`catch` blocks and ~500 assertions remain
  there. Drop entries as each is converted rather than widening the list.

`packages/*` and `lib/*` source is now at zero for both: 38 `try`/`catch`
blocks and ~110 assertions converted.

Rather than 38 inline conversions, the repeated shapes got a seam:
`trySync`/`tryPromise` in `packages/ui/src/lib/try-sync.ts` and
`packages/effect-sdk/src/shared/try-sync.ts` run a throwing host call behind
an `Option`; `local-storage.ts` puts the read and write of a preference
behind the same guard; `guardFlush` in the SDK's `flush-core.ts` replaces the
identical never-reject wrapper the client, server, and Cloudflare presets each
kept. Parsing moved to `Schema.fromJsonString`, `Schema.URLFromString`, and
`Schema.decodeUnknownOption` where a schema was the honest answer.

Nearly every prod assertion was a `noUncheckedIndexedAccess` artifact rather
than an unsafe cast, so the fixes make the invariant real instead of adding
Option plumbing: `Drain` swaps two mutually-exclusive nullable stores for one
union field; `pipe-dispatch`'s `int(key, def?)` is overloaded so a defaulted
call is a `number`; `soleValue` and `childAt` replace the eight `length === 1`
branches and nine has-then-get pairs; the LRU's `entryAt` names a broken slot
instead of surfacing `undefined` two frames later.

Two latent bugs closed on the way: an attribute filter over an empty key list
emitted `has(…, NULL)`, and the executor's capability-aware compile now falls
back to baseline capabilities instead of passing `undefined` through.
@Makisuo
Makisuo force-pushed the claude/oxlint-try-catch-ban-a83eb2 branch from 15c9a7c to 6a0cfdf Compare August 30, 2026 00:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant