diff --git a/.changeset/fresh-cameras-repeat.md b/.changeset/fresh-cameras-repeat.md new file mode 100644 index 00000000..863aa8b7 --- /dev/null +++ b/.changeset/fresh-cameras-repeat.md @@ -0,0 +1,11 @@ +--- +"@effect/language-service": minor +--- + +Add the `lazyPromiseInEffectSync` diagnostic to catch `Effect.sync(() => Promise...)` patterns and suggest using `Effect.promise` or `Effect.tryPromise` for async work. + +Example: + +```ts +Effect.sync(() => Promise.resolve(1)) +``` diff --git a/README.md b/README.md index f434cab6..5c35f49c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Some diagnostics are off by default or have a default severity of suggestion, bu globalErrorInEffectCatch⚠️Warns when catch callbacks return global Error type instead of typed errors✓✓ globalErrorInEffectFailure⚠️Warns when the global Error type is used in an Effect failure channel✓✓ layerMergeAllWithDependencies⚠️🔧Detects interdependencies in Layer.mergeAll calls where one layer provides a service that another layer requires✓✓ + lazyPromiseInEffectSync⚠️Warns when Effect.sync lazily returns a Promise instead of using an async Effect constructor✓✓ leakingRequirements💡Detects implementation services leaked in service methods✓✓ multipleEffectProvide⚠️🔧Warns against chaining Effect.provide calls which can cause service lifecycle issues✓✓ returnEffectInGen💡🔧Warns when returning an Effect in a generator causes nested Effect<Effect<...>>✓✓ diff --git a/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap b/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap index a650ad2d..b885bb4f 100644 --- a/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap +++ b/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap @@ -248,7 +248,7 @@ exports[`Completion effectDataClasses > effectDataClasses_directImportTaggedErro exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2:5 1`] = ` [ { - "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", + "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,lazyPromiseInEffectSync,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", "isSnippet": true, "kind": "string", "name": "@effect-diagnostics", @@ -259,7 +259,7 @@ exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2: "sortText": "11", }, { - "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", + "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,lazyPromiseInEffectSync,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", "isSnippet": true, "kind": "string", "name": "@effect-diagnostics-next-line", diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes new file mode 100644 index 00000000..fca14860 --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes @@ -0,0 +1,4 @@ +lazyPromiseInEffectSync_skipNextLine from 325 to 349 +lazyPromiseInEffectSync_skipFile from 325 to 349 +lazyPromiseInEffectSync_skipNextLine from 464 to 482 +lazyPromiseInEffectSync_skipFile from 464 to 482 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output new file mode 100644 index 00000000..9b2bb525 --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output @@ -0,0 +1,5 @@ +() => Promise.resolve(1) +8:46 - 8:70 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync) + +() => promiseValue +11:44 - 11:62 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync) \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes new file mode 100644 index 00000000..54caa6c2 --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes @@ -0,0 +1,2 @@ +lazyPromiseInEffectSync_skipNextLine from 152 to 176 +lazyPromiseInEffectSync_skipFile from 152 to 176 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output new file mode 100644 index 00000000..5a90937f --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output @@ -0,0 +1,2 @@ +() => Promise.resolve(1) +5:35 - 5:59 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync) \ No newline at end of file diff --git a/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync.ts b/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync.ts new file mode 100644 index 00000000..790f7229 --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync.ts @@ -0,0 +1,20 @@ +// @effect-diagnostics lazyPromiseInEffectSync:warning +import { Effect } from "effect" + +declare const promiseValue: Promise +declare const thenableValue: { then: (onFulfilled: (value: number) => unknown) => unknown } + +// Should trigger - Promise returned from Effect.sync +export const fromPromiseResolve = Effect.sync(() => Promise.resolve(1)) + +// Should trigger - declared Promise type returned from Effect.sync +export const fromPromiseValue = Effect.sync(() => promiseValue) + +// Should NOT trigger - sync value +export const fromValue = Effect.sync(() => 1) + +// Should NOT trigger - thenable but not Promise +export const fromThenable = Effect.sync(() => thenableValue) + +// Should NOT trigger - already using the async constructor +export const fromEffectPromise = Effect.promise(() => Promise.resolve(1)) diff --git a/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync_preview.ts b/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync_preview.ts new file mode 100644 index 00000000..6ba4ca4d --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/lazyPromiseInEffectSync_preview.ts @@ -0,0 +1,5 @@ +// @effect-diagnostics *:off +// @effect-diagnostics lazyPromiseInEffectSync:warning +import { Effect } from "effect" + +export const preview = Effect.sync(() => Promise.resolve(1)) diff --git a/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap b/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap index 1be36423..b0eba606 100644 --- a/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap +++ b/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap @@ -109,7 +109,7 @@ exports[`Completion effectDataClasses > effectDataClasses.ts at 4:35 1`] = ` exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2:5 1`] = ` [ { - "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", + "insertText": "@effect-diagnostics \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,lazyPromiseInEffectSync,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", "isSnippet": true, "kind": "string", "name": "@effect-diagnostics", @@ -120,7 +120,7 @@ exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2: "sortText": "11", }, { - "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", + "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,asyncFunction,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,cryptoRandomUUID,cryptoRandomUUIDInEffect,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalConsole,globalConsoleInEffect,globalDate,globalDateInEffect,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,globalFetchInEffect,globalRandom,globalRandomInEffect,globalTimers,globalTimersInEffect,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,lazyPromiseInEffectSync,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,newPromise,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,processEnv,processEnvInEffect,redundantSchemaTagIdentifier,returnEffectInGen,runEffectInsideEffect,schemaStructWithTag,schemaSyncInEffect,schemaUnionOfLiterals,scopeInLayerEffect,serviceNotAsClass,strictBooleanExpressions,strictEffectProvide,tryCatchInEffectGen,unknownInEffectCatch,unnecessaryEffectGen,unnecessaryFailYieldableError,unnecessaryPipe,unnecessaryPipeChain,unsupportedServiceAccessors|}:\${2|off,warning,error,message,suggestion|}$0", "isSnippet": true, "kind": "string", "name": "@effect-diagnostics-next-line", diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes new file mode 100644 index 00000000..fca14860 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.codefixes @@ -0,0 +1,4 @@ +lazyPromiseInEffectSync_skipNextLine from 325 to 349 +lazyPromiseInEffectSync_skipFile from 325 to 349 +lazyPromiseInEffectSync_skipNextLine from 464 to 482 +lazyPromiseInEffectSync_skipFile from 464 to 482 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output new file mode 100644 index 00000000..9b2bb525 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync.ts.output @@ -0,0 +1,5 @@ +() => Promise.resolve(1) +8:46 - 8:70 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync) + +() => promiseValue +11:44 - 11:62 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync) \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes new file mode 100644 index 00000000..54caa6c2 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.codefixes @@ -0,0 +1,2 @@ +lazyPromiseInEffectSync_skipNextLine from 152 to 176 +lazyPromiseInEffectSync_skipFile from 152 to 176 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output new file mode 100644 index 00000000..5a90937f --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/lazyPromiseInEffectSync_preview.ts.output @@ -0,0 +1,2 @@ +() => Promise.resolve(1) +5:35 - 5:59 | 0 | This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync) \ No newline at end of file diff --git a/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync.ts b/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync.ts new file mode 100644 index 00000000..790f7229 --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync.ts @@ -0,0 +1,20 @@ +// @effect-diagnostics lazyPromiseInEffectSync:warning +import { Effect } from "effect" + +declare const promiseValue: Promise +declare const thenableValue: { then: (onFulfilled: (value: number) => unknown) => unknown } + +// Should trigger - Promise returned from Effect.sync +export const fromPromiseResolve = Effect.sync(() => Promise.resolve(1)) + +// Should trigger - declared Promise type returned from Effect.sync +export const fromPromiseValue = Effect.sync(() => promiseValue) + +// Should NOT trigger - sync value +export const fromValue = Effect.sync(() => 1) + +// Should NOT trigger - thenable but not Promise +export const fromThenable = Effect.sync(() => thenableValue) + +// Should NOT trigger - already using the async constructor +export const fromEffectPromise = Effect.promise(() => Promise.resolve(1)) diff --git a/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync_preview.ts b/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync_preview.ts new file mode 100644 index 00000000..6ba4ca4d --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/lazyPromiseInEffectSync_preview.ts @@ -0,0 +1,5 @@ +// @effect-diagnostics *:off +// @effect-diagnostics lazyPromiseInEffectSync:warning +import { Effect } from "effect" + +export const preview = Effect.sync(() => Promise.resolve(1)) diff --git a/packages/language-service/src/core/TypeParser.ts b/packages/language-service/src/core/TypeParser.ts index e8efd2c6..16c5d224 100644 --- a/packages/language-service/src/core/TypeParser.ts +++ b/packages/language-service/src/core/TypeParser.ts @@ -224,6 +224,10 @@ export interface TypeParser { type: ts.Type, atLocation: ts.Node ) => Nano.Nano<{ type: ts.Type }, TypeParserIssue> + promiseType: ( + type: ts.Type, + atLocation: ts.Node + ) => Nano.Nano extendsEffectService: (atLocation: ts.ClassDeclaration) => Nano.Nano< { className: ts.Identifier @@ -1861,6 +1865,28 @@ export function make( (type) => type ) + const promiseType = Nano.cachedBy( + function( + type: ts.Type, + atLocation: ts.Node + ) { + const promiseSymbol = typeChecker.resolveName("Promise", undefined, ts.SymbolFlags.Type, false) + if (!promiseSymbol) return typeParserIssue("global Promise type not found", type, atLocation) + const globalPromiseType = typeChecker.getDeclaredTypeOfSymbol(promiseSymbol) + + if ( + type === globalPromiseType || + ("target" in type && (type as ts.TypeReference).target === globalPromiseType) || + typeChecker.isTypeAssignableTo(type, globalPromiseType) + ) { + return Nano.succeed(type) + } + return typeParserIssue("type is not a Promise", type, atLocation) + }, + "TypeParser.promiseType", + (type) => type + ) + const extendsSchemaClass = Nano.cachedBy( Nano.fn("TypeParser.extendsSchemaClass")(function*( atLocation: ts.ClassDeclaration @@ -3184,6 +3210,7 @@ export function make( singleArgCall, scopeType, promiseLike, + promiseType, extendsEffectTag, extendsEffectService, extendsServiceMapService, diff --git a/packages/language-service/src/diagnostics.ts b/packages/language-service/src/diagnostics.ts index 156f887c..f6b98599 100644 --- a/packages/language-service/src/diagnostics.ts +++ b/packages/language-service/src/diagnostics.ts @@ -33,6 +33,7 @@ import { globalTimersInEffect } from "./diagnostics/globalTimersInEffect.js" import { importFromBarrel } from "./diagnostics/importFromBarrel.js" import { instanceOfSchema } from "./diagnostics/instanceOfSchema.js" import { layerMergeAllWithDependencies } from "./diagnostics/layerMergeAllWithDependencies.js" +import { lazyPromiseInEffectSync } from "./diagnostics/lazyPromiseInEffectSync.js" import { leakingRequirements } from "./diagnostics/leakingRequirements.js" import { missedPipeableOpportunity } from "./diagnostics/missedPipeableOpportunity.js" import { missingEffectContext } from "./diagnostics/missingEffectContext.js" @@ -90,6 +91,7 @@ export const diagnostics = [ effectInFailure, missingStarInYieldEffectGen, newPromise, + lazyPromiseInEffectSync, unnecessaryEffectGen, unnecessaryFailYieldableError, missingReturnYieldStar, diff --git a/packages/language-service/src/diagnostics/lazyPromiseInEffectSync.ts b/packages/language-service/src/diagnostics/lazyPromiseInEffectSync.ts new file mode 100644 index 00000000..58feced9 --- /dev/null +++ b/packages/language-service/src/diagnostics/lazyPromiseInEffectSync.ts @@ -0,0 +1,65 @@ +import type ts from "typescript" +import * as LSP from "../core/LSP.js" +import * as Nano from "../core/Nano.js" +import * as TypeCheckerApi from "../core/TypeCheckerApi.js" +import * as TypeCheckerUtils from "../core/TypeCheckerUtils.js" +import * as TypeParser from "../core/TypeParser.js" +import * as TypeScriptApi from "../core/TypeScriptApi.js" + +export const lazyPromiseInEffectSync = LSP.createDiagnostic({ + name: "lazyPromiseInEffectSync", + code: 70, + description: "Warns when Effect.sync lazily returns a Promise instead of using an async Effect constructor", + group: "antipattern", + severity: "warning", + fixable: false, + supportedEffect: ["v3", "v4"], + apply: Nano.fn("lazyPromiseInEffectSync.apply")(function*(sourceFile, report) { + const ts = yield* Nano.service(TypeScriptApi.TypeScriptApi) + const typeChecker = yield* Nano.service(TypeCheckerApi.TypeCheckerApi) + const typeCheckerUtils = yield* Nano.service(TypeCheckerUtils.TypeCheckerUtils) + const typeParser = yield* Nano.service(TypeParser.TypeParser) + + const nodeToVisit: Array = [] + const appendNodeToVisit = (node: ts.Node) => { + nodeToVisit.push(node) + return undefined + } + ts.forEachChild(sourceFile, appendNodeToVisit) + + while (nodeToVisit.length > 0) { + const node = nodeToVisit.shift()! + ts.forEachChild(node, appendNodeToVisit) + + if (!ts.isCallExpression(node)) continue + + const isSyncCall = yield* Nano.orUndefined( + typeParser.isNodeReferenceToEffectModuleApi("sync")(node.expression) + ) + if (!isSyncCall) continue + + const lazyArg = node.arguments[0] + if (!lazyArg) continue + + const lazyArgType = typeCheckerUtils.getTypeAtLocation(lazyArg) + if (!lazyArgType) continue + + const entries = typeCheckerUtils.unrollUnionMembers(lazyArgType).flatMap((member) => + typeChecker.getSignaturesOfType(member, ts.SignatureKind.Call).map((signature) => + typeParser.promiseType(typeChecker.getReturnTypeOfSignature(signature), lazyArg) + ) + ) + if (entries.length === 0) continue + + const promiseReturn = yield* Nano.orUndefined(Nano.firstSuccessOf(entries)) + if (!promiseReturn) continue + + report({ + location: lazyArg, + messageText: + "This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work.", + fixes: [] + }) + } + }) +}) diff --git a/packages/language-service/src/metadata.json b/packages/language-service/src/metadata.json index d9cd3dde..149f5826 100644 --- a/packages/language-service/src/metadata.json +++ b/packages/language-service/src/metadata.json @@ -571,6 +571,27 @@ ] } }, + { + "name": "lazyPromiseInEffectSync", + "group": "antipattern", + "description": "Warns when Effect.sync lazily returns a Promise instead of using an async Effect constructor", + "defaultSeverity": "warning", + "fixable": false, + "supportedEffect": [ + "v3", + "v4" + ], + "preview": { + "sourceText": "import { Effect } from \"effect\"\n\nexport const preview = Effect.sync(() => Promise.resolve(1))\n", + "diagnostics": [ + { + "start": 68, + "end": 92, + "text": "This `Effect.sync` thunk returns a Promise. Use `Effect.promise` or `Effect.tryPromise` to represent async work. effect(lazyPromiseInEffectSync)" + } + ] + } + }, { "name": "leakingRequirements", "group": "antipattern", diff --git a/schema.json b/schema.json index d4c1d5dc..ffee4c09 100644 --- a/schema.json +++ b/schema.json @@ -2579,6 +2579,18 @@ "default": "warning", "description": "Detects interdependencies in Layer.mergeAll calls where one layer provides a service that another layer requires Default severity: warning." }, + "lazyPromiseInEffectSync": { + "type": "string", + "enum": [ + "off", + "error", + "warning", + "message", + "suggestion" + ], + "default": "warning", + "description": "Warns when Effect.sync lazily returns a Promise instead of using an async Effect constructor Default severity: warning." + }, "leakingRequirements": { "type": "string", "enum": [