Add seven repo-local oxlint rules scoped to packages/plugins/workos-vault/src to keep the provider's Effect-only surface, tagged-error discipline, and typed boundary patterns in place. Rules added: - no-inline-object-type-assertion - no-instanceof-tagged-error - no-manual-tag-check - no-promise-client-surface - no-raw-error-throw - no-redundant-error-factory - no-unknown-shape-probing Adapted to the existing flat scripts/oxlint-plugin-executor/utils.js layout on main; no rules already on main were duplicated. Cleanup in workos-vault to satisfy the new rules: - WorkOSVaultClientError now captures status + message at construction so callers consume typed fields instead of probing unknown causes. - Renamed exported WorkOSVaultSdk to module-private WorkOSVaultPromiseApi and removed it from the public package surface. - Replaced unwrapVaultError + inline shape probes in secret-store with direct typed access on WorkOSVaultClientError. - Introduced a named row type in secret-store.test.ts instead of an inline object type assertion.
Unified · Split
| @@ -24,5 +24,19 @@ · 23 unmodified lines | |||
| 24 | }, | 24 | }, |
| 25 | ], | 25 | ], |
| 26 | }, | 26 | }, |
| 27 | "overrides": [ | ||
| 28 | { | ||
| 29 | "files": ["packages/plugins/workos-vault/src/**/*.{ts,tsx}"], | ||
| 30 | "rules": { | ||
| 31 | "executor/no-inline-object-type-assertion": "error", | ||
| 32 | "executor/no-instanceof-tagged-error": "error", | ||
| 33 | "executor/no-manual-tag-check": "error", | ||
| 34 | "executor/no-promise-client-surface": "error", | ||
| 35 | "executor/no-raw-error-throw": "error", | ||
| 36 | "executor/no-redundant-error-factory": "error", | ||
| 37 | "executor/no-unknown-shape-probing": "error", | ||
| 38 | }, | ||
| 39 | }, | ||
| 40 | ], | ||
| 27 | "ignorePatterns": [".astro/", "**/routeTree.gen.ts", ".references/"], | 41 | "ignorePatterns": [".astro/", "**/routeTree.gen.ts", ".references/"], |
| 28 | } | 42 | } |
| @@ -1,5 +1,9 @@ | |||
| 1 | import type { WorkOS } from "@workos-inc/node/worker"; | 1 | import type { WorkOS } from "@workos-inc/node/worker"; |
| 2 | import { WorkOS as WorkOSClient } from "@workos-inc/node/worker"; | 2 | import { |
| 3 | GenericServerException, | ||
| 4 | NotFoundException, | ||
| 5 | WorkOS as WorkOSClient, | ||
| 6 | } from "@workos-inc/node/worker"; | ||
| 3 | import { Data, Effect, Result } from "effect"; | 7 | import { Data, Effect, Result } from "effect"; |
| 4 | 8 | ||
| 5 | export interface WorkOSVaultObjectMetadata { | 9 | export interface WorkOSVaultObjectMetadata { |
| @@ -16,10 +20,48 @@ export interface WorkOSVaultObject { · 10 unmodified lines | |||
| 16 | readonly value?: string; | 20 | readonly value?: string; |
| 17 | } | 21 | } |
| 18 | 22 | ||
| 23 | // Minimal shape carrying an HTTP-style status code. Production WorkOS errors | ||
| 24 | // (`GenericServerException`/`NotFoundException`) and test fakes both populate | ||
| 25 | // a numeric `status`, so the boundary normalises against this named type | ||
| 26 | // rather than probing arbitrary unknown shapes. | ||
| 27 | interface ErrorWithStatus extends Error { | ||
| 28 | readonly status: number; | ||
| 29 | } | ||
| 30 | |||
| 31 | const isErrorWithStatus = (cause: unknown): cause is ErrorWithStatus => | ||
| 32 | cause instanceof Error && typeof (cause as ErrorWithStatus).status === "number"; | ||
| 33 | |||
| 34 | const statusFromWorkOSCause = (cause: unknown): number | undefined => { | ||
| 35 | if (cause instanceof GenericServerException || cause instanceof NotFoundException) { | ||
| 36 | return cause.status; | ||
| 37 | } | ||
| 38 | if (isErrorWithStatus(cause)) return cause.status; | ||
| 39 | return undefined; | ||
| 40 | }; | ||
| 41 | |||
| 42 | const messageFromWorkOSCause = (cause: unknown): string => | ||
| 43 | cause instanceof Error ? cause.message : typeof cause === "string" ? cause : ""; | ||
| 44 | |||
| 19 | export class WorkOSVaultClientError extends Data.TaggedError("WorkOSVaultClientError")<{ | 45 | export class WorkOSVaultClientError extends Data.TaggedError("WorkOSVaultClientError")<{ |
| 20 | readonly cause: unknown; | 46 | readonly cause: unknown; |
| 47 | readonly message: string; | ||
| 21 | readonly operation: string; | 48 | readonly operation: string; |
| 22 | }> {} | 49 | readonly status?: number; |
| 50 | }> { | ||
| 51 | constructor(options: { | ||
| 52 | readonly cause: unknown; | ||
| 53 | readonly message?: string; | ||
| 54 | readonly operation: string; | ||
| 55 | readonly status?: number; | ||
| 56 | }) { | ||
| 57 | super({ | ||
| 58 | cause: options.cause, | ||
| 59 | message: options.message ?? messageFromWorkOSCause(options.cause), | ||
| 60 | operation: options.operation, | ||
| 61 | status: options.status ?? statusFromWorkOSCause(options.cause), | ||
| 62 | }); | ||
| 63 | } | ||
| 64 | } | ||
| 23 | 65 | ||
| 24 | export class WorkOSVaultClientInstantiationError extends Data.TaggedError( | 66 | export class WorkOSVaultClientInstantiationError extends Data.TaggedError( |
| 25 | "WorkOSVaultClientInstantiationError", | 67 | "WorkOSVaultClientInstantiationError", |
| @@ -27,7 +69,10 @@ export class WorkOSVaultClientInstantiationError extends Data.TaggedError( · 1 unmodified line | |||
| 27 | readonly cause: unknown; | 69 | readonly cause: unknown; |
| 28 | }> {} | 70 | }> {} |
| 29 | 71 | ||
| 30 | export interface WorkOSVaultSdk { | 72 | // Promise-shaped facade onto the underlying WorkOS SDK. Module-private — the |
| 73 | // public surface in `WorkOSVaultClient` is Effect-only. Test doubles import | ||
| 74 | // this type to stand up an in-memory equivalent. | ||
| 75 | export interface WorkOSVaultPromiseApi { | ||
| 31 | readonly createObject: (options: { | 76 | readonly createObject: (options: { |
| 32 | readonly name: string; | 77 | readonly name: string; |
| 33 | readonly value: string; | 78 | readonly value: string; |
| @@ -55,7 +100,7 @@ interface WorkOSVaultUseOptions { · 21 unmodified lines | |||
| 55 | export interface WorkOSVaultClient { | 100 | export interface WorkOSVaultClient { |
| 56 | readonly use: <A>( | 101 | readonly use: <A>( |
| 57 | operation: string, | 102 | operation: string, |
| 58 | fn: (client: WorkOSVaultSdk) => Promise<A>, | 103 | fn: (client: WorkOSVaultPromiseApi) => Promise<A>, |
| 59 | options?: WorkOSVaultUseOptions, | 104 | options?: WorkOSVaultUseOptions, |
| 60 | ) => Effect.Effect<A, WorkOSVaultClientError, never>; | 105 | ) => Effect.Effect<A, WorkOSVaultClientError, never>; |
| 61 | readonly createObject: (options: { | 106 | readonly createObject: (options: { |
| @@ -76,30 +121,20 @@ export interface WorkOSVaultClient { · 14 unmodified lines | |||
| 76 | }) => Effect.Effect<void, WorkOSVaultClientError, never>; | 121 | }) => Effect.Effect<void, WorkOSVaultClientError, never>; |
| 77 | } | 122 | } |
| 78 | 123 | ||
| 79 | const vaultErrorStatus = (error: WorkOSVaultClientError): number | null => { | ||
| 80 | const cause = error.cause; | ||
| 81 | return typeof cause === "object" && | ||
| 82 | cause !== null && | ||
| 83 | "status" in cause && | ||
| 84 | typeof (cause as { readonly status: unknown }).status === "number" | ||
| 85 | ? (cause as { readonly status: number }).status | ||
| 86 | : null; | ||
| 87 | }; | ||
| 88 | |||
| 89 | const isExpectedVaultError = ( | 124 | const isExpectedVaultError = ( |
| 90 | error: WorkOSVaultClientError, | 125 | error: WorkOSVaultClientError, |
| 91 | options: WorkOSVaultUseOptions | undefined, | 126 | options: WorkOSVaultUseOptions | undefined, |
| 92 | ): boolean => { | 127 | ): boolean => { |
| 93 | const status = vaultErrorStatus(error); | 128 | if (error.status === undefined) return false; |
| 94 | return status !== null && (options?.expectedErrorStatuses?.includes(status) ?? false); | 129 | return options?.expectedErrorStatuses?.includes(error.status) ?? false; |
| 95 | }; | 130 | }; |
| 96 | 131 | ||
| 97 | export const makeWorkOSVaultClient = (workos: Pick<WorkOS, "vault">): WorkOSVaultClient => { | 132 | export const makeWorkOSVaultClient = (workos: Pick<WorkOS, "vault">): WorkOSVaultClient => { |
| 98 | const client: WorkOSVaultSdk = workos.vault; | 133 | const client: WorkOSVaultPromiseApi = workos.vault; |
| 99 | 134 | ||
| 100 | const use = <A>( | 135 | const use = <A>( |
| 101 | operation: string, | 136 | operation: string, |
| 102 | fn: (vault: WorkOSVaultSdk) => Promise<A>, | 137 | fn: (vault: WorkOSVaultPromiseApi) => Promise<A>, |
| 103 | options?: WorkOSVaultUseOptions, | 138 | options?: WorkOSVaultUseOptions, |
| 104 | ): Effect.Effect<A, WorkOSVaultClientError, never> => { | 139 | ): Effect.Effect<A, WorkOSVaultClientError, never> => { |
| 105 | const attempt = Effect.tryPromise({ | 140 | const attempt = Effect.tryPromise({ |
| @@ -116,7 +151,7 @@ export const makeWorkOSVaultClient = (workos: Pick<WorkOS, "vault">): WorkOSVaul · 10 unmodified lines | |||
| 116 | return outcome; | 151 | return outcome; |
| 117 | } | 152 | } |
| 118 | 153 | ||
| 119 | const status = vaultErrorStatus(outcome.failure); | 154 | const status = outcome.failure.status; |
| 120 | if (isExpectedVaultError(outcome.failure, options)) { | 155 | if (isExpectedVaultError(outcome.failure, options)) { |
| 121 | yield* Effect.annotateCurrentSpan({ | 156 | yield* Effect.annotateCurrentSpan({ |
| 122 | "workos_vault.outcome": options?.expectedErrorOutcome ?? "expected_error", | 157 | "workos_vault.outcome": options?.expectedErrorOutcome ?? "expected_error", |
| @@ -7,7 +7,6 @@ export { · 6 unmodified lines | |||
| 7 | type WorkOSVaultCredentials, | 7 | type WorkOSVaultCredentials, |
| 8 | type WorkOSVaultObject, | 8 | type WorkOSVaultObject, |
| 9 | type WorkOSVaultObjectMetadata, | 9 | type WorkOSVaultObjectMetadata, |
| 10 | type WorkOSVaultSdk, | ||
| 11 | } from "./client"; | 10 | } from "./client"; |
| 12 | export { | 11 | export { |
| 13 | workosVaultPlugin, | 12 | workosVaultPlugin, |
| @@ -22,6 +22,14 @@ import { · 21 unmodified lines | |||
| 22 | } from "./client"; | 22 | } from "./client"; |
| 23 | import { workosVaultPlugin } from "./plugin"; | 23 | import { workosVaultPlugin } from "./plugin"; |
| 24 | 24 | ||
| 25 | interface VaultMetadataRow { | ||
| 26 | readonly id: string; | ||
| 27 | readonly scope_id: string; | ||
| 28 | readonly name: string; | ||
| 29 | readonly purpose: string | null; | ||
| 30 | readonly created_at: Date; | ||
| 31 | } | ||
| 32 | |||
| 25 | // --------------------------------------------------------------------------- | 33 | // --------------------------------------------------------------------------- |
| 26 | // Fake status errors — the real provider's isStatusError check pattern- | 34 | // Fake status errors — the real provider's isStatusError check pattern- |
| 27 | // matches on a `status` field, so these bare Error subclasses are | 35 | // matches on a `status` field, so these bare Error subclasses are |
| @@ -465,13 +473,11 @@ describe("WorkOS Vault secret provider — multi-scope isolation", () => { · 437 unmodified lines | |||
| 465 | }), | 473 | }), |
| 466 | ); | 474 | ); |
| 467 | 475 | ||
| 468 | const rows = yield* adapter.findMany({ | 476 | const rows = (yield* adapter.findMany({ |
| 469 | model: "workos_vault_metadata", | 477 | model: "workos_vault_metadata", |
| 470 | where: [{ field: "id", value: "api-token" }], | 478 | where: [{ field: "id", value: "api-token" }], |
| 471 | }); | 479 | })) as readonly VaultMetadataRow[]; |
| 472 | const scopes = rows | 480 | const scopes = rows.map((r) => r.scope_id).sort(); |
| 473 | .map((r) => (r as { scope_id: string }).scope_id) | ||
| 474 | .sort(); | ||
| 475 | expect(scopes).toEqual([outerId, innerId].sort()); | 481 | expect(scopes).toEqual([outerId, innerId].sort()); |
| 476 | }), | 482 | }), |
| 477 | ); | 483 | ); |
| @@ -1,5 +1,4 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect } from "effect"; |
| 2 | import { GenericServerException, NotFoundException } from "@workos-inc/node/worker"; | ||
| 3 | 2 | ||
| 4 | import { | 3 | import { |
| 5 | defineSchema, | 4 | defineSchema, |
| @@ -10,8 +9,8 @@ import { · 4 unmodified lines | |||
| 10 | } from "@executor-js/sdk/core"; | 9 | } from "@executor-js/sdk/core"; |
| 11 | 10 | ||
| 12 | import { | 11 | import { |
| 13 | WorkOSVaultClientError, | ||
| 14 | type WorkOSVaultClient, | 12 | type WorkOSVaultClient, |
| 13 | type WorkOSVaultClientError, | ||
| 15 | type WorkOSVaultObject, | 14 | type WorkOSVaultObject, |
| 16 | } from "./client"; | 15 | } from "./client"; |
| 17 | 16 | ||
| @@ -155,35 +154,11 @@ export const makeWorkosVaultStore = ( · 137 unmodified lines | |||
| 155 | // Vault helpers — scope-prefixed object naming + 409-retry upsert. | 154 | // Vault helpers — scope-prefixed object naming + 409-retry upsert. |
| 156 | // --------------------------------------------------------------------------- | 155 | // --------------------------------------------------------------------------- |
| 157 | 156 | ||
| 158 | const unwrapVaultError = (error: unknown): unknown => | 157 | const isStatusError = (error: WorkOSVaultClientError, status: number): boolean => |
| 159 | error instanceof WorkOSVaultClientError ? error.cause : error; | 158 | error.status === status; |
| 160 | |||
| 161 | const isStatusError = (error: unknown, status: number): boolean => { | ||
| 162 | const cause = unwrapVaultError(error); | ||
| 163 | return ( | ||
| 164 | ((cause instanceof GenericServerException || | ||
| 165 | cause instanceof NotFoundException) && | ||
| 166 | cause.status === status) || | ||
| 167 | (typeof cause === "object" && | ||
| 168 | cause !== null && | ||
| 169 | "status" in cause && | ||
| 170 | typeof (cause as { status: unknown }).status === "number" && | ||
| 171 | (cause as { status: number }).status === status) | ||
| 172 | ); | ||
| 173 | }; | ||
| 174 | 159 | ||
| 175 | const isKekNotReadyError = (error: unknown): boolean => { | 160 | const isKekNotReadyError = (error: WorkOSVaultClientError): boolean => |
| 176 | const cause = unwrapVaultError(error); | 161 | error.message.includes("KEK was created but is not yet ready"); |
| 177 | const message = | ||
| 178 | cause instanceof Error | ||
| 179 | ? cause.message | ||
| 180 | : typeof cause === "string" | ||
| 181 | ? cause | ||
| 182 | : typeof cause === "object" && cause !== null && "message" in cause | ||
| 183 | ? String((cause as { message: unknown }).message) | ||
| 184 | : ""; | ||
| 185 | return message.includes("KEK was created but is not yet ready"); | ||
| 186 | }; | ||
| 187 | 162 | ||
| 188 | // Default context builder. Each semantic piece of a scope id lives in | 163 | // Default context builder. Each semantic piece of a scope id lives in |
| 189 | // its own vault-context key so WorkOS's KEK matcher sees individual | 164 | // its own vault-context key so WorkOS's KEK matcher sees individual |
| @@ -327,11 +302,8 @@ const deleteSecretValue = ( · 137 unmodified lines | |||
| 327 | return true; | 302 | return true; |
| 328 | }); | 303 | }); |
| 329 | 304 | ||
| 330 | const formatVaultError = (error: unknown): StorageError => { | 305 | const formatVaultError = (error: WorkOSVaultClientError): StorageError => |
| 331 | const cause = unwrapVaultError(error); | 306 | new StorageError({ message: error.message, cause: error.cause }); |
| 332 | const message = cause instanceof Error ? cause.message : String(cause); | ||
| 333 | return new StorageError({ message, cause }); | ||
| 334 | }; | ||
| 335 | 307 | ||
| 336 | // --------------------------------------------------------------------------- | 308 | // --------------------------------------------------------------------------- |
| 337 | // makeWorkOSVaultSecretProvider — builds a SecretProvider backed by | 309 | // makeWorkOSVaultSecretProvider — builds a SecretProvider backed by |
| @@ -2,7 +2,14 @@ import noConditionalTests from "./oxlint-plugin-executor/rules/no-conditional-te · 1 unmodified line | |||
| 2 | import noCrossPackageRelativeImports from "./oxlint-plugin-executor/rules/no-cross-package-relative-imports.js"; | 2 | import noCrossPackageRelativeImports from "./oxlint-plugin-executor/rules/no-cross-package-relative-imports.js"; |
| 3 | import noDoubleCast from "./oxlint-plugin-executor/rules/no-double-cast.js"; | 3 | import noDoubleCast from "./oxlint-plugin-executor/rules/no-double-cast.js"; |
| 4 | import noEffectInternalTags from "./oxlint-plugin-executor/rules/no-effect-internal-tags.js"; | 4 | import noEffectInternalTags from "./oxlint-plugin-executor/rules/no-effect-internal-tags.js"; |
| 5 | import noInlineObjectTypeAssertion from "./oxlint-plugin-executor/rules/no-inline-object-type-assertion.js"; | ||
| 6 | import noInstanceofTaggedError from "./oxlint-plugin-executor/rules/no-instanceof-tagged-error.js"; | ||
| 7 | import noManualTagCheck from "./oxlint-plugin-executor/rules/no-manual-tag-check.js"; | ||
| 8 | import noPromiseClientSurface from "./oxlint-plugin-executor/rules/no-promise-client-surface.js"; | ||
| 9 | import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js"; | ||
| 10 | import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js"; | ||
| 5 | import noTsNocheck from "./oxlint-plugin-executor/rules/no-ts-nocheck.js"; | 11 | import noTsNocheck from "./oxlint-plugin-executor/rules/no-ts-nocheck.js"; |
| 12 | import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js"; | ||
| 6 | import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js"; | 13 | import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js"; |
| 7 | import requireReactivityKeys from "./oxlint-plugin-executor/rules/require-reactivity-keys.js"; | 14 | import requireReactivityKeys from "./oxlint-plugin-executor/rules/require-reactivity-keys.js"; |
| 8 | 15 | ||
| @@ -18,5 +25,12 @@ export default { · 9 unmodified lines | |||
| 18 | "require-reactivity-keys": requireReactivityKeys, | 25 | "require-reactivity-keys": requireReactivityKeys, |
| 19 | "no-effect-internal-tags": noEffectInternalTags, | 26 | "no-effect-internal-tags": noEffectInternalTags, |
| 20 | "no-ts-nocheck": noTsNocheck, | 27 | "no-ts-nocheck": noTsNocheck, |
| 28 | "no-inline-object-type-assertion": noInlineObjectTypeAssertion, | ||
| 29 | "no-instanceof-tagged-error": noInstanceofTaggedError, | ||
| 30 | "no-manual-tag-check": noManualTagCheck, | ||
| 31 | "no-promise-client-surface": noPromiseClientSurface, | ||
| 32 | "no-raw-error-throw": noRawErrorThrow, | ||
| 33 | "no-redundant-error-factory": noRedundantErrorFactory, | ||
| 34 | "no-unknown-shape-probing": noUnknownShapeProbing, | ||
| 21 | }, | 35 | }, |
| 22 | }; | 36 | }; |
| @@ -0,0 +1,40 @@ | |||
| 1 | import { isIdentifier } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Do not assert against inline object-shaped types. Use a named type, Schema, or a proper type guard."; | ||
| 5 | |||
| 6 | const isUnknownKeyword = (node) => node?.type === "TSUnknownKeyword"; | ||
| 7 | |||
| 8 | const isStringKey = (node) => | ||
| 9 | node?.type === "TSStringKeyword" || | ||
| 10 | (node?.type === "TSLiteralType" && typeof node.literal?.value === "string"); | ||
| 11 | |||
| 12 | const isRecordUnknown = (node) => | ||
| 13 | node?.type === "TSTypeReference" && | ||
| 14 | isIdentifier(node.typeName, "Record") && | ||
| 15 | node.typeArguments?.params?.length === 2 && | ||
| 16 | isStringKey(node.typeArguments.params[0]) && | ||
| 17 | isUnknownKeyword(node.typeArguments.params[1]); | ||
| 18 | |||
| 19 | const isBannedType = (node) => node?.type === "TSTypeLiteral" || isRecordUnknown(node); | ||
| 20 | |||
| 21 | export default { | ||
| 22 | meta: { | ||
| 23 | type: "problem", | ||
| 24 | docs: { | ||
| 25 | description: message, | ||
| 26 | }, | ||
| 27 | }, | ||
| 28 | create(context) { | ||
| 29 | const check = (node) => { | ||
| 30 | if (isBannedType(node.typeAnnotation)) { | ||
| 31 | context.report({ node, message }); | ||
| 32 | } | ||
| 33 | }; | ||
| 34 | |||
| 35 | return { | ||
| 36 | TSAsExpression: check, | ||
| 37 | TSTypeAssertion: check, | ||
| 38 | }; | ||
| 39 | }, | ||
| 40 | }; | ||
| @@ -0,0 +1,27 @@ | |||
| 1 | import { isIdentifier, nodeName } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Do not use instanceof for tagged errors. Use Effect.catchTag, Effect.catchTags, or a _tag-based guard."; | ||
| 5 | |||
| 6 | const looksLikeTaggedErrorName = (name) => | ||
| 7 | typeof name === "string" && name !== "Error" && name.endsWith("Error"); | ||
| 8 | |||
| 9 | export default { | ||
| 10 | meta: { | ||
| 11 | type: "problem", | ||
| 12 | docs: { | ||
| 13 | description: message, | ||
| 14 | }, | ||
| 15 | }, | ||
| 16 | create(context) { | ||
| 17 | return { | ||
| 18 | BinaryExpression(node) { | ||
| 19 | if (node.operator !== "instanceof") return; | ||
| 20 | const rightName = nodeName(node.right); | ||
| 21 | if (isIdentifier(node.right) && looksLikeTaggedErrorName(rightName)) { | ||
| 22 | context.report({ node, message }); | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | }; | ||
| 26 | }, | ||
| 27 | }; | ||
| @@ -0,0 +1,25 @@ | |||
| 1 | import { isIdentifier, isStringLiteral } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Do not inspect _tag manually. Use Effect.catchTag, Effect.catchTags, Predicate.isTagged, or another Effect tagged-error API."; | ||
| 5 | |||
| 6 | const isTagProperty = (node) => | ||
| 7 | isIdentifier(node, "_tag") || (isStringLiteral(node) && node.value === "_tag"); | ||
| 8 | |||
| 9 | export default { | ||
| 10 | meta: { | ||
| 11 | type: "problem", | ||
| 12 | docs: { | ||
| 13 | description: message, | ||
| 14 | }, | ||
| 15 | }, | ||
| 16 | create(context) { | ||
| 17 | return { | ||
| 18 | MemberExpression(node) { | ||
| 19 | if (isTagProperty(node.property)) { | ||
| 20 | context.report({ node, message }); | ||
| 21 | } | ||
| 22 | }, | ||
| 23 | }; | ||
| 24 | }, | ||
| 25 | }; | ||
| @@ -0,0 +1,42 @@ | |||
| 1 | import { containsPromiseType, nodeName } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Do not expose Promise-shaped client surfaces. Wrap third-party SDK promises at the adapter boundary and expose Effect methods."; | ||
| 5 | |||
| 6 | const isExported = (node) => node?.parent?.type === "ExportNamedDeclaration"; | ||
| 7 | |||
| 8 | const isClientInterface = (node) => { | ||
| 9 | const name = nodeName(node.id); | ||
| 10 | return ( | ||
| 11 | typeof name === "string" && | ||
| 12 | (name.endsWith("Client") || (isExported(node) && name.endsWith("Sdk"))) | ||
| 13 | ); | ||
| 14 | }; | ||
| 15 | |||
| 16 | const methodReturnsPromise = (node) => containsPromiseType(node.returnType); | ||
| 17 | |||
| 18 | const propertyReturnsPromise = (node) => containsPromiseType(node.typeAnnotation); | ||
| 19 | |||
| 20 | export default { | ||
| 21 | meta: { | ||
| 22 | type: "problem", | ||
| 23 | docs: { | ||
| 24 | description: message, | ||
| 25 | }, | ||
| 26 | }, | ||
| 27 | create(context) { | ||
| 28 | return { | ||
| 29 | TSInterfaceDeclaration(node) { | ||
| 30 | if (!isClientInterface(node)) return; | ||
| 31 | for (const member of node.body?.body ?? []) { | ||
| 32 | if ( | ||
| 33 | (member.type === "TSMethodSignature" && methodReturnsPromise(member)) || | ||
| 34 | (member.type === "TSPropertySignature" && propertyReturnsPromise(member)) | ||
| 35 | ) { | ||
| 36 | context.report({ node: member, message }); | ||
| 37 | } | ||
| 38 | } | ||
| 39 | }, | ||
| 40 | }; | ||
| 41 | }, | ||
| 42 | }; | ||
| @@ -0,0 +1,24 @@ | |||
| 1 | import { isIdentifier } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Do not throw raw Error objects in Effect code. Return Effect.fail with a tagged error or assert directly in tests."; | ||
| 5 | |||
| 6 | const isNewError = (node) => node?.type === "NewExpression" && isIdentifier(node.callee, "Error"); | ||
| 7 | |||
| 8 | export default { | ||
| 9 | meta: { | ||
| 10 | type: "problem", | ||
| 11 | docs: { | ||
| 12 | description: message, | ||
| 13 | }, | ||
| 14 | }, | ||
| 15 | create(context) { | ||
| 16 | return { | ||
| 17 | ThrowStatement(node) { | ||
| 18 | if (isNewError(node.argument)) { | ||
| 19 | context.report({ node, message }); | ||
| 20 | } | ||
| 21 | }, | ||
| 22 | }; | ||
| 23 | }, | ||
| 24 | }; | ||
| @@ -0,0 +1,52 @@ | |||
| 1 | import { isIdentifier } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Do not add redundant make*Error wrappers that only construct a tagged error. Construct the tagged error directly."; | ||
| 5 | |||
| 6 | const isErrorFactoryName = (name) => /^make[A-Z].*Error$/.test(name); | ||
| 7 | |||
| 8 | const isNewErrorExpression = (node) => | ||
| 9 | node?.type === "NewExpression" && isIdentifier(node.callee) && node.callee.name.endsWith("Error"); | ||
| 10 | |||
| 11 | const returnsOnlyNewError = (node) => { | ||
| 12 | if (isNewErrorExpression(node)) return true; | ||
| 13 | if (node?.type !== "BlockStatement") return false; | ||
| 14 | const statements = node.body ?? []; | ||
| 15 | return ( | ||
| 16 | statements.length === 1 && | ||
| 17 | statements[0]?.type === "ReturnStatement" && | ||
| 18 | isNewErrorExpression(statements[0].argument) | ||
| 19 | ); | ||
| 20 | }; | ||
| 21 | |||
| 22 | const reportIfRedundantFactory = (context, name, body, node) => { | ||
| 23 | if (isErrorFactoryName(name) && returnsOnlyNewError(body)) { | ||
| 24 | context.report({ node, message }); | ||
| 25 | } | ||
| 26 | }; | ||
| 27 | |||
| 28 | export default { | ||
| 29 | meta: { | ||
| 30 | type: "problem", | ||
| 31 | docs: { | ||
| 32 | description: message, | ||
| 33 | }, | ||
| 34 | }, | ||
| 35 | create(context) { | ||
| 36 | return { | ||
| 37 | FunctionDeclaration(node) { | ||
| 38 | reportIfRedundantFactory(context, node.id?.name, node.body, node); | ||
| 39 | }, | ||
| 40 | VariableDeclarator(node) { | ||
| 41 | if (!isIdentifier(node.id)) return; | ||
| 42 | if ( | ||
| 43 | node.init?.type !== "ArrowFunctionExpression" && | ||
| 44 | node.init?.type !== "FunctionExpression" | ||
| 45 | ) { | ||
| 46 | return; | ||
| 47 | } | ||
| 48 | reportIfRedundantFactory(context, node.id.name, node.init.body, node); | ||
| 49 | }, | ||
| 50 | }; | ||
| 51 | }, | ||
| 52 | }; | ||
| @@ -0,0 +1,32 @@ | |||
| 1 | import { isIdentifier, isStringLiteral } from "../utils.js"; | ||
| 2 | |||
| 3 | const message = | ||
| 4 | "Do not probe unknown object shapes in domain code. Normalize at a boundary with Schema, a typed adapter, or a named guard."; | ||
| 5 | |||
| 6 | const isReflectGet = (node) => | ||
| 7 | node?.type === "MemberExpression" && | ||
| 8 | isIdentifier(node.object, "Reflect") && | ||
| 9 | isIdentifier(node.property, "get"); | ||
| 10 | |||
| 11 | export default { | ||
| 12 | meta: { | ||
| 13 | type: "problem", | ||
| 14 | docs: { | ||
| 15 | description: message, | ||
| 16 | }, | ||
| 17 | }, | ||
| 18 | create(context) { | ||
| 19 | return { | ||
| 20 | CallExpression(node) { | ||
| 21 | if (isReflectGet(node.callee)) { | ||
| 22 | context.report({ node, message }); | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | BinaryExpression(node) { | ||
| 26 | if (node.operator === "in" && isStringLiteral(node.left)) { | ||
| 27 | context.report({ node, message }); | ||
| 28 | } | ||
| 29 | }, | ||
| 30 | }; | ||
| 31 | }, | ||
| 32 | }; | ||
| @@ -71,3 +71,60 @@ export function getStringValue(node) { · 70 unmodified lines | |||
| 71 | if (expression?.type === "StringLiteral") return expression.value; | 71 | if (expression?.type === "StringLiteral") return expression.value; |
| 72 | return undefined; | 72 | return undefined; |
| 73 | } | 73 | } |
| 74 | |||
| 75 | export function isIdentifier(node, name) { | ||
| 76 | return node?.type === "Identifier" && (name === undefined || node.name === name); | ||
| 77 | } | ||
| 78 | |||
| 79 | export function isStringLiteral(node) { | ||
| 80 | return ( | ||
| 81 | (node?.type === "Literal" && typeof node.value === "string") || | ||
| 82 | node?.type === "StringLiteral" | ||
| 83 | ); | ||
| 84 | } | ||
| 85 | |||
| 86 | export function typeName(node) { | ||
| 87 | if (node?.type === "Identifier") return node.name; | ||
| 88 | if (node?.type === "TSQualifiedName") { | ||
| 89 | const left = typeName(node.left); | ||
| 90 | const right = typeName(node.right); | ||
| 91 | return left && right ? `${left}.${right}` : undefined; | ||
| 92 | } | ||
| 93 | return undefined; | ||
| 94 | } | ||
| 95 | |||
| 96 | export function typeReferenceName(node) { | ||
| 97 | return node?.type === "TSTypeReference" ? typeName(node.typeName) : undefined; | ||
| 98 | } | ||
| 99 | |||
| 100 | export function isPromiseType(node) { | ||
| 101 | return typeReferenceName(node) === "Promise"; | ||
| 102 | } | ||
| 103 | |||
| 104 | export function containsPromiseType(node) { | ||
| 105 | if (!node || typeof node !== "object") return false; | ||
| 106 | if (isPromiseType(node)) return true; | ||
| 107 | |||
| 108 | switch (node.type) { | ||
| 109 | case "TSTypeAnnotation": | ||
| 110 | return containsPromiseType(node.typeAnnotation); | ||
| 111 | case "TSFunctionType": | ||
| 112 | return containsPromiseType(node.returnType); | ||
| 113 | case "TSParenthesizedType": | ||
| 114 | return containsPromiseType(node.typeAnnotation); | ||
| 115 | case "TSUnionType": | ||
| 116 | case "TSIntersectionType": | ||
| 117 | return (node.types ?? []).some(containsPromiseType); | ||
| 118 | case "TSConditionalType": | ||
| 119 | return containsPromiseType(node.trueType) || containsPromiseType(node.falseType); | ||
| 120 | default: | ||
| 121 | return false; | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | export function nodeName(node) { | ||
| 126 | if (isIdentifier(node)) return node.name; | ||
| 127 | if (node?.type === "PrivateIdentifier") return node.name; | ||
| 128 | if (isStringLiteral(node)) return node.value; | ||
| 129 | return undefined; | ||
| 130 | } | ||