Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/src/backend/DesktopBackendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ function drainBackendOutput(
cause,
}),
),
Effect.catchTag("BackendProcessOutputHandlingError", onOutputFailure),
Effect.catchTags({ BackendProcessOutputHandlingError: onOutputFailure }),
),
),
Effect.catchTags({
Expand Down
9 changes: 5 additions & 4 deletions apps/server/src/auth/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}),
);

Expand Down Expand Up @@ -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),
),
Expand Down
11 changes: 6 additions & 5 deletions apps/server/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/cli/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/cli/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.",
Expand Down
12 changes: 6 additions & 6 deletions apps/server/src/cloud/CliTokenManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
}),
);
},
);
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/cloud/CloudLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")(
Expand Down Expand Up @@ -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")(
Expand Down Expand Up @@ -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({
Expand Down
38 changes: 20 additions & 18 deletions apps/server/src/htmlRender/HtmlRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
),
),
}),
),
);

Expand Down
22 changes: 12 additions & 10 deletions apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
},
}),
);
});
Expand Down
7 changes: 3 additions & 4 deletions apps/server/src/provider/acp/AcpRegistrySupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
15 changes: 8 additions & 7 deletions apps/server/src/workspace/WorkspaceSearchIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 10 additions & 9 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
2 changes: 2 additions & 0 deletions oxlint-plugin-t3code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
},
});
52 changes: 52 additions & 0 deletions oxlint-plugin-t3code/rules/prefer-catch-tags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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.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";`);
});
44 changes: 44 additions & 0 deletions oxlint-plugin-t3code/rules/prefer-catch-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { defineRule } 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) {
const effectNamespaces = new Set<string>();
return {
ImportDeclaration(node) {
if (node.source.value !== "effect/Effect") return;
for (const specifier of node.specifiers) {
if (specifier.type === "ImportNamespaceSpecifier") {
effectNamespaces.add(specifier.local.name);
} else 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" &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium rules/prefer-catch-tags.ts:34

The rule reports Effect.catchTag() on a shadowing parameter or local variable, so valid code such as function f(Effect) { Effect.catchTag(); } fails CI even though the access is not the imported namespace. The name-only effectNamespaces check must resolve the Effect identifier to its import binding before reporting.

🤖 Copy this AI Prompt to have your agent fix this:
In file @oxlint-plugin-t3code/rules/prefer-catch-tags.ts around line 34:

The rule reports `Effect.catchTag()` on a shadowing parameter or local variable, so valid code such as `function f(Effect) { Effect.catchTag(); }` fails CI even though the access is not the imported namespace. The name-only `effectNamespaces` check must resolve the `Effect` identifier to its import binding before reporting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Fixed in 46b9b3c: the rule now resolves the identifier through scope and reports only when it's bound by import * as … from "effect/Effect", so a shadowing parameter or local isn't flagged. Added a test for it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

effectNamespaces.has(node.object.name) &&
node.property.type === "Identifier" &&
node.property.name === "catchTag"
) {
context.report({ node, message: MESSAGE });
}
},
};
},
});
Loading
Loading