History

Enforce WorkOS Vault style rules with oxlint (#481)

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.

Rhys Sullivan committed 6 months ago

Commit e3ee27f1 parent c5d93a314 files · +399 −60Browse files

Unified · Split

14 files
  1. .oxlintrc.jsonc +14 −0
  2. packages/plugins/workos-vault/src/sdk/client.ts +54 −19
  3. packages/plugins/workos-vault/src/sdk/index.ts +0 −1
  4. packages/plugins/workos-vault/src/sdk/secret-store.test.ts +11 −5
  5. packages/plugins/workos-vault/src/sdk/secret-store.ts +7 −35
  6. scripts/oxlint-plugin-executor.js +14 −0
  7. scripts/oxlint-plugin-executor/rules/no-inline-object-type-assertion.js +40 −0
  8. scripts/oxlint-plugin-executor/rules/no-instanceof-tagged-error.js +27 −0
  9. scripts/oxlint-plugin-executor/rules/no-manual-tag-check.js +25 −0
  10. scripts/oxlint-plugin-executor/rules/no-promise-client-surface.js +42 −0
  11. scripts/oxlint-plugin-executor/rules/no-raw-error-throw.js +24 −0
  12. scripts/oxlint-plugin-executor/rules/no-redundant-error-factory.js +52 −0
  13. scripts/oxlint-plugin-executor/rules/no-unknown-shape-probing.js +32 −0
  14. scripts/oxlint-plugin-executor/utils.js +57 −0
.oxlintrc.jsonc+14−0
@@ -24,5 +24,19 @@ · 23 unmodified lines
2424 },
2525 ],
2626 },
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+ ],
2741 "ignorePatterns": [".astro/", "**/routeTree.gen.ts", ".references/"],
2842 }
packages/plugins/workos-vault/src/sdk/client.ts+54−19
@@ -1,5 +1,9 @@
11 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";
37 import { Data, Effect, Result } from "effect";
48
59 export interface WorkOSVaultObjectMetadata {
@@ -16,10 +20,48 @@ export interface WorkOSVaultObject { · 10 unmodified lines
1620 readonly value?: string;
1721 }
1822
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+
1945 export class WorkOSVaultClientError extends Data.TaggedError("WorkOSVaultClientError")<{
2046 readonly cause: unknown;
47+ readonly message: string;
2148 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+}
2365
2466 export class WorkOSVaultClientInstantiationError extends Data.TaggedError(
2567 "WorkOSVaultClientInstantiationError",
@@ -27,7 +69,10 @@ export class WorkOSVaultClientInstantiationError extends Data.TaggedError( · 1 unmodified line
2769 readonly cause: unknown;
2870 }> {}
2971
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 {
3176 readonly createObject: (options: {
3277 readonly name: string;
3378 readonly value: string;
@@ -55,7 +100,7 @@ interface WorkOSVaultUseOptions { · 21 unmodified lines
55100 export interface WorkOSVaultClient {
56101 readonly use: <A>(
57102 operation: string,
58− fn: (client: WorkOSVaultSdk) => Promise<A>,
103+ fn: (client: WorkOSVaultPromiseApi) => Promise<A>,
59104 options?: WorkOSVaultUseOptions,
60105 ) => Effect.Effect<A, WorkOSVaultClientError, never>;
61106 readonly createObject: (options: {
@@ -76,30 +121,20 @@ export interface WorkOSVaultClient { · 14 unmodified lines
76121 }) => Effect.Effect<void, WorkOSVaultClientError, never>;
77122 }
78123
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−
89124 const isExpectedVaultError = (
90125 error: WorkOSVaultClientError,
91126 options: WorkOSVaultUseOptions | undefined,
92127 ): boolean => {
93− const status = vaultErrorStatus(error);
94− return status !== null && (options?.expectedErrorStatuses?.includes(status) ?? false);
128+ if (error.status === undefined) return false;
129+ return options?.expectedErrorStatuses?.includes(error.status) ?? false;
95130 };
96131
97132 export const makeWorkOSVaultClient = (workos: Pick<WorkOS, "vault">): WorkOSVaultClient => {
98− const client: WorkOSVaultSdk = workos.vault;
133+ const client: WorkOSVaultPromiseApi = workos.vault;
99134
100135 const use = <A>(
101136 operation: string,
102− fn: (vault: WorkOSVaultSdk) => Promise<A>,
137+ fn: (vault: WorkOSVaultPromiseApi) => Promise<A>,
103138 options?: WorkOSVaultUseOptions,
104139 ): Effect.Effect<A, WorkOSVaultClientError, never> => {
105140 const attempt = Effect.tryPromise({
@@ -116,7 +151,7 @@ export const makeWorkOSVaultClient = (workos: Pick<WorkOS, "vault">): WorkOSVaul · 10 unmodified lines
116151 return outcome;
117152 }
118153
119− const status = vaultErrorStatus(outcome.failure);
154+ const status = outcome.failure.status;
120155 if (isExpectedVaultError(outcome.failure, options)) {
121156 yield* Effect.annotateCurrentSpan({
122157 "workos_vault.outcome": options?.expectedErrorOutcome ?? "expected_error",
packages/plugins/workos-vault/src/sdk/index.ts+0−1
@@ -7,7 +7,6 @@ export { · 6 unmodified lines
77 type WorkOSVaultCredentials,
88 type WorkOSVaultObject,
99 type WorkOSVaultObjectMetadata,
10− type WorkOSVaultSdk,
1110 } from "./client";
1211 export {
1312 workosVaultPlugin,
packages/plugins/workos-vault/src/sdk/secret-store.test.ts+11−5
@@ -22,6 +22,14 @@ import { · 21 unmodified lines
2222 } from "./client";
2323 import { workosVaultPlugin } from "./plugin";
2424
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+
2533 // ---------------------------------------------------------------------------
2634 // Fake status errors — the real provider's isStatusError check pattern-
2735 // 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
465473 }),
466474 );
467475
468− const rows = yield* adapter.findMany({
476+ const rows = (yield* adapter.findMany({
469477 model: "workos_vault_metadata",
470478 where: [{ field: "id", value: "api-token" }],
471− });
472− const scopes = rows
473− .map((r) => (r as { scope_id: string }).scope_id)
474− .sort();
479+ })) as readonly VaultMetadataRow[];
480+ const scopes = rows.map((r) => r.scope_id).sort();
475481 expect(scopes).toEqual([outerId, innerId].sort());
476482 }),
477483 );
packages/plugins/workos-vault/src/sdk/secret-store.ts+7−35
@@ -1,5 +1,4 @@
11 import { Effect } from "effect";
2−import { GenericServerException, NotFoundException } from "@workos-inc/node/worker";
32
43 import {
54 defineSchema,
@@ -10,8 +9,8 @@ import { · 4 unmodified lines
109 } from "@executor-js/sdk/core";
1110
1211 import {
13− WorkOSVaultClientError,
1412 type WorkOSVaultClient,
13+ type WorkOSVaultClientError,
1514 type WorkOSVaultObject,
1615 } from "./client";
1716
@@ -155,35 +154,11 @@ export const makeWorkosVaultStore = ( · 137 unmodified lines
155154 // Vault helpers — scope-prefixed object naming + 409-retry upsert.
156155 // ---------------------------------------------------------------------------
157156
158−const unwrapVaultError = (error: unknown): unknown =>
159− error instanceof WorkOSVaultClientError ? error.cause : error;
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−};
157+const isStatusError = (error: WorkOSVaultClientError, status: number): boolean =>
158+ error.status === status;
174159
175−const isKekNotReadyError = (error: unknown): boolean => {
176− const cause = unwrapVaultError(error);
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−};
160+const isKekNotReadyError = (error: WorkOSVaultClientError): boolean =>
161+ error.message.includes("KEK was created but is not yet ready");
187162
188163 // Default context builder. Each semantic piece of a scope id lives in
189164 // its own vault-context key so WorkOS's KEK matcher sees individual
@@ -327,11 +302,8 @@ const deleteSecretValue = ( · 137 unmodified lines
327302 return true;
328303 });
329304
330−const formatVaultError = (error: unknown): StorageError => {
331− const cause = unwrapVaultError(error);
332− const message = cause instanceof Error ? cause.message : String(cause);
333− return new StorageError({ message, cause });
334−};
305+const formatVaultError = (error: WorkOSVaultClientError): StorageError =>
306+ new StorageError({ message: error.message, cause: error.cause });
335307
336308 // ---------------------------------------------------------------------------
337309 // makeWorkOSVaultSecretProvider — builds a SecretProvider backed by
scripts/oxlint-plugin-executor.js+14−0
@@ -2,7 +2,14 @@ import noConditionalTests from "./oxlint-plugin-executor/rules/no-conditional-te · 1 unmodified line
22 import noCrossPackageRelativeImports from "./oxlint-plugin-executor/rules/no-cross-package-relative-imports.js";
33 import noDoubleCast from "./oxlint-plugin-executor/rules/no-double-cast.js";
44 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";
511 import noTsNocheck from "./oxlint-plugin-executor/rules/no-ts-nocheck.js";
12+import noUnknownShapeProbing from "./oxlint-plugin-executor/rules/no-unknown-shape-probing.js";
613 import noVitestImport from "./oxlint-plugin-executor/rules/no-vitest-import.js";
714 import requireReactivityKeys from "./oxlint-plugin-executor/rules/require-reactivity-keys.js";
815
@@ -18,5 +25,12 @@ export default { · 9 unmodified lines
1825 "require-reactivity-keys": requireReactivityKeys,
1926 "no-effect-internal-tags": noEffectInternalTags,
2027 "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,
2135 },
2236 };
scripts/oxlint-plugin-executor/rules/no-inline-object-type-assertion.js+40−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-instanceof-tagged-error.js+27−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-manual-tag-check.js+25−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-promise-client-surface.js+42−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-raw-error-throw.js+24−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-redundant-error-factory.js+52−0
@@ -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+};
scripts/oxlint-plugin-executor/rules/no-unknown-shape-probing.js+32−0
@@ -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+};
scripts/oxlint-plugin-executor/utils.js+57−0
@@ -71,3 +71,60 @@ export function getStringValue(node) { · 70 unmodified lines
7171 if (expression?.type === "StringLiteral") return expression.value;
7272 return undefined;
7373 }
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+}