authorgravatar for ethorelli@gmail.comErik Thorelli <ethorelli@gmail.com> 2026-10-05 21:49:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 21:49:13-07:00
logff397a29523d3c63e5844b11608c0189640c7561
treebd5179ac2bcd68f267b03c130c2adc7a8b6336d8
parentfad673225650193fad2a85487ecffc903f8ff30a
signature Signed by PGP key B5690EEEBB952194

chore(lint): catch known tags with Effect.catchTags (#16361)


21 files changed, 220 insertions(+), 85 deletions(-)

apps/desktop/src/backend/DesktopBackendManager.ts+1-1
......@@ -423,7 +423,7 @@ function drainBackendOutput(
423423 cause,
424424 }),
425425 ),
426 Effect.catchTag("BackendProcessOutputHandlingError", onOutputFailure),
426 Effect.catchTags({ BackendProcessOutputHandlingError: onOutputFailure }),
427427 ),
428428 ),
429429 Effect.catchTags({
apps/server/src/auth/http.ts+5-4
......@@ -223,7 +223,7 @@ export const layerAuthenticatedAuth = Layer.effect(
223223 }),
224224 session.subject === "cloud-connect" ? traceAuthenticatedRelayRequest : identity,
225225 );
226 }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized));
226 }).pipe(Effect.catchTags({ EnvironmentAuthInvalidError: appendDpopChallengeOnUnauthorized }));
227227 }),
228228);
229229
......@@ -474,9 +474,10 @@ export const layer = HttpApiBuilder.group(
474474 );
475475 return { revoked };
476476 },
477 Effect.catchTag("ServerAuthForbiddenOperationError", () =>
478 failEnvironmentOperationForbidden("current_session_revoke_not_allowed"),
479 ),
477 Effect.catchTags({
478 ServerAuthForbiddenOperationError: () =>
479 failEnvironmentOperationForbidden("current_session_revoke_not_allowed"),
480 }),
480481 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
481482 failEnvironmentInternal("client_session_revoke_failed", error),
482483 ),
apps/server/src/cli/connect.ts+6-5
......@@ -105,11 +105,12 @@ const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
105105 // A stored credential whose refresh fails (revoked, expired grant) must
106106 // fall through to a fresh device authorization, not dead-end the command.
107107 const existing = yield* tokens.getExisting.pipe(
108 Effect.catchTag("CloudCliCredentialRefreshError", () =>
109 Console.log(
110 "The stored T3 Connect credential could not be refreshed; signing in again.",
111 ).pipe(Effect.as(Option.none())),
112 ),
108 Effect.catchTags({
109 CloudCliCredentialRefreshError: () =>
110 Console.log(
111 "The stored T3 Connect credential could not be refreshed; signing in again.",
112 ).pipe(Effect.as(Option.none())),
113 }),
113114 );
114115 if (Option.isSome(existing)) {
115116 return existing.value.identity ?? null;
apps/server/src/cli/uninstall.ts+1-1
......@@ -162,7 +162,7 @@ const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: {
162162 }
163163 const confirmed = yield* Prompt.run(
164164 Prompt.Confirm({ message: "Remove t3 from this machine?", initial: false }),
165 ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false)));
165 ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) }));
166166 if (!confirmed) {
167167 yield* Console.log("Left as is.");
168168 return;
apps/server/src/cli/update.ts+2-2
......@@ -384,7 +384,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
384384 }
385385 const confirmed = yield* Prompt.run(
386386 Prompt.Confirm({ message: "Install the preview build anyway?", initial: false }),
387 ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false)));
387 ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) }));
388388 if (!confirmed) {
389389 yield* Console.log("Left as is.");
390390 return;
......@@ -473,7 +473,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
473473 message: "Restart the background service once the download is verified?",
474474 initial: true,
475475 }),
476 ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false)));
476 ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) }));
477477 } else {
478478 yield* Console.log(
479479 " Not a terminal, so the service keeps running its current version. Rerun with --yes to restart it now, or run `t3 service restart` later.",
apps/server/src/cloud/CliTokenManager.ts+6-6
......@@ -408,9 +408,9 @@ export const deviceAuthorizationLogin = Effect.fn("cloud.cli_token.device_author
408408 });
409409 return yield* pollDeviceToken(metadata, authorization.device_code, interval).pipe(
410410 Effect.timeout(expiresIn),
411 Effect.catchTag("TimeoutError", (cause) =>
412 Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
413 ),
411 Effect.catchTags({
412 TimeoutError: (cause) => Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
413 }),
414414 );
415415 },
416416);
......@@ -505,9 +505,9 @@ export const make = Effect.gen(function* () {
505505 authorizationUrl,
506506 callback: Deferred.await(callback).pipe(
507507 Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
508 Effect.catchTag("TimeoutError", (cause) =>
509 Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
510 ),
508 Effect.catchTags({
509 TimeoutError: (cause) => Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
510 }),
511511 ),
512512 terminal,
513513 launchBrowser: externalLauncher.launchBrowser,
apps/server/src/cloud/CloudLink.ts+3-3
......@@ -681,7 +681,7 @@ const make = Effect.gen(function* () {
681681 return proof satisfies RelayEnvironmentLinkProof;
682682 },
683683 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("generate-link-proof")),
684 Effect.catchTag("PlatformError", internalError("generate-link-proof")),
684 Effect.catchTags({ PlatformError: internalError("generate-link-proof") }),
685685 );
686686
687687 const activateManagedTunnel = Effect.fn("environment.cloud.activateManagedTunnel")(
......@@ -1546,7 +1546,7 @@ const make = Effect.gen(function* () {
15461546 } satisfies RelayEnvironmentHealthResponse;
15471547 },
15481548 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("answer-health")),
1549 Effect.catchTag("PlatformError", internalError("answer-health")),
1549 Effect.catchTags({ PlatformError: internalError("answer-health") }),
15501550 );
15511551
15521552 const mintCredential = Effect.fn("environment.cloud.mintCredential")(
......@@ -1653,7 +1653,7 @@ const make = Effect.gen(function* () {
16531653 } satisfies RelayEnvironmentMintResponse;
16541654 },
16551655 Effect.catchIf(ServerSecretStore.isSecretStoreError, internalError("issue-credential")),
1656 Effect.catchTag("PlatformError", internalError("issue-credential")),
1656 Effect.catchTags({ PlatformError: internalError("issue-credential") }),
16571657 );
16581658
16591659 return CloudLink.of({
apps/server/src/htmlRender/HtmlRender.ts+20-18
......@@ -351,26 +351,28 @@ const make = Effect.gen(function* () {
351351 browsers.withPermits(1)(
352352 // Read once a permit is held, so a queued call sees a fallback learned meanwhile.
353353 Effect.suspend(() => run(noSandbox)).pipe(
354 Effect.catchTag("HtmlRenderSandboxUnavailableError", () =>
355 Effect.logInfo(
356 "Chrome's sandbox is unavailable on this host; launching it without one.",
357 ).pipe(
358 Effect.andThen(
359 Effect.sync(() => {
360 noSandbox = true;
354 Effect.catchTags({
355 HtmlRenderSandboxUnavailableError: () =>
356 Effect.logInfo(
357 "Chrome's sandbox is unavailable on this host; launching it without one.",
358 ).pipe(
359 Effect.andThen(
360 Effect.sync(() => {
361 noSandbox = true;
362 }),
363 ),
364 Effect.andThen(run(true)),
365 ),
366 }),
367 Effect.catchTags({
368 HtmlRenderSandboxUnavailableError: (cause) =>
369 Effect.fail(
370 new HeadlessChrome.HtmlRenderBrowserError({
371 reason: "the browser has no sandbox",
372 cause,
361373 }),
362374 ),
363 Effect.andThen(run(true)),
364 ),
365 ),
366 Effect.catchTag("HtmlRenderSandboxUnavailableError", (cause) =>
367 Effect.fail(
368 new HeadlessChrome.HtmlRenderBrowserError({
369 reason: "the browser has no sandbox",
370 cause,
371 }),
372 ),
373 ),
375 }),
374376 ),
375377 );
376378
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts+12-10
......@@ -3011,16 +3011,18 @@ export function makeOpenCodeAdapterV2(
30113011 );
30123012 yield* Effect.raceFirst(Fiber.join(request), Deferred.await(receipt)).pipe(
30133013 Effect.timeout("10 seconds"),
3014 Effect.catchTag("TimeoutError", (cause) => {
3015 const error = new OpenCodeRuntime.OpenCodeRuntimeError({
3016 operation: "session.command",
3017 detail: "OpenCode command admission did not complete within 10 seconds.",
3018 cause,
3019 });
3020 abortController.abort();
3021 return finalizeTurn(state, turn, "failed", {
3022 failure: makeProviderFailure({ cause: error, class: "provider_error" }),
3023 }).pipe(Effect.andThen(Effect.fail(error)));
3014 Effect.catchTags({
3015 TimeoutError: (cause) => {
3016 const error = new OpenCodeRuntime.OpenCodeRuntimeError({
3017 operation: "session.command",
3018 detail: "OpenCode command admission did not complete within 10 seconds.",
3019 cause,
3020 });
3021 abortController.abort();
3022 return finalizeTurn(state, turn, "failed", {
3023 failure: makeProviderFailure({ cause: error, class: "provider_error" }),
3024 }).pipe(Effect.andThen(Effect.fail(error)));
3025 },
30243026 }),
30253027 );
30263028 });
apps/server/src/provider/acp/AcpRegistrySupport.ts+3-4
......@@ -1916,16 +1916,15 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct
19161916 ),
19171917 )
19181918 .pipe(
1919 Effect.catchTag(
1920 "ServerSettingsError",
1921 (cause) =>
1919 Effect.catchTags({
1920 ServerSettingsError: (cause) =>
19221921 new AcpRegistryError({
19231922 reason: "install_failed",
19241923 detail:
19251924 "Could not read provider settings while checking managed ACP binary references.",
19261925 cause,
19271926 }),
1928 ),
1927 }),
19291928 );
19301929
19311930 return AcpRegistryCatalog.of({
apps/server/src/pullRequest/PullRequestService.ts+1-1
......@@ -1578,7 +1578,7 @@ export const make = Effect.gen(function* () {
15781578 ? operation.pipe(Effect.provideService(routingCredential, identity), Effect.result)
15791579 : Effect.fail(rejected()),
15801580 )
1581 .pipe(Effect.catchTag("PullRequestProviderError", () => Effect.fail(rejected())));
1581 .pipe(Effect.catchTags({ PullRequestProviderError: () => Effect.fail(rejected()) }));
15821582 return yield* Effect.fromResult(result);
15831583 });
15841584
apps/server/src/workspace/WorkspaceSearchIndex.ts+8-7
......@@ -376,13 +376,14 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (
376376 cause,
377377 }),
378378 ).pipe(
379 Effect.catchTag("WorkspaceSearchIndexScanTimedOut", (error) =>
380 variant === "paths"
381 ? Effect.sync(() => {
382 initialScanTimedOut = true;
383 })
384 : Effect.fail(error),
385 ),
379 Effect.catchTags({
380 WorkspaceSearchIndexScanTimedOut: (error) =>
381 variant === "paths"
382 ? Effect.sync(() => {
383 initialScanTimedOut = true;
384 })
385 : Effect.fail(error),
386 }),
386387 );
387388
388389 const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning();
apps/server/src/ws.ts+10-9
......@@ -2697,15 +2697,16 @@ const layerWsRpc = (
26972697 status,
26982698 }),
26992699 ),
2700 Effect.catchTag("RelayClientInstallError", (error) =>
2701 Queue.fail(
2702 queue,
2703 new RelayClientInstallFailedError({
2704 reason: error.reason,
2705 message: error.message,
2706 }),
2707 ),
2708 ),
2700 Effect.catchTags({
2701 RelayClientInstallError: (error) =>
2702 Queue.fail(
2703 queue,
2704 new RelayClientInstallFailedError({
2705 reason: error.reason,
2706 message: error.message,
2707 }),
2708 ),
2709 }),
27092710 Effect.andThen(Queue.end(queue)),
27102711 Effect.forkScoped,
27112712 ),
oxlint-plugin-t3code/index.ts+2
......@@ -9,6 +9,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e
99import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
1010import noTestInLoop from "./rules/no-test-in-loop.ts";
1111import noUnscopedHas from "./rules/no-unscoped-has.ts";
12import preferCatchTags from "./rules/prefer-catch-tags.ts";
1213import requireSuppressionReason from "./rules/require-suppression-reason.ts";
1314
1415export default definePlugin({
......@@ -25,6 +26,7 @@ export default definePlugin({
2526 "no-native-title-tooltip": noNativeTitleTooltip,
2627 "no-test-in-loop": noTestInLoop,
2728 "no-unscoped-has": noUnscopedHas,
29 "prefer-catch-tags": preferCatchTags,
2830 "require-suppression-reason": requireSuppressionReason,
2931 },
3032});
oxlint-plugin-t3code/rules/prefer-catch-tags.test.ts created+64
......@@ -0,0 +1,64 @@
1import { assert, describe } from "@effect/vitest";
2
3import { createOxlintRuleHarness } from "../test/utils.ts";
4
5const rule = createOxlintRuleHarness("t3code/prefer-catch-tags");
6
7describe("t3code/prefer-catch-tags", () => {
8 rule.valid(
9 "allows Effect.catchTags",
10 `
11 import * as Effect from "effect/Effect";
12
13 export const program = Effect.fail({ _tag: "A" } as const).pipe(
14 Effect.catchTags({ A: () => Effect.void }),
15 );
16 `,
17 );
18
19 rule.valid(
20 "ignores catchTag on other modules",
21 `
22 import * as Stream from "effect/Stream";
23
24 export const recover = Stream.catchTag("A", () => Stream.empty);
25 `,
26 );
27
28 rule.valid(
29 "ignores a local that shadows the namespace",
30 `
31 import * as Effect from "effect/Effect";
32
33 export const program = Effect.void;
34 export function recover(Effect: { catchTag: (tag: string) => void }) {
35 Effect.catchTag("A");
36 }
37 `,
38 );
39
40 rule.invalid(
41 "reports Effect.catchTag",
42 `
43 import * as Effect from "effect/Effect";
44
45 export const program = Effect.fail({ _tag: "A" } as const).pipe(
46 Effect.catchTag("A", () => Effect.void),
47 );
48 `,
49 (output) => {
50 assert.match(output, /Effect\.catchTags\(\{ Tag: handler \}\)/);
51 },
52 );
53
54 rule.invalid(
55 "reports catchTag through an aliased namespace",
56 `
57 import * as Eff from "effect/Effect";
58
59 export const recover = Eff.catchTag("A", () => Eff.void);
60 `,
61 );
62
63 rule.invalid("reports a named catchTag import", `import { catchTag } from "effect/Effect";`);
64});
oxlint-plugin-t3code/rules/prefer-catch-tags.ts created+60
......@@ -0,0 +1,60 @@
1import { defineRule, type ESTree } from "@oxlint/plugins";
2
3const MESSAGE = "Catch known tags with `Effect.catchTags({ Tag: handler })`, even for one tag.";
4
5/** Reports `Effect.catchTag`, through any namespace import of `effect/Effect` or a named import. */
6export default defineRule({
7 meta: {
8 type: "suggestion",
9 docs: {
10 description: "Require `Effect.catchTags` over `Effect.catchTag`.",
11 },
12 },
13 create(context) {
14 // Whether `identifier` is bound by `import * as X from "effect/Effect"`, not a shadowing local.
15 const isEffectNamespace = (identifier: ESTree.IdentifierReference) => {
16 let scope = context.sourceCode.getScope(identifier);
17 while (true) {
18 const variable = scope.set.get(identifier.name);
19 if (variable !== undefined) {
20 return variable.defs.some(
21 (def) =>
22 def.type === "ImportBinding" &&
23 def.node.type === "ImportNamespaceSpecifier" &&
24 def.parent?.type === "ImportDeclaration" &&
25 def.parent.source.value === "effect/Effect",
26 );
27 }
28 if (scope.upper === null) return false;
29 scope = scope.upper;
30 }
31 };
32
33 return {
34 ImportDeclaration(node) {
35 if (node.source.value !== "effect/Effect") return;
36 for (const specifier of node.specifiers) {
37 if (
38 specifier.type === "ImportSpecifier" &&
39 (specifier.imported.type === "Identifier"
40 ? specifier.imported.name
41 : specifier.imported.value) === "catchTag"
42 ) {
43 context.report({ node: specifier, message: MESSAGE });
44 }
45 }
46 },
47 MemberExpression(node) {
48 if (
49 !node.computed &&
50 node.object.type === "Identifier" &&
51 node.property.type === "Identifier" &&
52 node.property.name === "catchTag" &&
53 isEffectNamespace(node.object)
54 ) {
55 context.report({ node, message: MESSAGE });
56 }
57 },
58 };
59 },
60});
packages/client-runtime/src/connection/registry.ts+3-3
......@@ -482,7 +482,7 @@ export const make = Effect.gen(function* () {
482482 persistedRoutesByEnvironment.keys(),
483483 (environmentId) =>
484484 acquireSupervisor(environmentId).pipe(
485 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
485 Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void }),
486486 ),
487487 {
488488 concurrency: "unbounded",
......@@ -999,7 +999,7 @@ export const make = Effect.gen(function* () {
999999 relayEnvironmentIds,
10001000 (environmentId) =>
10011001 removeRoute(environmentId, RELAY_ROUTE_ID).pipe(
1002 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
1002 Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void }),
10031003 ),
10041004 {
10051005 concurrency: "unbounded",
......@@ -1012,7 +1012,7 @@ export const make = Effect.gen(function* () {
10121012 const retryNow = (environmentId: EnvironmentId) =>
10131013 acquireSupervisor(environmentId).pipe(
10141014 Effect.flatMap((supervisor) => supervisor.retryNow),
1015 Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void),
1015 Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void }),
10161016 Effect.withSpan("EnvironmentRegistry.retryNow"),
10171017 );
10181018 const setEnabled = Effect.fn("EnvironmentRegistry.setEnabled")(function* (
packages/shared/src/relayClient.ts+10-9
......@@ -379,15 +379,16 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function
379379 );
380380 yield* report("waiting_for_lock");
381381 yield* acquireInstallLock(lockPath).pipe(
382 Effect.catchTag("PlatformError", (cause) =>
383 Effect.fail(
384 new RelayClientInstallError({
385 reason: "write_failed",
386 message: "Could not acquire the relay client installation lock.",
387 cause,
388 }),
389 ),
390 ),
382 Effect.catchTags({
383 PlatformError: (cause) =>
384 Effect.fail(
385 new RelayClientInstallError({
386 reason: "write_failed",
387 message: "Could not acquire the relay client installation lock.",
388 cause,
389 }),
390 ),
391 }),
391392 );
392393 return yield* Effect.gen(function* () {
393394 const afterLock = yield* resolve;
packages/shared/src/shell.ts+1-1
......@@ -756,7 +756,7 @@ export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function
756756) {
757757 return yield* resolveCommandPath(command, options).pipe(
758758 Effect.as(true),
759 Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)),
759 Effect.catchTags({ CommandResolutionError: () => Effect.succeed(false) }),
760760 );
761761});
762762
scripts/mobile-native-client.ts+1-1
......@@ -316,7 +316,7 @@ const main = Command.make(
316316 installedBinary: installedBinary(platform, device),
317317 readRecord: fs.readFileString(recordPath).pipe(
318318 Effect.flatMap(decodeRecord),
319 Effect.catchTag("SchemaError", () => Effect.succeed(null)),
319 Effect.catchTags({ SchemaError: () => Effect.succeed(null) }),
320320 Effect.catchIf(
321321 (error) => error.reason._tag === "NotFound",
322322 () => Effect.succeed(null),
vite.config.ts+1
......@@ -170,6 +170,7 @@ export default defineConfig({
170170 "t3code/no-test-in-loop": "error",
171171 "t3code/no-unscoped-has": "error",
172172 "t3code/namespace-node-imports": "error",
173 "t3code/prefer-catch-tags": "error",
173174 "t3code/require-suppression-reason": "error",
174175 },
175176 overrides: [