diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 02bc1c5d7d8c..702cc89ebb8f 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -423,7 +423,7 @@ function drainBackendOutput( cause, }), ), - Effect.catchTag("BackendProcessOutputHandlingError", onOutputFailure), + Effect.catchTags({ BackendProcessOutputHandlingError: onOutputFailure }), ), ), Effect.catchTags({ diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index a2180db4ea12..5f6ac755a03d 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -223,7 +223,7 @@ export const layerAuthenticatedAuth = Layer.effect( }), session.subject === "cloud-connect" ? traceAuthenticatedRelayRequest : identity, ); - }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); + }).pipe(Effect.catchTags({ EnvironmentAuthInvalidError: appendDpopChallengeOnUnauthorized })); }), ); @@ -474,9 +474,10 @@ export const layer = HttpApiBuilder.group( ); return { revoked }; }, - Effect.catchTag("ServerAuthForbiddenOperationError", () => - failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), - ), + Effect.catchTags({ + ServerAuthForbiddenOperationError: () => + failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), + }), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("client_session_revoke_failed", error), ), diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 79af5024a12e..83d384097ecc 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -105,11 +105,12 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: { // A stored credential whose refresh fails (revoked, expired grant) must // fall through to a fresh device authorization, not dead-end the command. const existing = yield* tokens.getExisting.pipe( - Effect.catchTag("CloudCliCredentialRefreshError", () => - Console.log( - "The stored T3 Connect credential could not be refreshed; signing in again.", - ).pipe(Effect.as(Option.none())), - ), + Effect.catchTags({ + CloudCliCredentialRefreshError: () => + Console.log( + "The stored T3 Connect credential could not be refreshed; signing in again.", + ).pipe(Effect.as(Option.none())), + }), ); if (Option.isSome(existing)) { return existing.value.identity ?? null; diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index f962c2ddf87f..4b977bc24951 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -162,7 +162,7 @@ const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { } const confirmed = yield* Prompt.run( Prompt.Confirm({ message: "Remove t3 from this machine?", initial: false }), - ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); + ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) })); if (!confirmed) { yield* Console.log("Left as is."); return; diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index f6a46eccea92..576158e316bc 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -384,7 +384,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { } const confirmed = yield* Prompt.run( Prompt.Confirm({ message: "Install the preview build anyway?", initial: false }), - ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); + ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) })); if (!confirmed) { yield* Console.log("Left as is."); return; @@ -473,7 +473,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { message: "Restart the background service once the download is verified?", initial: true, }), - ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); + ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) })); } else { yield* Console.log( " Not a terminal, so the service keeps running its current version. Rerun with --yes to restart it now, or run `t3 service restart` later.", diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 16671606274c..475f9aa9caee 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -408,9 +408,9 @@ export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_author }); return yield* pollDeviceToken(metadata, authorization.device_code, interval).pipe( Effect.timeout(expiresIn), - Effect.catchTag("TimeoutError", (cause) => - Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), - ), + Effect.catchTags({ + TimeoutError: (cause) => Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), + }), ); }, ); @@ -505,9 +505,9 @@ export const make = Effect.gen(function* () { authorizationUrl, callback: Deferred.await(callback).pipe( Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), - Effect.catchTag("TimeoutError", (cause) => - Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), - ), + Effect.catchTags({ + TimeoutError: (cause) => Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })), + }), ), terminal, launchBrowser: externalLauncher.launchBrowser, diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 7289a3d814e8..57904596b5f1 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -681,7 +681,7 @@ const make = Effect.gen(function* () { return proof satisfies RelayEnvironmentLinkProof; }, Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("generate-link-proof")), - Effect.catchTag("PlatformError", internalError("generate-link-proof")), + Effect.catchTags({ PlatformError: internalError("generate-link-proof") }), ); const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")( @@ -1546,7 +1546,7 @@ const make = Effect.gen(function* () { } satisfies RelayEnvironmentHealthResponse; }, Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("answer-health")), - Effect.catchTag("PlatformError", internalError("answer-health")), + Effect.catchTags({ PlatformError: internalError("answer-health") }), ); const mintCredential = Effect.fn("environment.cloud.mintCredential")( @@ -1653,7 +1653,7 @@ const make = Effect.gen(function* () { } satisfies RelayEnvironmentMintResponse; }, Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("issue-credential")), - Effect.catchTag("PlatformError", internalError("issue-credential")), + Effect.catchTags({ PlatformError: internalError("issue-credential") }), ); return CloudLink.of({ diff --git a/apps/server/src/htmlRender/HtmlRender.ts b/apps/server/src/htmlRender/HtmlRender.ts index a6587267df68..085a6b44c92c 100644 --- a/apps/server/src/htmlRender/HtmlRender.ts +++ b/apps/server/src/htmlRender/HtmlRender.ts @@ -351,26 +351,28 @@ const make = Effect.gen(function* () { browsers.withPermits(1)( // Read once a permit is held, so a queued call sees a fallback learned meanwhile. Effect.suspend(() => run(noSandbox)).pipe( - Effect.catchTag("HtmlRenderSandboxUnavailableError", () => - Effect.logInfo( - "Chrome's sandbox is unavailable on this host; launching it without one.", - ).pipe( - Effect.andThen( - Effect.sync(() => { - noSandbox = true; + Effect.catchTags({ + HtmlRenderSandboxUnavailableError: () => + Effect.logInfo( + "Chrome's sandbox is unavailable on this host; launching it without one.", + ).pipe( + Effect.andThen( + Effect.sync(() => { + noSandbox = true; + }), + ), + Effect.andThen(run(true)), + ), + }), + Effect.catchTags({ + HtmlRenderSandboxUnavailableError: (cause) => + Effect.fail( + new HeadlessChrome.HtmlRenderBrowserError({ + reason: "the browser has no sandbox", + cause, }), ), - Effect.andThen(run(true)), - ), - ), - Effect.catchTag("HtmlRenderSandboxUnavailableError", (cause) => - Effect.fail( - new HeadlessChrome.HtmlRenderBrowserError({ - reason: "the browser has no sandbox", - cause, - }), - ), - ), + }), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 9d4a3ae969e8..aad31d636325 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -3011,16 +3011,18 @@ export function makeOpenCodeAdapterV2( ); yield* Effect.raceFirst(Fiber.join(request), Deferred.await(receipt)).pipe( Effect.timeout("10 seconds"), - Effect.catchTag("TimeoutError", (cause) => { - const error = new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "session.command", - detail: "OpenCode command admission did not complete within 10 seconds.", - cause, - }); - abortController.abort(); - return finalizeTurn(state, turn, "failed", { - failure: makeProviderFailure({ cause: error, class: "provider_error" }), - }).pipe(Effect.andThen(Effect.fail(error))); + Effect.catchTags({ + TimeoutError: (cause) => { + const error = new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "session.command", + detail: "OpenCode command admission did not complete within 10 seconds.", + cause, + }); + abortController.abort(); + return finalizeTurn(state, turn, "failed", { + failure: makeProviderFailure({ cause: error, class: "provider_error" }), + }).pipe(Effect.andThen(Effect.fail(error))); + }, }), ); }); diff --git a/apps/server/src/provider/acp/AcpRegistrySupport.ts b/apps/server/src/provider/acp/AcpRegistrySupport.ts index 0b02f733babb..a25bb711ea34 100644 --- a/apps/server/src/provider/acp/AcpRegistrySupport.ts +++ b/apps/server/src/provider/acp/AcpRegistrySupport.ts @@ -1916,16 +1916,15 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct ), ) .pipe( - Effect.catchTag( - "ServerSettingsError", - (cause) => + Effect.catchTags({ + ServerSettingsError: (cause) => new AcpRegistryError({ reason: "install_failed", detail: "Could not read provider settings while checking managed ACP binary references.", cause, }), - ), + }), ); return AcpRegistryCatalog.of({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index f4982837b212..1e65926e84f4 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1578,7 +1578,7 @@ export const make = Effect.gen(function* () { ? operation.pipe(Effect.provideService(routingCredential, identity), Effect.result) : Effect.fail(rejected()), ) - .pipe(Effect.catchTag("PullRequestProviderError", () => Effect.fail(rejected()))); + .pipe(Effect.catchTags({ PullRequestProviderError: () => Effect.fail(rejected()) })); return yield* Effect.fromResult(result); }); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 2d3402dd76a3..7cd96a65ebee 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -376,13 +376,14 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( cause, }), ).pipe( - Effect.catchTag("WorkspaceSearchIndexScanTimedOut", (error) => - variant === "paths" - ? Effect.sync(() => { - initialScanTimedOut = true; - }) - : Effect.fail(error), - ), + Effect.catchTags({ + WorkspaceSearchIndexScanTimedOut: (error) => + variant === "paths" + ? Effect.sync(() => { + initialScanTimedOut = true; + }) + : Effect.fail(error), + }), ); const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 96844f8cfd79..3f94cb72aa78 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2697,15 +2697,16 @@ const layerWsRpc = ( status, }), ), - Effect.catchTag("RelayClientInstallError", (error) => - Queue.fail( - queue, - new RelayClientInstallFailedError({ - reason: error.reason, - message: error.message, - }), - ), - ), + Effect.catchTags({ + RelayClientInstallError: (error) => + Queue.fail( + queue, + new RelayClientInstallFailedError({ + reason: error.reason, + message: error.message, + }), + ), + }), Effect.andThen(Queue.end(queue)), Effect.forkScoped, ), diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index ee0368b0f093..475b991e0dde 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; import noTestInLoop from "./rules/no-test-in-loop.ts"; import noUnscopedHas from "./rules/no-unscoped-has.ts"; +import preferCatchTags from "./rules/prefer-catch-tags.ts"; import requireSuppressionReason from "./rules/require-suppression-reason.ts"; export default definePlugin({ @@ -25,6 +26,7 @@ export default definePlugin({ "no-native-title-tooltip": noNativeTitleTooltip, "no-test-in-loop": noTestInLoop, "no-unscoped-has": noUnscopedHas, + "prefer-catch-tags": preferCatchTags, "require-suppression-reason": requireSuppressionReason, }, }); diff --git a/oxlint-plugin-t3code/rules/prefer-catch-tags.test.ts b/oxlint-plugin-t3code/rules/prefer-catch-tags.test.ts new file mode 100644 index 000000000000..6912357749ec --- /dev/null +++ b/oxlint-plugin-t3code/rules/prefer-catch-tags.test.ts @@ -0,0 +1,64 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/prefer-catch-tags"); + +describe("t3code/prefer-catch-tags", () => { + rule.valid( + "allows Effect.catchTags", + ` + import * as Effect from "effect/Effect"; + + export const program = Effect.fail({ _tag: "A" } as const).pipe( + Effect.catchTags({ A: () => Effect.void }), + ); + `, + ); + + rule.valid( + "ignores catchTag on other modules", + ` + import * as Stream from "effect/Stream"; + + export const recover = Stream.catchTag("A", () => Stream.empty); + `, + ); + + rule.valid( + "ignores a local that shadows the namespace", + ` + import * as Effect from "effect/Effect"; + + export const program = Effect.void; + export function recover(Effect: { catchTag: (tag: string) => void }) { + Effect.catchTag("A"); + } + `, + ); + + rule.invalid( + "reports Effect.catchTag", + ` + import * as Effect from "effect/Effect"; + + export const program = Effect.fail({ _tag: "A" } as const).pipe( + Effect.catchTag("A", () => Effect.void), + ); + `, + (output) => { + assert.match(output, /Effect\.catchTags\(\{ Tag: handler \}\)/); + }, + ); + + rule.invalid( + "reports catchTag through an aliased namespace", + ` + import * as Eff from "effect/Effect"; + + export const recover = Eff.catchTag("A", () => Eff.void); + `, + ); + + rule.invalid("reports a named catchTag import", `import { catchTag } from "effect/Effect";`); +}); diff --git a/oxlint-plugin-t3code/rules/prefer-catch-tags.ts b/oxlint-plugin-t3code/rules/prefer-catch-tags.ts new file mode 100644 index 000000000000..743b94442cc5 --- /dev/null +++ b/oxlint-plugin-t3code/rules/prefer-catch-tags.ts @@ -0,0 +1,60 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; + +const MESSAGE = "Catch known tags with `Effect.catchTags({ Tag: handler })`, even for one tag."; + +/** Reports `Effect.catchTag`, through any namespace import of `effect/Effect` or a named import. */ +export default defineRule({ + meta: { + type: "suggestion", + docs: { + description: "Require `Effect.catchTags` over `Effect.catchTag`.", + }, + }, + create(context) { + // Whether `identifier` is bound by `import * as X from "effect/Effect"`, not a shadowing local. + const isEffectNamespace = (identifier: ESTree.IdentifierReference) => { + let scope = context.sourceCode.getScope(identifier); + while (true) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) { + return variable.defs.some( + (def) => + def.type === "ImportBinding" && + def.node.type === "ImportNamespaceSpecifier" && + def.parent?.type === "ImportDeclaration" && + def.parent.source.value === "effect/Effect", + ); + } + if (scope.upper === null) return false; + scope = scope.upper; + } + }; + + return { + ImportDeclaration(node) { + if (node.source.value !== "effect/Effect") return; + for (const specifier of node.specifiers) { + if ( + specifier.type === "ImportSpecifier" && + (specifier.imported.type === "Identifier" + ? specifier.imported.name + : specifier.imported.value) === "catchTag" + ) { + context.report({ node: specifier, message: MESSAGE }); + } + } + }, + MemberExpression(node) { + if ( + !node.computed && + node.object.type === "Identifier" && + node.property.type === "Identifier" && + node.property.name === "catchTag" && + isEffectNamespace(node.object) + ) { + context.report({ node, message: MESSAGE }); + } + }, + }; + }, +}); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index d6ffac55c3db..d0cb52ddfc39 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -482,7 +482,7 @@ export const make = Effect.gen(function* () { persistedRoutesByEnvironment.keys(), (environmentId) => acquireSupervisor(environmentId).pipe( - Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void }), ), { concurrency: "unbounded", @@ -999,7 +999,7 @@ export const make = Effect.gen(function* () { relayEnvironmentIds, (environmentId) => removeRoute(environmentId, RELAY_ROUTE_ID).pipe( - Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void }), ), { concurrency: "unbounded", @@ -1012,7 +1012,7 @@ export const make = Effect.gen(function* () { const retryNow = (environmentId: EnvironmentId) => acquireSupervisor(environmentId).pipe( Effect.flatMap((supervisor) => supervisor.retryNow), - Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void }), Effect.withSpan("EnvironmentRegistry.retryNow"), ); const setEnabled = Effect.fn("EnvironmentRegistry.setEnabled")(function* ( diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 11c3680b5245..57576cad32f3 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -379,15 +379,16 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ); yield* report("waiting_for_lock"); yield* acquireInstallLock(lockPath).pipe( - Effect.catchTag("PlatformError", (cause) => - Effect.fail( - new RelayClientInstallError({ - reason: "write_failed", - message: "Could not acquire the relay client installation lock.", - cause, - }), - ), - ), + Effect.catchTags({ + PlatformError: (cause) => + Effect.fail( + new RelayClientInstallError({ + reason: "write_failed", + message: "Could not acquire the relay client installation lock.", + cause, + }), + ), + }), ); return yield* Effect.gen(function* () { const afterLock = yield* resolve; diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index e0bfc322142f..99c9bb785b1f 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -756,7 +756,7 @@ export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function ) { return yield* resolveCommandPath(command, options).pipe( Effect.as(true), - Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)), + Effect.catchTags({ CommandResolutionError: () => Effect.succeed(false) }), ); }); diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts index e20151e3457e..6d9f1c762a63 100644 --- a/scripts/mobile-native-client.ts +++ b/scripts/mobile-native-client.ts @@ -316,7 +316,7 @@ const main = Command.make( installedBinary: installedBinary(platform, device), readRecord: fs.readFileString(recordPath).pipe( Effect.flatMap(decodeRecord), - Effect.catchTag("SchemaError", () => Effect.succeed(null)), + Effect.catchTags({ SchemaError: () => Effect.succeed(null) }), Effect.catchIf( (error) => error.reason._tag === "NotFound", () => Effect.succeed(null), diff --git a/vite.config.ts b/vite.config.ts index 789ac38e20b6..5fdca6eeec5d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -170,6 +170,7 @@ export default defineConfig({ "t3code/no-test-in-loop": "error", "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", + "t3code/prefer-catch-tags": "error", "t3code/require-suppression-reason": "error", }, overrides: [