diff --git a/.changeset/slow-ligers-laugh.md b/.changeset/slow-ligers-laugh.md new file mode 100644 index 00000000..5e8fe381 --- /dev/null +++ b/.changeset/slow-ligers-laugh.md @@ -0,0 +1,5 @@ +--- +"@effect/language-service": minor +--- + +Add `newPromise` and `asyncFunction` effect-native diagnostics to report manual `Promise` construction and async function declarations, with guidance toward Effect-based async control flow. diff --git a/README.md b/README.md index 7023cd7c..f434cab6 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Some diagnostics are off by default or have a default severity of suggestion, bu tryCatchInEffectGen💡Discourages try/catch in Effect generators in favor of Effect error handling✓✓ unknownInEffectCatch⚠️Warns when catch callbacks return unknown instead of typed errors✓✓ Effect-native Prefer Effect-native APIs and abstractions when available. + asyncFunction➖Warns when declaring async functions and suggests using Effect values and Effect.gen for async control flow✓✓ cryptoRandomUUID➖Warns when using crypto.randomUUID() outside Effect generators instead of the Effect Random module, which uses Effect-injected randomness rather than the crypto module behind the scenes✓ cryptoRandomUUIDInEffect➖Warns when using crypto.randomUUID() inside Effect generators instead of the Effect Random module, which uses Effect-injected randomness rather than the crypto module behind the scenes✓ extendsNativeError➖Warns when a class directly extends the native Error class✓✓ @@ -102,6 +103,7 @@ Some diagnostics are off by default or have a default severity of suggestion, bu globalTimers➖Warns when using setTimeout/setInterval outside Effect generators instead of Effect.sleep/Schedule✓✓ globalTimersInEffect➖Warns when using setTimeout/setInterval inside Effect generators instead of Effect.sleep/Schedule✓✓ instanceOfSchema➖🔧Suggests using Schema.is instead of instanceof for Effect Schema types✓✓ + newPromise➖Warns when constructing promises with new Promise instead of using Effect APIs✓✓ nodeBuiltinImport➖Warns when importing Node.js built-in modules that have Effect-native counterparts✓✓ preferSchemaOverJson💡Suggests using Effect Schema for JSON operations instead of JSON.parse/JSON.stringify which may throw✓✓ processEnv➖Warns when reading process.env outside Effect generators instead of using Effect Config✓✓ diff --git a/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap b/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap index 7931068d..a650ad2d 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,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,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,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,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,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,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/asyncFunction.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/asyncFunction.ts.codefixes new file mode 100644 index 00000000..b10107cd --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/asyncFunction.ts.codefixes @@ -0,0 +1,8 @@ +asyncFunction_skipNextLine from 125 to 204 +asyncFunction_skipFile from 125 to 204 +asyncFunction_skipNextLine from 273 to 326 +asyncFunction_skipFile from 273 to 326 +asyncFunction_skipNextLine from 392 to 451 +asyncFunction_skipFile from 392 to 451 +asyncFunction_skipNextLine from 568 to 639 +asyncFunction_skipFile from 568 to 639 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/asyncFunction.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/asyncFunction.ts.output new file mode 100644 index 00000000..2535365c --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/asyncFunction.ts.output @@ -0,0 +1,23 @@ +export async function declaredAsync() { + await Promise.resolve(1) + return 1 +} +5:0 - 8:1 | 0 | This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`. effect(asyncFunction) + +async () => { + await Promise.resolve(2) + return 2 +} +11:26 - 14:1 | 0 | This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`. effect(asyncFunction) + +async run() { + await Promise.resolve(3) + return 3 + } +18:2 - 21:3 | 0 | This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`. effect(asyncFunction) + +async function nested() { + await Promise.resolve(4) + return 4 + } +26:9 - 29:3 | 0 | This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`. effect(asyncFunction) \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/asyncFunction_preview.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/asyncFunction_preview.ts.codefixes new file mode 100644 index 00000000..ef6e6da4 --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/asyncFunction_preview.ts.codefixes @@ -0,0 +1,2 @@ +asyncFunction_skipNextLine from 98 to 140 +asyncFunction_skipFile from 98 to 140 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/asyncFunction_preview.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/asyncFunction_preview.ts.output new file mode 100644 index 00000000..5b089c3d --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/asyncFunction_preview.ts.output @@ -0,0 +1,4 @@ +async () => { + await Promise.resolve(1) +} +4:23 - 6:1 | 0 | This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`. effect(asyncFunction) \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise.ts.codefixes new file mode 100644 index 00000000..5ea2336b --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise.ts.codefixes @@ -0,0 +1,8 @@ +newPromise_skipNextLine from 152 to 196 +newPromise_skipFile from 152 to 196 +newPromise_skipNextLine from 286 to 330 +newPromise_skipFile from 286 to 330 +newPromise_skipNextLine from 424 to 470 +newPromise_skipFile from 424 to 470 +newPromise_skipNextLine from 584 to 628 +newPromise_skipFile from 584 to 628 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise.ts.output new file mode 100644 index 00000000..2bc87250 --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise.ts.output @@ -0,0 +1,11 @@ +new Promise((resolve) => resolve(1)) +5:29 - 5:73 | 0 | This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction. effect(newPromise) + +new Promise((resolve) => resolve(2)) +8:36 - 8:80 | 0 | This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction. effect(newPromise) + +new MyPromise((resolve) => resolve(3)) +12:30 - 12:76 | 0 | This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction. effect(newPromise) + +new Promise((resolve) => resolve(4)) +16:9 - 16:53 | 0 | This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction. effect(newPromise) \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise_preview.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise_preview.ts.codefixes new file mode 100644 index 00000000..70237618 --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise_preview.ts.codefixes @@ -0,0 +1,2 @@ +newPromise_skipNextLine from 95 to 139 +newPromise_skipFile from 95 to 139 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise_preview.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise_preview.ts.output new file mode 100644 index 00000000..df7de4ba --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/newPromise_preview.ts.output @@ -0,0 +1,2 @@ +new Promise((resolve) => resolve(1)) +4:23 - 4:67 | 0 | This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction. effect(newPromise) \ No newline at end of file diff --git a/packages/harness-effect-v3/examples/diagnostics/asyncFunction.ts b/packages/harness-effect-v3/examples/diagnostics/asyncFunction.ts new file mode 100644 index 00000000..d882e9d7 --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/asyncFunction.ts @@ -0,0 +1,33 @@ +// @effect-diagnostics asyncFunction:warning +import { Effect } from "effect" + +// Should trigger - async function declaration +export async function declaredAsync() { + await Promise.resolve(1) + return 1 +} + +// Should trigger - async arrow function +export const asyncArrow = async () => { + await Promise.resolve(2) + return 2 +} + +// Should trigger - async method +export const asyncMethod = { + async run() { + await Promise.resolve(3) + return 3 + } +} + +// Should trigger - async function inside Effect.gen +export const asyncInGen = Effect.gen(function*() { + return async function nested() { + await Promise.resolve(4) + return 4 + } +}) + +// Should NOT trigger - regular function using Effect +export const regularFunction = () => Effect.succeed(5) diff --git a/packages/harness-effect-v3/examples/diagnostics/asyncFunction_preview.ts b/packages/harness-effect-v3/examples/diagnostics/asyncFunction_preview.ts new file mode 100644 index 00000000..2ae98a10 --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/asyncFunction_preview.ts @@ -0,0 +1,6 @@ +// @effect-diagnostics *:off +// @effect-diagnostics asyncFunction:warning + +export const preview = async () => { + await Promise.resolve(1) +} diff --git a/packages/harness-effect-v3/examples/diagnostics/newPromise.ts b/packages/harness-effect-v3/examples/diagnostics/newPromise.ts new file mode 100644 index 00000000..8ca509cf --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/newPromise.ts @@ -0,0 +1,25 @@ +// @effect-diagnostics newPromise:warning +import { Effect } from "effect" + +// Should trigger - new Promise at module level +export const modulePromise = new Promise((resolve) => resolve(1)) + +// Should trigger - new Promise in regular function +export const regularPromise = () => new Promise((resolve) => resolve(2)) + +// Should trigger - aliased Promise +const MyPromise = Promise +export const aliasedPromise = new MyPromise((resolve) => resolve(3)) + +// Should trigger - new Promise inside Effect.gen +export const promiseInGen = Effect.gen(function*() { + return new Promise((resolve) => resolve(4)) +}) + +// Should NOT trigger - shadowed Promise +export const shadowedPromise = () => { + class Promise { + constructor(readonly value: A) {} + } + return new Promise(5) +} diff --git a/packages/harness-effect-v3/examples/diagnostics/newPromise_preview.ts b/packages/harness-effect-v3/examples/diagnostics/newPromise_preview.ts new file mode 100644 index 00000000..e6001c15 --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/newPromise_preview.ts @@ -0,0 +1,4 @@ +// @effect-diagnostics *:off +// @effect-diagnostics newPromise:warning + +export const preview = new Promise((resolve) => 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 079e9c2e..1be36423 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,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,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,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,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,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,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/asyncFunction.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/asyncFunction.ts.codefixes new file mode 100644 index 00000000..b10107cd --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/asyncFunction.ts.codefixes @@ -0,0 +1,8 @@ +asyncFunction_skipNextLine from 125 to 204 +asyncFunction_skipFile from 125 to 204 +asyncFunction_skipNextLine from 273 to 326 +asyncFunction_skipFile from 273 to 326 +asyncFunction_skipNextLine from 392 to 451 +asyncFunction_skipFile from 392 to 451 +asyncFunction_skipNextLine from 568 to 639 +asyncFunction_skipFile from 568 to 639 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/asyncFunction.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/asyncFunction.ts.output new file mode 100644 index 00000000..2535365c --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/asyncFunction.ts.output @@ -0,0 +1,23 @@ +export async function declaredAsync() { + await Promise.resolve(1) + return 1 +} +5:0 - 8:1 | 0 | This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`. effect(asyncFunction) + +async () => { + await Promise.resolve(2) + return 2 +} +11:26 - 14:1 | 0 | This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`. effect(asyncFunction) + +async run() { + await Promise.resolve(3) + return 3 + } +18:2 - 21:3 | 0 | This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`. effect(asyncFunction) + +async function nested() { + await Promise.resolve(4) + return 4 + } +26:9 - 29:3 | 0 | This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`. effect(asyncFunction) \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/asyncFunction_preview.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/asyncFunction_preview.ts.codefixes new file mode 100644 index 00000000..ef6e6da4 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/asyncFunction_preview.ts.codefixes @@ -0,0 +1,2 @@ +asyncFunction_skipNextLine from 98 to 140 +asyncFunction_skipFile from 98 to 140 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/asyncFunction_preview.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/asyncFunction_preview.ts.output new file mode 100644 index 00000000..5b089c3d --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/asyncFunction_preview.ts.output @@ -0,0 +1,4 @@ +async () => { + await Promise.resolve(1) +} +4:23 - 6:1 | 0 | This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`. effect(asyncFunction) \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise.ts.codefixes new file mode 100644 index 00000000..5ea2336b --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise.ts.codefixes @@ -0,0 +1,8 @@ +newPromise_skipNextLine from 152 to 196 +newPromise_skipFile from 152 to 196 +newPromise_skipNextLine from 286 to 330 +newPromise_skipFile from 286 to 330 +newPromise_skipNextLine from 424 to 470 +newPromise_skipFile from 424 to 470 +newPromise_skipNextLine from 584 to 628 +newPromise_skipFile from 584 to 628 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise.ts.output new file mode 100644 index 00000000..2bc87250 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise.ts.output @@ -0,0 +1,11 @@ +new Promise((resolve) => resolve(1)) +5:29 - 5:73 | 0 | This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction. effect(newPromise) + +new Promise((resolve) => resolve(2)) +8:36 - 8:80 | 0 | This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction. effect(newPromise) + +new MyPromise((resolve) => resolve(3)) +12:30 - 12:76 | 0 | This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction. effect(newPromise) + +new Promise((resolve) => resolve(4)) +16:9 - 16:53 | 0 | This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction. effect(newPromise) \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise_preview.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise_preview.ts.codefixes new file mode 100644 index 00000000..70237618 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise_preview.ts.codefixes @@ -0,0 +1,2 @@ +newPromise_skipNextLine from 95 to 139 +newPromise_skipFile from 95 to 139 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise_preview.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise_preview.ts.output new file mode 100644 index 00000000..df7de4ba --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/newPromise_preview.ts.output @@ -0,0 +1,2 @@ +new Promise((resolve) => resolve(1)) +4:23 - 4:67 | 0 | This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction. effect(newPromise) \ No newline at end of file diff --git a/packages/harness-effect-v4/examples/diagnostics/asyncFunction.ts b/packages/harness-effect-v4/examples/diagnostics/asyncFunction.ts new file mode 100644 index 00000000..d882e9d7 --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/asyncFunction.ts @@ -0,0 +1,33 @@ +// @effect-diagnostics asyncFunction:warning +import { Effect } from "effect" + +// Should trigger - async function declaration +export async function declaredAsync() { + await Promise.resolve(1) + return 1 +} + +// Should trigger - async arrow function +export const asyncArrow = async () => { + await Promise.resolve(2) + return 2 +} + +// Should trigger - async method +export const asyncMethod = { + async run() { + await Promise.resolve(3) + return 3 + } +} + +// Should trigger - async function inside Effect.gen +export const asyncInGen = Effect.gen(function*() { + return async function nested() { + await Promise.resolve(4) + return 4 + } +}) + +// Should NOT trigger - regular function using Effect +export const regularFunction = () => Effect.succeed(5) diff --git a/packages/harness-effect-v4/examples/diagnostics/asyncFunction_preview.ts b/packages/harness-effect-v4/examples/diagnostics/asyncFunction_preview.ts new file mode 100644 index 00000000..2ae98a10 --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/asyncFunction_preview.ts @@ -0,0 +1,6 @@ +// @effect-diagnostics *:off +// @effect-diagnostics asyncFunction:warning + +export const preview = async () => { + await Promise.resolve(1) +} diff --git a/packages/harness-effect-v4/examples/diagnostics/newPromise.ts b/packages/harness-effect-v4/examples/diagnostics/newPromise.ts new file mode 100644 index 00000000..8ca509cf --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/newPromise.ts @@ -0,0 +1,25 @@ +// @effect-diagnostics newPromise:warning +import { Effect } from "effect" + +// Should trigger - new Promise at module level +export const modulePromise = new Promise((resolve) => resolve(1)) + +// Should trigger - new Promise in regular function +export const regularPromise = () => new Promise((resolve) => resolve(2)) + +// Should trigger - aliased Promise +const MyPromise = Promise +export const aliasedPromise = new MyPromise((resolve) => resolve(3)) + +// Should trigger - new Promise inside Effect.gen +export const promiseInGen = Effect.gen(function*() { + return new Promise((resolve) => resolve(4)) +}) + +// Should NOT trigger - shadowed Promise +export const shadowedPromise = () => { + class Promise { + constructor(readonly value: A) {} + } + return new Promise(5) +} diff --git a/packages/harness-effect-v4/examples/diagnostics/newPromise_preview.ts b/packages/harness-effect-v4/examples/diagnostics/newPromise_preview.ts new file mode 100644 index 00000000..e6001c15 --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/newPromise_preview.ts @@ -0,0 +1,4 @@ +// @effect-diagnostics *:off +// @effect-diagnostics newPromise:warning + +export const preview = new Promise((resolve) => resolve(1)) diff --git a/packages/language-service/src/diagnostics.ts b/packages/language-service/src/diagnostics.ts index 89692f5b..156f887c 100644 --- a/packages/language-service/src/diagnostics.ts +++ b/packages/language-service/src/diagnostics.ts @@ -1,4 +1,5 @@ import { anyUnknownInErrorContext } from "./diagnostics/anyUnknownInErrorContext.js" +import { asyncFunction } from "./diagnostics/asyncFunction.js" import { catchAllToMapError } from "./diagnostics/catchAllToMapError.js" import { catchUnfailableEffect } from "./diagnostics/catchUnfailableEffect.js" import { classSelfMismatch } from "./diagnostics/classSelfMismatch.js" @@ -41,6 +42,7 @@ import { missingLayerContext } from "./diagnostics/missingLayerContext.js" import { missingReturnYieldStar } from "./diagnostics/missingReturnYieldStar.js" import { missingStarInYieldEffectGen } from "./diagnostics/missingStarInYieldEffectGen.js" import { multipleEffectProvide } from "./diagnostics/multipleEffectProvide.js" +import { newPromise } from "./diagnostics/newPromise.js" import { nodeBuiltinImport } from "./diagnostics/nodeBuiltinImport.js" import { nonObjectEffectServiceType } from "./diagnostics/nonObjectEffectServiceType.js" import { outdatedApi } from "./diagnostics/outdatedApi.js" @@ -70,6 +72,7 @@ import { unsupportedServiceAccessors } from "./diagnostics/unsupportedServiceAcc export const diagnostics = [ outdatedApi, anyUnknownInErrorContext, + asyncFunction, instanceOfSchema, catchAllToMapError, catchUnfailableEffect, @@ -86,6 +89,7 @@ export const diagnostics = [ floatingEffect, effectInFailure, missingStarInYieldEffectGen, + newPromise, unnecessaryEffectGen, unnecessaryFailYieldableError, missingReturnYieldStar, diff --git a/packages/language-service/src/diagnostics/asyncFunction.ts b/packages/language-service/src/diagnostics/asyncFunction.ts new file mode 100644 index 00000000..5e8fc4b7 --- /dev/null +++ b/packages/language-service/src/diagnostics/asyncFunction.ts @@ -0,0 +1,53 @@ +import type ts from "typescript" +import * as LSP from "../core/LSP.js" +import * as Nano from "../core/Nano.js" +import * as TypeScriptApi from "../core/TypeScriptApi.js" + +type AsyncFunctionNode = + | ts.FunctionDeclaration + | ts.FunctionExpression + | ts.ArrowFunction + | ts.MethodDeclaration + +export const asyncFunction = LSP.createDiagnostic({ + name: "asyncFunction", + code: 69, + description: + "Warns when declaring async functions and suggests using Effect values and Effect.gen for async control flow", + group: "effectNative", + severity: "off", + fixable: false, + supportedEffect: ["v3", "v4"], + apply: Nano.fn("asyncFunction.apply")(function*(sourceFile, report) { + const ts = yield* Nano.service(TypeScriptApi.TypeScriptApi) + + const hasAsyncModifier = (node: AsyncFunctionNode) => + ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) === true + + const visit = (node: ts.Node) => { + if ( + !ts.isFunctionDeclaration(node) && + !ts.isFunctionExpression(node) && + !ts.isArrowFunction(node) && + !ts.isMethodDeclaration(node) + ) { + ts.forEachChild(node, visit) + return undefined + } + + if (hasAsyncModifier(node)) { + report({ + location: node, + messageText: + "This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`.", + fixes: [] + }) + } + + ts.forEachChild(node, visit) + return undefined + } + + ts.forEachChild(sourceFile, visit) + }) +}) diff --git a/packages/language-service/src/diagnostics/newPromise.ts b/packages/language-service/src/diagnostics/newPromise.ts new file mode 100644 index 00000000..d5a08bdd --- /dev/null +++ b/packages/language-service/src/diagnostics/newPromise.ts @@ -0,0 +1,43 @@ +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 TypeScriptApi from "../core/TypeScriptApi.js" + +export const newPromise = LSP.createDiagnostic({ + name: "newPromise", + code: 68, + description: "Warns when constructing promises with new Promise instead of using Effect APIs", + group: "effectNative", + severity: "off", + fixable: false, + supportedEffect: ["v3", "v4"], + apply: Nano.fn("newPromise.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 promiseSymbol = typeChecker.resolveName("Promise", undefined, ts.SymbolFlags.Value, false) + if (!promiseSymbol) return + + const visit = (node: ts.Node) => { + if (ts.isNewExpression(node)) { + const symbol = typeChecker.getSymbolAtLocation(node.expression) + if (symbol && typeCheckerUtils.resolveToGlobalSymbol(symbol) === promiseSymbol) { + report({ + location: node, + messageText: + "This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction.", + fixes: [] + }) + } + } + + ts.forEachChild(node, visit) + return undefined + } + + ts.forEachChild(sourceFile, visit) + }) +}) diff --git a/packages/language-service/src/metadata.json b/packages/language-service/src/metadata.json index e0631bb8..d9cd3dde 100644 --- a/packages/language-service/src/metadata.json +++ b/packages/language-service/src/metadata.json @@ -26,9 +26,11 @@ "name": "effect-native", "description": "Enable all Effect-native diagnostics at warning level.", "diagnosticSeverity": { + "asyncFunction": "warning", "instanceOfSchema": "warning", "cryptoRandomUUID": "warning", "cryptoRandomUUIDInEffect": "warning", + "newPromise": "warning", "globalFetch": "warning", "globalFetchInEffect": "warning", "processEnv": "warning", @@ -756,6 +758,27 @@ ] } }, + { + "name": "asyncFunction", + "group": "effectNative", + "description": "Warns when declaring async functions and suggests using Effect values and Effect.gen for async control flow", + "defaultSeverity": "off", + "fixable": false, + "supportedEffect": [ + "v3", + "v4" + ], + "preview": { + "sourceText": "\nexport const preview = async () => {\n await Promise.resolve(1)\n}\n", + "diagnostics": [ + { + "start": 24, + "end": 66, + "text": "This code declares an async function, consider representing this async control flow with Effect values and `Effect.gen`. effect(asyncFunction)" + } + ] + } + }, { "name": "cryptoRandomUUID", "group": "effectNative", @@ -1042,6 +1065,27 @@ ] } }, + { + "name": "newPromise", + "group": "effectNative", + "description": "Warns when constructing promises with new Promise instead of using Effect APIs", + "defaultSeverity": "off", + "fixable": false, + "supportedEffect": [ + "v3", + "v4" + ], + "preview": { + "sourceText": "\nexport const preview = new Promise((resolve) => resolve(1))\n", + "diagnostics": [ + { + "start": 24, + "end": 68, + "text": "This code constructs `new Promise(...)`, prefer Effect APIs such as `Effect.async`, `Effect.promise`, or `Effect.tryPromise` instead of manual Promise construction. effect(newPromise)" + } + ] + } + }, { "name": "nodeBuiltinImport", "group": "effectNative", diff --git a/schema.json b/schema.json index 3dbf1cf9..d4c1d5dc 100644 --- a/schema.json +++ b/schema.json @@ -2171,6 +2171,18 @@ "default": "off", "description": "Detects 'any' or 'unknown' types in Effect error or requirements channels Default severity: off." }, + "asyncFunction": { + "type": "string", + "enum": [ + "off", + "error", + "warning", + "message", + "suggestion" + ], + "default": "off", + "description": "Warns when declaring async functions and suggests using Effect values and Effect.gen for async control flow Default severity: off." + }, "catchAllToMapError": { "type": "string", "enum": [ @@ -2675,6 +2687,18 @@ "default": "warning", "description": "Warns against chaining Effect.provide calls which can cause service lifecycle issues Default severity: warning." }, + "newPromise": { + "type": "string", + "enum": [ + "off", + "error", + "warning", + "message", + "suggestion" + ], + "default": "off", + "description": "Warns when constructing promises with new Promise instead of using Effect APIs Default severity: off." + }, "nodeBuiltinImport": { "type": "string", "enum": [