From 0d1e2a1b5d2d77e3e7594a64599db3d8a9edcf86 Mon Sep 17 00:00:00 2001 From: Mattia Manzati Date: Mon, 23 Mar 2026 10:20:08 +0100 Subject: [PATCH] Add effectFn implicit any diagnostic --- .changeset/real-trains-glow.md | 5 + README.md | 1 + .../__snapshots__/completions.test.ts.snap | 4 +- .../effectFnImplicitAny.ts.codefixes | 10 ++ .../diagnostics/effectFnImplicitAny.ts.output | 14 +++ .../effectFnImplicitAny_preview.ts.codefixes | 2 + .../effectFnImplicitAny_preview.ts.output | 2 + .../diagnostics/effectFnImplicitAny.ts | 40 +++++++ .../effectFnImplicitAny_preview.ts | 6 + .../__snapshots__/completions.test.ts.snap | 4 +- .../effectFnImplicitAny.ts.codefixes | 10 ++ .../diagnostics/effectFnImplicitAny.ts.output | 14 +++ .../effectFnImplicitAny_preview.ts.codefixes | 2 + .../effectFnImplicitAny_preview.ts.output | 2 + .../diagnostics/effectFnImplicitAny.ts | 40 +++++++ .../effectFnImplicitAny_preview.ts | 6 + packages/language-service/src/diagnostics.ts | 2 + .../src/diagnostics/effectFnImplicitAny.ts | 112 ++++++++++++++++++ packages/language-service/src/metadata.json | 21 ++++ .../language-service/test/diagnostics.test.ts | 15 ++- .../language-service/test/metadata.test.ts | 9 +- packages/language-service/test/utils/mocks.ts | 17 ++- schema.json | 12 ++ 23 files changed, 338 insertions(+), 12 deletions(-) create mode 100644 .changeset/real-trains-glow.md create mode 100644 packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny.ts.codefixes create mode 100644 packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny.ts.output create mode 100644 packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.codefixes create mode 100644 packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.output create mode 100644 packages/harness-effect-v3/examples/diagnostics/effectFnImplicitAny.ts create mode 100644 packages/harness-effect-v3/examples/diagnostics/effectFnImplicitAny_preview.ts create mode 100644 packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny.ts.codefixes create mode 100644 packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny.ts.output create mode 100644 packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.codefixes create mode 100644 packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.output create mode 100644 packages/harness-effect-v4/examples/diagnostics/effectFnImplicitAny.ts create mode 100644 packages/harness-effect-v4/examples/diagnostics/effectFnImplicitAny_preview.ts create mode 100644 packages/language-service/src/diagnostics/effectFnImplicitAny.ts diff --git a/.changeset/real-trains-glow.md b/.changeset/real-trains-glow.md new file mode 100644 index 00000000..b7785762 --- /dev/null +++ b/.changeset/real-trains-glow.md @@ -0,0 +1,5 @@ +--- +"@effect/language-service": minor +--- + +Add the `effectFnImplicitAny` diagnostic to mirror `noImplicitAny` for unannotated `Effect.fn` and `Effect.fnUntraced` callback parameters, and support `// @strict` in diagnostic example files so test fixtures can enable strict compiler options. diff --git a/README.md b/README.md index 8fed049e..15e75ba6 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Some diagnostics are off by default or have a default severity of suggestion, bu anyUnknownInErrorContext➖Detects 'any' or 'unknown' types in Effect error or requirements channels✓✓ classSelfMismatch❌🔧Ensures Self type parameter matches the class name in Service/Tag/Schema classes✓✓ duplicatePackage⚠️Detects when multiple versions of the same Effect package are loaded✓✓ + effectFnImplicitAny❌Mirrors noImplicitAny for unannotated Effect.fn and Effect.fnUntraced callback parameters when no outer contextual function type exists✓✓ floatingEffect❌Ensures Effects are yielded or assigned to variables, not left floating✓✓ genericEffectServices⚠️Prevents services with type parameters that cannot be discriminated at runtime✓✓ missingEffectContext❌Reports missing service requirements in Effect context channel✓✓ diff --git a/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap b/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap index 61ad776b..3295306b 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,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,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,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,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,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,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,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,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/effectFnImplicitAny.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny.ts.codefixes new file mode 100644 index 00000000..70eabb83 --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny.ts.codefixes @@ -0,0 +1,10 @@ +effectFnImplicitAny_skipNextLine from 709 to 710 +effectFnImplicitAny_skipFile from 709 to 710 +effectFnImplicitAny_skipNextLine from 712 to 713 +effectFnImplicitAny_skipFile from 712 to 713 +effectFnImplicitAny_skipNextLine from 580 to 585 +effectFnImplicitAny_skipFile from 580 to 585 +effectFnImplicitAny_skipNextLine from 408 to 413 +effectFnImplicitAny_skipFile from 408 to 413 +effectFnImplicitAny_skipNextLine from 239 to 244 +effectFnImplicitAny_skipFile from 239 to 244 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny.ts.output new file mode 100644 index 00000000..bdf3eed3 --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny.ts.output @@ -0,0 +1,14 @@ +input +6:60 - 6:65 | 1 | Parameter 'input' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) + +input +11:65 - 11:70 | 1 | Parameter 'input' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) + +input +14:62 - 14:67 | 1 | Parameter 'input' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) + +a +19:44 - 19:45 | 1 | Parameter 'a' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) + +b +19:47 - 19:48 | 1 | Parameter 'b' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.codefixes new file mode 100644 index 00000000..b8821759 --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.codefixes @@ -0,0 +1,2 @@ +effectFnImplicitAny_skipNextLine from 175 to 180 +effectFnImplicitAny_skipFile from 175 to 180 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.output new file mode 100644 index 00000000..5d0e6de3 --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.output @@ -0,0 +1,2 @@ +input +6:45 - 6:50 | 1 | Parameter 'input' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) \ No newline at end of file diff --git a/packages/harness-effect-v3/examples/diagnostics/effectFnImplicitAny.ts b/packages/harness-effect-v3/examples/diagnostics/effectFnImplicitAny.ts new file mode 100644 index 00000000..c3379026 --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/effectFnImplicitAny.ts @@ -0,0 +1,40 @@ +// @strict +// @effect-diagnostics effectFnImplicitAny:error +import * as Effect from "effect/Effect" + +// Should trigger - standalone Effect.fn generator callback falls back to any +export const standalone = Effect.fn("standalone")(function*(input) { + return input +}) + +// Should trigger - standalone Effect.fn regular callback falls back to any +export const standaloneRegular = Effect.fn("standaloneRegular")((input) => Effect.succeed(input)) + +// Should trigger - standalone Effect.fnUntraced callback falls back to any +export const standaloneUntraced = Effect.fnUntraced(function*(input) { + return input +}) + +// Should trigger - multiple params are all implicit any +export const multiple = Effect.fn(function*(a, b) { + return [a, b] as const +}) + +// Should not trigger - outer contextual any matches normal noImplicitAny behavior +declare const acceptsAny: (f: (input: any) => Effect.Effect) => void +acceptsAny(Effect.fn("acceptsAny")(function*(input) { + return input +})) + +// Should not trigger - outer contextual function type provides a concrete input type +declare const acceptsString: (f: (input: string) => Effect.Effect) => void +acceptsString(Effect.fn("acceptsString")((input) => Effect.succeed(input.length))) + +// Should not trigger - destructuring receives its type from the outer callback type +declare const acceptsRequest: (f: (input: { readonly id: string }) => Effect.Effect) => void +acceptsRequest(Effect.fn("acceptsRequest")(function*({ id }) { + return id +})) + +// Should not trigger - explicit parameter types are already present +export const typed = Effect.fn("typed")((input: string) => Effect.succeed(input.length)) diff --git a/packages/harness-effect-v3/examples/diagnostics/effectFnImplicitAny_preview.ts b/packages/harness-effect-v3/examples/diagnostics/effectFnImplicitAny_preview.ts new file mode 100644 index 00000000..27542f60 --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/effectFnImplicitAny_preview.ts @@ -0,0 +1,6 @@ +// @strict +// @effect-diagnostics *:off +// @effect-diagnostics effectFnImplicitAny:error +import * as Effect from "effect/Effect" + +export const preview = Effect.fn("preview")((input) => Effect.succeed(input)) diff --git a/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap b/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap index 7c45c1ab..d1aa5ab8 100644 --- a/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap +++ b/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap @@ -143,7 +143,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,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,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,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,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", @@ -154,7 +154,7 @@ exports[`Completion effectDiagnosticsComment > effectDiagnosticsComment.ts at 2: "sortText": "11", }, { - "insertText": "@effect-diagnostics-next-line \${1|anyUnknownInErrorContext,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,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,catchAllToMapError,catchUnfailableEffect,classSelfMismatch,deterministicKeys,duplicatePackage,effectFnIife,effectFnImplicitAny,effectFnOpportunity,effectGenUsesAdapter,effectInFailure,effectInVoidSuccess,effectMapVoid,effectSucceedWithVoid,extendsNativeError,floatingEffect,genericEffectServices,globalErrorInEffectCatch,globalErrorInEffectFailure,globalFetch,importFromBarrel,instanceOfSchema,layerMergeAllWithDependencies,leakingRequirements,missedPipeableOpportunity,missingEffectContext,missingEffectError,missingEffectServiceDependency,missingLayerContext,missingReturnYieldStar,missingStarInYieldEffectGen,multipleEffectProvide,nodeBuiltinImport,nonObjectEffectServiceType,outdatedApi,outdatedEffectCodegen,overriddenSchemaConstructor,preferSchemaOverJson,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/effectFnImplicitAny.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny.ts.codefixes new file mode 100644 index 00000000..70eabb83 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny.ts.codefixes @@ -0,0 +1,10 @@ +effectFnImplicitAny_skipNextLine from 709 to 710 +effectFnImplicitAny_skipFile from 709 to 710 +effectFnImplicitAny_skipNextLine from 712 to 713 +effectFnImplicitAny_skipFile from 712 to 713 +effectFnImplicitAny_skipNextLine from 580 to 585 +effectFnImplicitAny_skipFile from 580 to 585 +effectFnImplicitAny_skipNextLine from 408 to 413 +effectFnImplicitAny_skipFile from 408 to 413 +effectFnImplicitAny_skipNextLine from 239 to 244 +effectFnImplicitAny_skipFile from 239 to 244 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny.ts.output new file mode 100644 index 00000000..bdf3eed3 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny.ts.output @@ -0,0 +1,14 @@ +input +6:60 - 6:65 | 1 | Parameter 'input' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) + +input +11:65 - 11:70 | 1 | Parameter 'input' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) + +input +14:62 - 14:67 | 1 | Parameter 'input' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) + +a +19:44 - 19:45 | 1 | Parameter 'a' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) + +b +19:47 - 19:48 | 1 | Parameter 'b' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.codefixes new file mode 100644 index 00000000..b8821759 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.codefixes @@ -0,0 +1,2 @@ +effectFnImplicitAny_skipNextLine from 175 to 180 +effectFnImplicitAny_skipFile from 175 to 180 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.output new file mode 100644 index 00000000..5d0e6de3 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/effectFnImplicitAny_preview.ts.output @@ -0,0 +1,2 @@ +input +6:45 - 6:50 | 1 | Parameter 'input' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny) \ No newline at end of file diff --git a/packages/harness-effect-v4/examples/diagnostics/effectFnImplicitAny.ts b/packages/harness-effect-v4/examples/diagnostics/effectFnImplicitAny.ts new file mode 100644 index 00000000..c3379026 --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/effectFnImplicitAny.ts @@ -0,0 +1,40 @@ +// @strict +// @effect-diagnostics effectFnImplicitAny:error +import * as Effect from "effect/Effect" + +// Should trigger - standalone Effect.fn generator callback falls back to any +export const standalone = Effect.fn("standalone")(function*(input) { + return input +}) + +// Should trigger - standalone Effect.fn regular callback falls back to any +export const standaloneRegular = Effect.fn("standaloneRegular")((input) => Effect.succeed(input)) + +// Should trigger - standalone Effect.fnUntraced callback falls back to any +export const standaloneUntraced = Effect.fnUntraced(function*(input) { + return input +}) + +// Should trigger - multiple params are all implicit any +export const multiple = Effect.fn(function*(a, b) { + return [a, b] as const +}) + +// Should not trigger - outer contextual any matches normal noImplicitAny behavior +declare const acceptsAny: (f: (input: any) => Effect.Effect) => void +acceptsAny(Effect.fn("acceptsAny")(function*(input) { + return input +})) + +// Should not trigger - outer contextual function type provides a concrete input type +declare const acceptsString: (f: (input: string) => Effect.Effect) => void +acceptsString(Effect.fn("acceptsString")((input) => Effect.succeed(input.length))) + +// Should not trigger - destructuring receives its type from the outer callback type +declare const acceptsRequest: (f: (input: { readonly id: string }) => Effect.Effect) => void +acceptsRequest(Effect.fn("acceptsRequest")(function*({ id }) { + return id +})) + +// Should not trigger - explicit parameter types are already present +export const typed = Effect.fn("typed")((input: string) => Effect.succeed(input.length)) diff --git a/packages/harness-effect-v4/examples/diagnostics/effectFnImplicitAny_preview.ts b/packages/harness-effect-v4/examples/diagnostics/effectFnImplicitAny_preview.ts new file mode 100644 index 00000000..27542f60 --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/effectFnImplicitAny_preview.ts @@ -0,0 +1,6 @@ +// @strict +// @effect-diagnostics *:off +// @effect-diagnostics effectFnImplicitAny:error +import * as Effect from "effect/Effect" + +export const preview = Effect.fn("preview")((input) => Effect.succeed(input)) diff --git a/packages/language-service/src/diagnostics.ts b/packages/language-service/src/diagnostics.ts index 2ada4caa..9ba20eac 100644 --- a/packages/language-service/src/diagnostics.ts +++ b/packages/language-service/src/diagnostics.ts @@ -5,6 +5,7 @@ import { classSelfMismatch } from "./diagnostics/classSelfMismatch.js" import { deterministicKeys } from "./diagnostics/deterministicKeys.js" import { duplicatePackage } from "./diagnostics/duplicatePackage.js" import { effectFnIife } from "./diagnostics/effectFnIife.js" +import { effectFnImplicitAny } from "./diagnostics/effectFnImplicitAny.js" import { effectFnOpportunity } from "./diagnostics/effectFnOpportunity.js" import { effectGenUsesAdapter } from "./diagnostics/effectGenUsesAdapter.js" import { effectInFailure } from "./diagnostics/effectInFailure.js" @@ -61,6 +62,7 @@ export const diagnostics = [ catchUnfailableEffect, classSelfMismatch, duplicatePackage, + effectFnImplicitAny, effectGenUsesAdapter, missingEffectContext, missingEffectError, diff --git a/packages/language-service/src/diagnostics/effectFnImplicitAny.ts b/packages/language-service/src/diagnostics/effectFnImplicitAny.ts new file mode 100644 index 00000000..3d88813c --- /dev/null +++ b/packages/language-service/src/diagnostics/effectFnImplicitAny.ts @@ -0,0 +1,112 @@ +import { pipe } from "effect/Function" +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 TypeParser from "../core/TypeParser.js" +import * as TypeScriptApi from "../core/TypeScriptApi.js" + +type EffectFnResult = { + readonly call: ts.CallExpression + readonly fn: + | ts.ArrowFunction + | ts.FunctionExpression +} + +const getParameterName = (typescript: TypeScriptApi.TypeScriptApi, name: ts.BindingName): string => { + if (typescript.isIdentifier(name)) { + return typescript.idText(name) + } + return "parameter" +} + +const hasOuterContextualFunctionType = ( + typescript: TypeScriptApi.TypeScriptApi, + typeChecker: ts.TypeChecker, + node: ts.CallExpression +): boolean => { + const contextualType = typeChecker.getContextualType(node) + if (!contextualType) { + return false + } + return typeChecker.getSignaturesOfType(contextualType, typescript.SignatureKind.Call).length > 0 +} + +export const effectFnImplicitAny = LSP.createDiagnostic({ + name: "effectFnImplicitAny", + code: 54, + description: + "Mirrors noImplicitAny for unannotated Effect.fn and Effect.fnUntraced callback parameters when no outer contextual function type exists", + group: "correctness", + severity: "error", + fixable: false, + supportedEffect: ["v3", "v4"], + apply: Nano.fn("effectFnImplicitAny.apply")(function*(sourceFile, report) { + const ts = yield* Nano.service(TypeScriptApi.TypeScriptApi) + const program = yield* Nano.service(TypeScriptApi.TypeScriptProgram) + const typeChecker = yield* Nano.service(TypeCheckerApi.TypeCheckerApi) + const typeParser = yield* Nano.service(TypeParser.TypeParser) + + const noImplicitAny = program.getCompilerOptions().noImplicitAny ?? program.getCompilerOptions().strict ?? false + if (!noImplicitAny) { + return + } + + const nodeToVisit: Array = [sourceFile] + const appendNodeToVisit = (node: ts.Node) => { + nodeToVisit.push(node) + return undefined + } + + while (nodeToVisit.length > 0) { + const node = nodeToVisit.pop()! + ts.forEachChild(node, appendNodeToVisit) + + const parsed = yield* pipe( + typeParser.effectFn(node), + Nano.map((result): EffectFnResult => ({ + call: result.node as ts.CallExpression, + fn: result.regularFunction + })), + Nano.orElse(() => + pipe( + typeParser.effectFnGen(node), + Nano.map((result): EffectFnResult => ({ + call: result.node as ts.CallExpression, + fn: result.generatorFunction + })) + ) + ), + Nano.orElse(() => + pipe( + typeParser.effectFnUntracedGen(node), + Nano.map((result): EffectFnResult => ({ + call: result.node as ts.CallExpression, + fn: result.generatorFunction + })) + ) + ), + Nano.orUndefined + ) + + if (!parsed || hasOuterContextualFunctionType(ts, typeChecker, parsed.call)) { + continue + } + + for (const parameter of parsed.fn.parameters) { + if (parameter.type || parameter.initializer) { + continue + } + + const parameterName = getParameterName(ts, parameter.name) + + report({ + location: parameter.name, + messageText: + `Parameter '${parameterName}' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type.`, + fixes: [] + }) + } + } + }) +}) diff --git a/packages/language-service/src/metadata.json b/packages/language-service/src/metadata.json index 56844528..f490f0d9 100644 --- a/packages/language-service/src/metadata.json +++ b/packages/language-service/src/metadata.json @@ -89,6 +89,27 @@ "diagnostics": [] } }, + { + "name": "effectFnImplicitAny", + "group": "correctness", + "description": "Mirrors noImplicitAny for unannotated Effect.fn and Effect.fnUntraced callback parameters when no outer contextual function type exists", + "defaultSeverity": "error", + "fixable": false, + "supportedEffect": [ + "v3", + "v4" + ], + "preview": { + "sourceText": "import * as Effect from \"effect/Effect\"\n\nexport const preview = Effect.fn(\"preview\")((input) => Effect.succeed(input))\n", + "diagnostics": [ + { + "start": 86, + "end": 91, + "text": "Parameter 'input' implicitly has an 'any' type in Effect.fn/Effect.fnUntraced. Add an explicit type annotation or provide a contextual function type. effect(effectFnImplicitAny)" + } + ] + } + }, { "name": "floatingEffect", "group": "correctness", diff --git a/packages/language-service/test/diagnostics.test.ts b/packages/language-service/test/diagnostics.test.ts index 264595b4..de079eba 100644 --- a/packages/language-service/test/diagnostics.test.ts +++ b/packages/language-service/test/diagnostics.test.ts @@ -25,6 +25,12 @@ import { applyEdits, configFromSourceComment, createServicesWithMockedVFS } from const getExamplesDiagnosticsDir = () => getExamplesSubdir("diagnostics") +function compilerOptionsFromSourceComment(sourceText: string): ts.CompilerOptions { + return { + ...(sourceText.includes("// @strict") ? { strict: true } : {}) + } +} + function diagnosticToLogFormat( sourceFile: ts.SourceFile, sourceText: string, @@ -56,7 +62,8 @@ function testDiagnosticOnExample( getHarnessDir(), getExamplesDir(), fileName, - sourceText + sourceText, + compilerOptionsFromSourceComment(sourceText) ) try { @@ -133,7 +140,8 @@ function testDiagnosticQuickfixesOnExample( getHarnessDir(), getExamplesDir(), fileName, - sourceText + sourceText, + compilerOptionsFromSourceComment(sourceText) ) languageServicesToDispose.push(languageService) @@ -191,7 +199,8 @@ function testDiagnosticQuickfixesOnExample( getHarnessDir(), getExamplesDir(), fileName, - finalSource + finalSource, + compilerOptionsFromSourceComment(finalSource) ) languageServicesToDispose.push(result.languageService) const typeDiags = result.program.getSemanticDiagnostics().filter((_) => diff --git a/packages/language-service/test/metadata.test.ts b/packages/language-service/test/metadata.test.ts index d00a074e..dcc5eec5 100644 --- a/packages/language-service/test/metadata.test.ts +++ b/packages/language-service/test/metadata.test.ts @@ -31,6 +31,12 @@ interface TrimmedPreview { const metadataPath = path.join(__dirname, "..", "src", "metadata.json") const diagnosticGroupOrder = new Map(diagnosticGroups.map((group, index) => [group.id, index])) +function compilerOptionsFromSourceComment(sourceText: string): ts.CompilerOptions { + return { + ...(sourceText.includes("// @strict") ? { strict: true } : {}) + } +} + function getPreviewFileForDiagnostic(diagnostic: LSP.DiagnosticDefinition): PreviewFile { for (const harnessVersion of ["v4", "v3"] as const) { const fileName = path.join("examples", "diagnostics", `${diagnostic.name}_preview.ts`) @@ -54,7 +60,8 @@ function getDiagnosticOutput( getHarnessDirForVersion(previewFile.harnessVersion), getExamplesDirForVersion(previewFile.harnessVersion), previewFile.fileName, - previewFile.sourceText + previewFile.sourceText, + compilerOptionsFromSourceComment(previewFile.sourceText) ) try { diff --git a/packages/language-service/test/utils/mocks.ts b/packages/language-service/test/utils/mocks.ts index 2cb7b589..23fdcade 100644 --- a/packages/language-service/test/utils/mocks.ts +++ b/packages/language-service/test/utils/mocks.ts @@ -6,7 +6,8 @@ export function createMockLanguageServiceHost( harnessDir: string, examplesDir: string, fileName: string, - sourceText: string + sourceText: string, + compilerOptionsOverrides: ts.CompilerOptions = {} ): ts.LanguageServiceHost { const realPath = (fileName: string) => path.resolve(harnessDir, fileName) @@ -21,7 +22,8 @@ export function createMockLanguageServiceHost( moduleResolution: ts.ModuleResolutionKind.NodeNext, paths: { "@/*": [path.join(examplesDir, "*")] - } + }, + ...compilerOptionsOverrides } }, getScriptFileNames() { @@ -55,9 +57,16 @@ export function createServicesWithMockedVFS( harnessDir: string, examplesDir: string, fileName: string, - sourceText: string + sourceText: string, + compilerOptionsOverrides: ts.CompilerOptions = {} ) { - const languageServiceHost = createMockLanguageServiceHost(harnessDir, examplesDir, fileName, sourceText) + const languageServiceHost = createMockLanguageServiceHost( + harnessDir, + examplesDir, + fileName, + sourceText, + compilerOptionsOverrides + ) const languageService = ts.createLanguageService( languageServiceHost, undefined, diff --git a/schema.json b/schema.json index fd837fb2..f17beff5 100644 --- a/schema.json +++ b/schema.json @@ -2243,6 +2243,18 @@ "default": "warning", "description": "Effect.fn or Effect.fnUntraced is called as an IIFE (Immediately Invoked Function Expression). Use Effect.gen instead. Default severity: warning." }, + "effectFnImplicitAny": { + "type": "string", + "enum": [ + "off", + "error", + "warning", + "message", + "suggestion" + ], + "default": "error", + "description": "Mirrors noImplicitAny for unannotated Effect.fn and Effect.fnUntraced callback parameters when no outer contextual function type exists Default severity: error." + }, "effectFnOpportunity": { "type": "string", "enum": [