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; +};