diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc
index 1023de02b..50a87303c 100644
--- a/.oxlintrc.jsonc
+++ b/.oxlintrc.jsonc
@@ -1,5 +1,6 @@
{
"plugins": ["react"],
+ "jsPlugins": ["./scripts/oxlint-plugin-executor/index.js"],
"rules": {
"typescript/no-explicit-any": "error",
"react/forbid-elements": [
@@ -16,5 +17,27 @@
},
],
},
+ "overrides": [
+ {
+ "files": ["packages/plugins/workos-vault/src/**/*.{ts,tsx}"],
+ "rules": {
+ "eslint/no-nested-ternary": "error",
+ "executor/no-inline-object-type-assertion": "error",
+ "executor/no-instanceof-tagged-error": "error",
+ "executor/no-manual-tag-check": "error",
+ "executor/no-promise-client-surface": "error",
+ "executor/no-raw-error-throw": "error",
+ "executor/no-redundant-error-factory": "error",
+ "executor/no-unknown-shape-probing": "error",
+ },
+ },
+ {
+ "files": ["packages/plugins/workos-vault/src/**/*.{test,spec}.{ts,tsx}"],
+ "rules": {
+ "executor/no-if-in-tests": "error",
+ "executor/no-vitest-import": "error",
+ },
+ },
+ ],
"ignorePatterns": [".astro/", "**/routeTree.gen.ts", ".references/"],
}
diff --git a/packages/plugins/workos-vault/src/sdk/client.ts b/packages/plugins/workos-vault/src/sdk/client.ts
index 961ba5e40..a2684e6fd 100644
--- a/packages/plugins/workos-vault/src/sdk/client.ts
+++ b/packages/plugins/workos-vault/src/sdk/client.ts
@@ -1,5 +1,9 @@
import type { WorkOS } from "@workos-inc/node/worker";
-import { WorkOS as WorkOSClient } from "@workos-inc/node/worker";
+import {
+ GenericServerException,
+ NotFoundException,
+ WorkOS as WorkOSClient,
+} from "@workos-inc/node/worker";
import { Data, Effect } from "effect";
export interface WorkOSVaultObjectMetadata {
@@ -18,8 +22,32 @@ export interface WorkOSVaultObject {
export class WorkOSVaultClientError extends Data.TaggedError("WorkOSVaultClientError")<{
readonly cause: unknown;
+ readonly message: string;
readonly operation: string;
-}> {}
+ readonly status?: number;
+}> {
+ constructor(options: {
+ readonly cause: unknown;
+ readonly message?: string;
+ readonly operation: string;
+ readonly status?: number;
+ }) {
+ super({
+ cause: options.cause,
+ message: options.message ?? messageFromCause(options.cause),
+ operation: options.operation,
+ status: options.status ?? statusFromWorkOSCause(options.cause),
+ });
+ }
+}
+
+const statusFromWorkOSCause = (cause: unknown): number | undefined =>
+ cause instanceof GenericServerException || cause instanceof NotFoundException
+ ? cause.status
+ : undefined;
+
+const messageFromCause = (cause: unknown): string =>
+ cause instanceof Error ? cause.message : String(cause);
export class WorkOSVaultClientInstantiationError extends Data.TaggedError(
"WorkOSVaultClientInstantiationError",
@@ -27,7 +55,7 @@ export class WorkOSVaultClientInstantiationError extends Data.TaggedError(
readonly cause: unknown;
}> {}
-export interface WorkOSVaultSdk {
+interface WorkOSVaultSdk {
readonly createObject: (options: {
readonly name: string;
readonly value: string;
@@ -48,10 +76,6 @@ export interface WorkOSVaultCredentials {
}
export interface WorkOSVaultClient {
- readonly use: (
- operation: string,
- fn: (client: WorkOSVaultSdk) => Promise,
- ) => Effect.Effect;
readonly createObject: (options: {
readonly name: string;
readonly value: string;
@@ -85,7 +109,6 @@ export const makeWorkOSVaultClient = (
}).pipe(Effect.withSpan(`workos_vault.${operation}`));
return {
- use,
createObject: (options) => use("create_object", (vault) => vault.createObject(options)),
readObjectByName: (name) => use("read_object_by_name", (vault) => vault.readObjectByName(name)),
updateObject: (options) => use("update_object", (vault) => vault.updateObject(options)),
diff --git a/packages/plugins/workos-vault/src/sdk/index.ts b/packages/plugins/workos-vault/src/sdk/index.ts
index 2279a2510..9cb7082ed 100644
--- a/packages/plugins/workos-vault/src/sdk/index.ts
+++ b/packages/plugins/workos-vault/src/sdk/index.ts
@@ -7,7 +7,6 @@ export {
type WorkOSVaultCredentials,
type WorkOSVaultObject,
type WorkOSVaultObjectMetadata,
- type WorkOSVaultSdk,
} from "./client";
export {
workosVaultPlugin,
diff --git a/packages/plugins/workos-vault/src/sdk/secret-store.test.ts b/packages/plugins/workos-vault/src/sdk/secret-store.test.ts
index 5dc782a35..15e9bcc74 100644
--- a/packages/plugins/workos-vault/src/sdk/secret-store.test.ts
+++ b/packages/plugins/workos-vault/src/sdk/secret-store.test.ts
@@ -12,10 +12,15 @@ import {
ScopeId,
SecretId,
SetSecretInput,
+ typedAdapter,
} from "@executor/sdk";
import { type WorkOSVaultClient } from "./client";
import { workosVaultPlugin } from "./plugin";
+import {
+ makeWorkosVaultStore,
+ type WorkosVaultSchema,
+} from "./secret-store";
import { makeTestWorkOSVaultClient } from "./testing";
const makeExecutor = (client: WorkOSVaultClient) =>
@@ -180,6 +185,9 @@ const makeLayeredExecutors = (client: WorkOSVaultClient) =>
const plugins = [workosVaultPlugin({ client })] as const;
const schema = collectSchemas(plugins);
const adapter = makeMemoryAdapter({ schema });
+ const metadataStore = makeWorkosVaultStore({
+ adapter: typedAdapter(adapter),
+ });
const blobs = makeInMemoryBlobStore();
const outerId = ScopeId.make("org");
@@ -207,7 +215,7 @@ const makeLayeredExecutors = (client: WorkOSVaultClient) =>
blobs,
plugins,
});
- return { execOuter, execInner, outerId, innerId, adapter };
+ return { execOuter, execInner, outerId, innerId, metadataStore };
});
describe("WorkOS Vault secret provider — multi-scope isolation", () => {
@@ -274,7 +282,7 @@ describe("WorkOS Vault secret provider — multi-scope isolation", () => {
// just the SDK's defensive shielding.
Effect.gen(function* () {
const client = makeTestWorkOSVaultClient();
- const { execOuter, execInner, outerId, innerId, adapter } =
+ const { execOuter, execInner, outerId, innerId, metadataStore } =
yield* makeLayeredExecutors(client);
yield* execOuter.secrets.set(
@@ -294,11 +302,11 @@ describe("WorkOS Vault secret provider — multi-scope isolation", () => {
}),
);
- const rows = yield* adapter.findMany({
- model: "workos_vault_metadata",
- where: [{ field: "id", value: "api-token" }],
- });
- const scopes = rows.map((r) => (r as { scope_id: string }).scope_id).sort();
+ const rows = yield* metadataStore.list();
+ const scopes = rows
+ .filter((row) => row.id === "api-token")
+ .map((row) => row.scope_id)
+ .sort();
expect(scopes).toEqual([outerId, innerId].sort());
}),
);
diff --git a/packages/plugins/workos-vault/src/sdk/secret-store.ts b/packages/plugins/workos-vault/src/sdk/secret-store.ts
index cc91fd710..3d0fef929 100644
--- a/packages/plugins/workos-vault/src/sdk/secret-store.ts
+++ b/packages/plugins/workos-vault/src/sdk/secret-store.ts
@@ -1,5 +1,4 @@
import { Effect } from "effect";
-import { GenericServerException, NotFoundException } from "@workos-inc/node/worker";
import {
defineSchema,
@@ -72,11 +71,9 @@ export interface WorkosVaultStore {
readonly list: () => Effect.Effect;
}
-export const makeWorkosVaultStore = (
- deps: StorageDeps,
-): WorkosVaultStore => {
- const { adapter: db } = deps;
-
+export const makeWorkosVaultStore = ({
+ adapter: db,
+}: Pick, "adapter">): WorkosVaultStore => {
// Every read/write to a specific row pins BOTH `id` and `scope_id`.
// The store runs behind the SDK's scoped adapter (which auto-injects
// `scope_id IN (stack)`), so a bare `{id}` filter resolves to any
@@ -155,35 +152,11 @@ export const makeWorkosVaultStore = (
// Vault helpers — scope-prefixed object naming + 409-retry upsert.
// ---------------------------------------------------------------------------
-const unwrapVaultError = (error: unknown): unknown =>
- error instanceof WorkOSVaultClientError ? error.cause : error;
-
-const isStatusError = (error: unknown, status: number): boolean => {
- const cause = unwrapVaultError(error);
- return (
- ((cause instanceof GenericServerException ||
- cause instanceof NotFoundException) &&
- cause.status === status) ||
- (typeof cause === "object" &&
- cause !== null &&
- "status" in cause &&
- typeof (cause as { status: unknown }).status === "number" &&
- (cause as { status: number }).status === status)
- );
-};
+const isStatusError = (error: WorkOSVaultClientError, status: number): boolean =>
+ error.status === status;
-const isKekNotReadyError = (error: unknown): boolean => {
- const cause = unwrapVaultError(error);
- const message =
- cause instanceof Error
- ? cause.message
- : typeof cause === "string"
- ? cause
- : typeof cause === "object" && cause !== null && "message" in cause
- ? String((cause as { message: unknown }).message)
- : "";
- return message.includes("KEK was created but is not yet ready");
-};
+const isKekNotReadyError = (error: WorkOSVaultClientError): boolean =>
+ error.message.includes("KEK was created but is not yet ready");
// Default context builder. Each semantic piece of a scope id lives in
// its own vault-context key so WorkOS's KEK matcher sees individual
@@ -246,11 +219,12 @@ const loadSecretObject = (
if (legacyName === encodedName) return Effect.succeed(null);
return client.readObjectByName(legacyName).pipe(
- Effect.catchAll((legacyError) =>
- isStatusError(legacyError, 404) || isStatusError(legacyError, 400)
- ? Effect.succeed(null)
- : Effect.fail(legacyError),
- ),
+ Effect.catchAll((legacyError) => {
+ if (isStatusError(legacyError, 404) || isStatusError(legacyError, 400)) {
+ return Effect.succeed(null);
+ }
+ return Effect.fail(legacyError);
+ }),
);
}),
);
@@ -327,11 +301,8 @@ const deleteSecretValue = (
return true;
});
-const formatVaultError = (error: unknown): StorageError => {
- const cause = unwrapVaultError(error);
- const message = cause instanceof Error ? cause.message : String(cause);
- return new StorageError({ message, cause });
-};
+const formatVaultError = (error: WorkOSVaultClientError): StorageError =>
+ new StorageError({ message: error.message, cause: error.cause });
// ---------------------------------------------------------------------------
// makeWorkOSVaultSecretProvider — builds a SecretProvider backed by
diff --git a/packages/plugins/workos-vault/src/sdk/testing.ts b/packages/plugins/workos-vault/src/sdk/testing.ts
index b4433adaf..1a3298682 100644
--- a/packages/plugins/workos-vault/src/sdk/testing.ts
+++ b/packages/plugins/workos-vault/src/sdk/testing.ts
@@ -5,7 +5,6 @@ import {
type WorkOSVaultClient,
type WorkOSVaultObject,
type WorkOSVaultObjectMetadata,
- type WorkOSVaultSdk,
} from "./client";
export class TestWorkOSVaultNotFoundError extends Data.TaggedError("TestWorkOSVaultNotFoundError")<{
@@ -170,23 +169,19 @@ export const makeTestWorkOSVaultClient = (
effect: Effect.Effect,
): Effect.Effect =>
effect.pipe(
- Effect.mapError((cause) => new WorkOSVaultClientError({ cause, operation })),
+ Effect.mapError(
+ (cause) =>
+ new WorkOSVaultClientError({
+ cause,
+ message: cause.message,
+ operation,
+ status: cause.status,
+ }),
+ ),
Effect.withSpan(`workos_vault.test.${operation}`),
);
- const rawClient: WorkOSVaultSdk = {
- createObject: (options) => Effect.runPromise(createObject(options)),
- readObjectByName: (name) => Effect.runPromise(readObjectByName(name)),
- updateObject: (options) => Effect.runPromise(updateObject(options)),
- deleteObject: (options) => Effect.runPromise(deleteObject(options)),
- };
-
return {
- use: (operation, fn) =>
- Effect.tryPromise({
- try: () => fn(rawClient),
- catch: (cause) => new WorkOSVaultClientError({ cause, operation }),
- }).pipe(Effect.withSpan(`workos_vault.test.${operation}`)),
createObject: (options) => wrap("create_object", createObject(options)),
readObjectByName: (name) => wrap("read_object_by_name", readObjectByName(name)),
updateObject: (options) => wrap("update_object", updateObject(options)),
diff --git a/packages/plugins/workos-vault/src/sdk/workos-vault.contract.test.ts b/packages/plugins/workos-vault/src/sdk/workos-vault.contract.test.ts
index 21e0657b5..2394e9444 100644
--- a/packages/plugins/workos-vault/src/sdk/workos-vault.contract.test.ts
+++ b/packages/plugins/workos-vault/src/sdk/workos-vault.contract.test.ts
@@ -11,28 +11,10 @@ const hasWorkOSDevCredentials =
Boolean(process.env.WORKOS_API_KEY) && Boolean(process.env.WORKOS_CLIENT_ID);
const contractRunEnabled = process.env.WORKOS_VAULT_CONTRACT === "1";
-const unwrapVaultError = (error: unknown): unknown =>
- error instanceof WorkOSVaultClientError ? error.cause : error;
+const statusOf = (error: WorkOSVaultClientError): number | undefined =>
+ error.status;
-const statusOf = (error: unknown): number | undefined => {
- const cause = unwrapVaultError(error);
- if (typeof cause !== "object" || cause === null || !("status" in cause)) {
- return undefined;
- }
- const status = Reflect.get(cause, "status");
- return typeof status === "number" ? status : undefined;
-};
-
-const messageOf = (error: unknown): string => {
- const cause = unwrapVaultError(error);
- if (cause instanceof Error) {
- return cause.message;
- }
- if (typeof cause === "object" && cause !== null && "message" in cause) {
- return String(Reflect.get(cause, "message"));
- }
- return String(cause);
-};
+const messageOf = (error: WorkOSVaultClientError): string => error.message;
const makeClient = (): Effect.Effect =>
makeConfiguredWorkOSVaultClient({
@@ -60,85 +42,91 @@ const candidateString = FastCheck.string({
),
);
+const contractSeed = (): number | undefined =>
+ process.env.WORKOS_VAULT_CONTRACT_SEED === undefined
+ ? undefined
+ : Number(process.env.WORKOS_VAULT_CONTRACT_SEED);
+
+const skipContractTest = Effect.sync(() =>
+ console.warn(
+ "[workos-vault contract] skipping: run `bun run test:contract:workos-vault` with WORKOS_API_KEY and WORKOS_CLIENT_ID",
+ ),
+);
+
describe("WorkOS Vault contract", () => {
it.effect(
"discovers object-name constraints against the dev Vault API",
() =>
- Effect.gen(function* () {
- if (!contractRunEnabled || !hasWorkOSDevCredentials) {
- console.warn(
- "[workos-vault contract] skipping: run `bun run test:contract:workos-vault` with WORKOS_API_KEY and WORKOS_CLIENT_ID",
- );
- return;
- }
-
- const client = yield* makeClient();
- const runId = `${Date.now()}-${crypto.randomUUID()}`;
- const accepted: string[] = [];
- const rejected: Array<{
- readonly name: string;
- readonly status: number | undefined;
- readonly message: string;
- }> = [];
-
- yield* Effect.promise(() =>
- FastCheck.assert(
- FastCheck.asyncProperty(candidateString, async (candidate) => {
- const name = generatedName(runId, candidate);
- const result = await Effect.runPromise(
- Effect.either(
- client.createObject({
- name,
- value: "contract-test",
- context: {
- app: "executor",
- contract_test_run_id: runId,
+ !contractRunEnabled || !hasWorkOSDevCredentials
+ ? skipContractTest
+ : Effect.gen(function* () {
+ const client = yield* makeClient();
+ const runId = `${Date.now()}-${crypto.randomUUID()}`;
+ const accepted: string[] = [];
+ const rejected: Array<{
+ readonly name: string;
+ readonly status: number | undefined;
+ readonly message: string;
+ }> = [];
+
+ yield* Effect.promise(() =>
+ FastCheck.assert(
+ FastCheck.asyncProperty(candidateString, async (candidate) => {
+ const name = generatedName(runId, candidate);
+ const result = await Effect.runPromise(
+ Effect.either(
+ client.createObject({
+ name,
+ value: "contract-test",
+ context: {
+ app: "executor",
+ contract_test_run_id: runId,
+ },
+ }),
+ ),
+ );
+
+ return Either.match(result, {
+ onRight: async (object) => {
+ accepted.push(name);
+ await Effect.runPromise(
+ client.deleteObject({ id: object.id }).pipe(Effect.ignore),
+ );
+ return true;
},
- }),
- ),
- );
-
- if (Either.isRight(result)) {
- accepted.push(name);
- await Effect.runPromise(
- client.deleteObject({ id: result.right.id }).pipe(Effect.ignore),
- );
- return true;
- }
-
- const status = statusOf(result.left);
- rejected.push({ name, status, message: messageOf(result.left) });
+ onLeft: (error) => {
+ const status = statusOf(error);
+ rejected.push({ name, status, message: messageOf(error) });
- // Contract-discovery failures are expected to be validation
- // style rejections. Anything else should stop the run.
- return status === 400 || status === 409;
- }),
- {
- numRuns: Number(process.env.WORKOS_VAULT_CONTRACT_RUNS ?? 40),
- seed:
- process.env.WORKOS_VAULT_CONTRACT_SEED === undefined
- ? undefined
- : Number(process.env.WORKOS_VAULT_CONTRACT_SEED),
- },
- ),
- );
-
- console.info(
- JSON.stringify(
- {
- runId,
- acceptedCount: accepted.length,
- rejectedCount: rejected.length,
- acceptedExamples: accepted.slice(0, 10),
- rejectedExamples: rejected.slice(0, 20),
- },
- null,
- 2,
- ),
- );
-
- expect(accepted.length + rejected.length).toBeGreaterThan(0);
- }),
+ // Contract-discovery failures are expected to be validation
+ // style rejections. Anything else should stop the run.
+ return status === 400 || status === 409;
+ },
+ });
+ }),
+ {
+ numRuns: Number(process.env.WORKOS_VAULT_CONTRACT_RUNS ?? 40),
+ seed: contractSeed(),
+ },
+ ),
+ );
+
+ console.info(
+ JSON.stringify(
+ {
+ runId,
+ acceptedCount: accepted.length,
+ rejectedCount: rejected.length,
+ acceptedExamples: accepted.slice(0, 10),
+ rejectedExamples: rejected.slice(0, 20),
+ },
+ null,
+ 2,
+ ),
+ );
+
+ expect(accepted.length + rejected.length).toBeGreaterThan(0);
+ }),
60_000,
);
});
diff --git a/scripts/oxlint-plugin-executor/index.js b/scripts/oxlint-plugin-executor/index.js
new file mode 100644
index 000000000..6c10819c8
--- /dev/null
+++ b/scripts/oxlint-plugin-executor/index.js
@@ -0,0 +1,26 @@
+import { noInlineObjectTypeAssertion } from "./rules/no-inline-object-type-assertion.js";
+import { noIfInTests } from "./rules/no-if-in-tests.js";
+import { noInstanceofTaggedError } from "./rules/no-instanceof-tagged-error.js";
+import { noManualTagCheck } from "./rules/no-manual-tag-check.js";
+import { noPromiseClientSurface } from "./rules/no-promise-client-surface.js";
+import { noRawErrorThrow } from "./rules/no-raw-error-throw.js";
+import { noRedundantErrorFactory } from "./rules/no-redundant-error-factory.js";
+import { noUnknownShapeProbing } from "./rules/no-unknown-shape-probing.js";
+import { noVitestImport } from "./rules/no-vitest-import.js";
+
+export default {
+ meta: {
+ name: "executor",
+ },
+ rules: {
+ "no-inline-object-type-assertion": noInlineObjectTypeAssertion,
+ "no-if-in-tests": noIfInTests,
+ "no-instanceof-tagged-error": noInstanceofTaggedError,
+ "no-manual-tag-check": noManualTagCheck,
+ "no-promise-client-surface": noPromiseClientSurface,
+ "no-raw-error-throw": noRawErrorThrow,
+ "no-redundant-error-factory": noRedundantErrorFactory,
+ "no-unknown-shape-probing": noUnknownShapeProbing,
+ "no-vitest-import": noVitestImport,
+ },
+};
diff --git a/scripts/oxlint-plugin-executor/rules/no-if-in-tests.js b/scripts/oxlint-plugin-executor/rules/no-if-in-tests.js
new file mode 100644
index 000000000..5fc3ab1d8
--- /dev/null
+++ b/scripts/oxlint-plugin-executor/rules/no-if-in-tests.js
@@ -0,0 +1,22 @@
+const message =
+ "Do not use if statements in tests. Prefer Effect branching/matching helpers and effect/vitest assertions.";
+
+export const noIfInTests = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: message,
+ },
+ messages: {
+ noIfInTests: message,
+ },
+ schema: [],
+ },
+ create(context) {
+ return {
+ IfStatement(node) {
+ context.report({ node, messageId: "noIfInTests" });
+ },
+ };
+ },
+};
diff --git a/scripts/oxlint-plugin-executor/rules/no-inline-object-type-assertion.js b/scripts/oxlint-plugin-executor/rules/no-inline-object-type-assertion.js
new file mode 100644
index 000000000..2a80cdfef
--- /dev/null
+++ b/scripts/oxlint-plugin-executor/rules/no-inline-object-type-assertion.js
@@ -0,0 +1,44 @@
+import { isIdentifier } from "../utils/ast.js";
+
+const message =
+ "Do not assert against inline object-shaped types. Use a named type, Schema, or a proper type guard.";
+
+const isUnknownKeyword = (node) => node?.type === "TSUnknownKeyword";
+
+const isStringKey = (node) =>
+ node?.type === "TSStringKeyword" ||
+ (node?.type === "TSLiteralType" && typeof node.literal?.value === "string");
+
+const isRecordUnknown = (node) =>
+ node?.type === "TSTypeReference" &&
+ isIdentifier(node.typeName, "Record") &&
+ node.typeArguments?.params?.length === 2 &&
+ isStringKey(node.typeArguments.params[0]) &&
+ isUnknownKeyword(node.typeArguments.params[1]);
+
+const isBannedType = (node) => node?.type === "TSTypeLiteral" || isRecordUnknown(node);
+
+export const noInlineObjectTypeAssertion = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: message,
+ },
+ messages: {
+ inlineObjectTypeAssertion: message,
+ },
+ schema: [],
+ },
+ create(context) {
+ const check = (node) => {
+ if (isBannedType(node.typeAnnotation)) {
+ context.report({ node, messageId: "inlineObjectTypeAssertion" });
+ }
+ };
+
+ return {
+ TSAsExpression: check,
+ TSTypeAssertion: check,
+ };
+ },
+};
diff --git a/scripts/oxlint-plugin-executor/rules/no-instanceof-tagged-error.js b/scripts/oxlint-plugin-executor/rules/no-instanceof-tagged-error.js
new file mode 100644
index 000000000..2480ee483
--- /dev/null
+++ b/scripts/oxlint-plugin-executor/rules/no-instanceof-tagged-error.js
@@ -0,0 +1,31 @@
+import { isIdentifier, nodeName } from "../utils/ast.js";
+
+const message =
+ "Do not use instanceof for tagged errors. Use Effect.catchTag, Effect.catchTags, or a _tag-based guard.";
+
+const looksLikeTaggedErrorName = (name) =>
+ typeof name === "string" && name !== "Error" && name.endsWith("Error");
+
+export const noInstanceofTaggedError = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: message,
+ },
+ messages: {
+ noInstanceofTaggedError: message,
+ },
+ schema: [],
+ },
+ create(context) {
+ return {
+ BinaryExpression(node) {
+ if (node.operator !== "instanceof") return;
+ const rightName = nodeName(node.right);
+ if (isIdentifier(node.right) && looksLikeTaggedErrorName(rightName)) {
+ context.report({ node, messageId: "noInstanceofTaggedError" });
+ }
+ },
+ };
+ },
+};
diff --git a/scripts/oxlint-plugin-executor/rules/no-manual-tag-check.js b/scripts/oxlint-plugin-executor/rules/no-manual-tag-check.js
new file mode 100644
index 000000000..81229acfc
--- /dev/null
+++ b/scripts/oxlint-plugin-executor/rules/no-manual-tag-check.js
@@ -0,0 +1,29 @@
+import { isIdentifier, isStringLiteral } from "../utils/ast.js";
+
+const message =
+ "Do not inspect _tag manually. Use Effect.catchTag, Effect.catchTags, Predicate.isTagged, or another Effect tagged-error API.";
+
+const isTagProperty = (node) =>
+ isIdentifier(node, "_tag") || (isStringLiteral(node) && node.value === "_tag");
+
+export const noManualTagCheck = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: message,
+ },
+ messages: {
+ noManualTagCheck: message,
+ },
+ schema: [],
+ },
+ create(context) {
+ return {
+ MemberExpression(node) {
+ if (isTagProperty(node.property)) {
+ context.report({ node, messageId: "noManualTagCheck" });
+ }
+ },
+ };
+ },
+};
diff --git a/scripts/oxlint-plugin-executor/rules/no-promise-client-surface.js b/scripts/oxlint-plugin-executor/rules/no-promise-client-surface.js
new file mode 100644
index 000000000..e330421c6
--- /dev/null
+++ b/scripts/oxlint-plugin-executor/rules/no-promise-client-surface.js
@@ -0,0 +1,44 @@
+import { containsPromiseType, nodeName } from "../utils/ast.js";
+
+const message =
+ "Do not expose Promise-shaped client surfaces. Wrap third-party SDK promises at the adapter boundary and expose Effect methods.";
+
+const isExported = (node) => node?.parent?.type === "ExportNamedDeclaration";
+
+const isClientInterface = (node) => {
+ const name = nodeName(node.id);
+ return typeof name === "string" &&
+ (name.endsWith("Client") || (isExported(node) && name.endsWith("Sdk")));
+};
+
+const methodReturnsPromise = (node) => containsPromiseType(node.returnType);
+
+const propertyReturnsPromise = (node) => containsPromiseType(node.typeAnnotation);
+
+export const noPromiseClientSurface = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: message,
+ },
+ messages: {
+ noPromiseClientSurface: message,
+ },
+ schema: [],
+ },
+ create(context) {
+ return {
+ TSInterfaceDeclaration(node) {
+ if (!isClientInterface(node)) return;
+ for (const member of node.body?.body ?? []) {
+ if (
+ (member.type === "TSMethodSignature" && methodReturnsPromise(member)) ||
+ (member.type === "TSPropertySignature" && propertyReturnsPromise(member))
+ ) {
+ context.report({ node: member, messageId: "noPromiseClientSurface" });
+ }
+ }
+ },
+ };
+ },
+};
diff --git a/scripts/oxlint-plugin-executor/rules/no-raw-error-throw.js b/scripts/oxlint-plugin-executor/rules/no-raw-error-throw.js
new file mode 100644
index 000000000..8d277c052
--- /dev/null
+++ b/scripts/oxlint-plugin-executor/rules/no-raw-error-throw.js
@@ -0,0 +1,28 @@
+import { isIdentifier } from "../utils/ast.js";
+
+const message =
+ "Do not throw raw Error objects in Effect code. Return Effect.fail with a tagged error or assert directly in tests.";
+
+const isNewError = (node) => node?.type === "NewExpression" && isIdentifier(node.callee, "Error");
+
+export const noRawErrorThrow = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: message,
+ },
+ messages: {
+ noRawErrorThrow: message,
+ },
+ schema: [],
+ },
+ create(context) {
+ return {
+ ThrowStatement(node) {
+ if (isNewError(node.argument)) {
+ context.report({ node, messageId: "noRawErrorThrow" });
+ }
+ },
+ };
+ },
+};
diff --git a/scripts/oxlint-plugin-executor/rules/no-redundant-error-factory.js b/scripts/oxlint-plugin-executor/rules/no-redundant-error-factory.js
new file mode 100644
index 000000000..6065bf120
--- /dev/null
+++ b/scripts/oxlint-plugin-executor/rules/no-redundant-error-factory.js
@@ -0,0 +1,51 @@
+import { isIdentifier } from "../utils/ast.js";
+
+const message =
+ "Do not add redundant make*Error wrappers that only construct a tagged error. Construct the tagged error directly.";
+
+const isErrorFactoryName = (name) => /^make[A-Z].*Error$/.test(name);
+
+const isNewErrorExpression = (node) =>
+ node?.type === "NewExpression" && isIdentifier(node.callee) && node.callee.name.endsWith("Error");
+
+const returnsOnlyNewError = (node) => {
+ if (isNewErrorExpression(node)) return true;
+ if (node?.type !== "BlockStatement") return false;
+ const statements = node.body ?? [];
+ return statements.length === 1 &&
+ statements[0]?.type === "ReturnStatement" &&
+ isNewErrorExpression(statements[0].argument);
+};
+
+const reportIfRedundantFactory = (context, name, body, node) => {
+ if (isErrorFactoryName(name) && returnsOnlyNewError(body)) {
+ context.report({ node, messageId: "noRedundantErrorFactory" });
+ }
+};
+
+export const noRedundantErrorFactory = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: message,
+ },
+ messages: {
+ noRedundantErrorFactory: message,
+ },
+ schema: [],
+ },
+ create(context) {
+ return {
+ FunctionDeclaration(node) {
+ reportIfRedundantFactory(context, node.id?.name, node.body, node);
+ },
+ VariableDeclarator(node) {
+ if (!isIdentifier(node.id)) return;
+ if (node.init?.type !== "ArrowFunctionExpression" && node.init?.type !== "FunctionExpression") {
+ return;
+ }
+ reportIfRedundantFactory(context, node.id.name, node.init.body, node);
+ },
+ };
+ },
+};
diff --git a/scripts/oxlint-plugin-executor/rules/no-unknown-shape-probing.js b/scripts/oxlint-plugin-executor/rules/no-unknown-shape-probing.js
new file mode 100644
index 000000000..0600d79b2
--- /dev/null
+++ b/scripts/oxlint-plugin-executor/rules/no-unknown-shape-probing.js
@@ -0,0 +1,36 @@
+import { isIdentifier, isStringLiteral } from "../utils/ast.js";
+
+const message =
+ "Do not probe unknown object shapes in domain code. Normalize at a boundary with Schema, a typed adapter, or a named guard.";
+
+const isReflectGet = (node) =>
+ node?.type === "MemberExpression" &&
+ isIdentifier(node.object, "Reflect") &&
+ isIdentifier(node.property, "get");
+
+export const noUnknownShapeProbing = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: message,
+ },
+ messages: {
+ noUnknownShapeProbing: message,
+ },
+ schema: [],
+ },
+ create(context) {
+ return {
+ CallExpression(node) {
+ if (isReflectGet(node.callee)) {
+ context.report({ node, messageId: "noUnknownShapeProbing" });
+ }
+ },
+ BinaryExpression(node) {
+ if (node.operator === "in" && isStringLiteral(node.left)) {
+ context.report({ node, messageId: "noUnknownShapeProbing" });
+ }
+ },
+ };
+ },
+};
diff --git a/scripts/oxlint-plugin-executor/rules/no-vitest-import.js b/scripts/oxlint-plugin-executor/rules/no-vitest-import.js
new file mode 100644
index 000000000..354066e99
--- /dev/null
+++ b/scripts/oxlint-plugin-executor/rules/no-vitest-import.js
@@ -0,0 +1,24 @@
+const message =
+ "Do not import from vitest directly. Use @effect/vitest and Effect's vitest helper modules, for example @effect/vitest/utils.";
+
+export const noVitestImport = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: message,
+ },
+ messages: {
+ noVitestImport: message,
+ },
+ schema: [],
+ },
+ create(context) {
+ return {
+ ImportDeclaration(node) {
+ if (node.source?.value === "vitest") {
+ context.report({ node, messageId: "noVitestImport" });
+ }
+ },
+ };
+ },
+};
diff --git a/scripts/oxlint-plugin-executor/utils/ast.js b/scripts/oxlint-plugin-executor/utils/ast.js
new file mode 100644
index 000000000..72e34d2ec
--- /dev/null
+++ b/scripts/oxlint-plugin-executor/utils/ast.js
@@ -0,0 +1,48 @@
+export const isIdentifier = (node, name) =>
+ node?.type === "Identifier" && (name === undefined || node.name === name);
+
+export const isStringLiteral = (node) =>
+ node?.type === "Literal" && typeof node.value === "string";
+
+export const typeName = (node) => {
+ if (node?.type === "Identifier") return node.name;
+ if (node?.type === "TSQualifiedName") {
+ const left = typeName(node.left);
+ const right = typeName(node.right);
+ return left && right ? `${left}.${right}` : undefined;
+ }
+ return undefined;
+};
+
+export const typeReferenceName = (node) =>
+ node?.type === "TSTypeReference" ? typeName(node.typeName) : undefined;
+
+export const isPromiseType = (node) => typeReferenceName(node) === "Promise";
+
+export const containsPromiseType = (node) => {
+ if (!node || typeof node !== "object") return false;
+ if (isPromiseType(node)) return true;
+
+ switch (node.type) {
+ case "TSTypeAnnotation":
+ return containsPromiseType(node.typeAnnotation);
+ case "TSFunctionType":
+ return containsPromiseType(node.returnType);
+ case "TSParenthesizedType":
+ return containsPromiseType(node.typeAnnotation);
+ case "TSUnionType":
+ case "TSIntersectionType":
+ return (node.types ?? []).some(containsPromiseType);
+ case "TSConditionalType":
+ return containsPromiseType(node.trueType) || containsPromiseType(node.falseType);
+ default:
+ return false;
+ }
+};
+
+export const nodeName = (node) => {
+ if (isIdentifier(node)) return node.name;
+ if (node?.type === "PrivateIdentifier") return node.name;
+ if (isStringLiteral(node)) return node.value;
+ return undefined;
+};