diff --git a/.changeset/green-turtles-laugh.md b/.changeset/green-turtles-laugh.md new file mode 100644 index 00000000..3da14cc2 --- /dev/null +++ b/.changeset/green-turtles-laugh.md @@ -0,0 +1,9 @@ +--- +"@effect/language-service": minor +--- + +Add `processEnv` and `processEnvInEffect` diagnostics to guide `process.env.*` reads toward Effect `Config` APIs. + +Examples: +- `process.env.PORT` +- `process.env["API_KEY"]` diff --git a/README.md b/README.md index f8bbf651..22b80e43 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ Some diagnostics are off by default or have a default severity of suggestion, bu instanceOfSchema➖🔧Suggests using Schema.is instead of instanceof for Effect Schema types✓✓ 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✓✓ + processEnvInEffect➖Warns when reading process.env inside Effect generators instead of using Effect Config✓✓ Style Cleanup, consistency, and idiomatic Effect code. catchAllToMapError💡🔧Suggests using Effect.mapError instead of Effect.catchAll when the callback only wraps the error with Effect.fail✓✓ deterministicKeys➖🔧Enforces deterministic naming for service/tag/error identifiers based on class names✓✓ diff --git a/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap b/packages/harness-effect-v3/__snapshots__/completions.test.ts.snap index 6e6150d4..f452cd9d 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,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,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,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", "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,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,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,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", "isSnippet": true, "kind": "string", "name": "@effect-diagnostics-next-line", diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv.ts.codefixes new file mode 100644 index 00000000..f210bbdc --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv.ts.codefixes @@ -0,0 +1,6 @@ +processEnv_skipNextLine from 173 to 189 +processEnv_skipFile from 173 to 189 +processEnv_skipNextLine from 272 to 291 +processEnv_skipFile from 272 to 291 +processEnv_skipNextLine from 578 to 597 +processEnv_skipFile from 578 to 597 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv.ts.output new file mode 100644 index 00000000..6dd5235b --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv.ts.output @@ -0,0 +1,8 @@ +process.env.PORT +6:19 - 6:35 | 0 | This code reads from `process.env`, environment configuration is represented through `Config` from Effect. effect(processEnv) + +process.env["HOST"] +9:26 - 9:45 | 0 | This code reads from `process.env`, environment configuration is represented through `Config` from Effect. effect(processEnv) + +process.env.API_KEY +18:19 - 18:38 | 0 | This code reads from `process.env`, environment configuration is represented through `Config` from Effect. effect(processEnv) \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect.ts.codefixes new file mode 100644 index 00000000..f0a0d60c --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect.ts.codefixes @@ -0,0 +1,4 @@ +processEnvInEffect_skipNextLine from 231 to 249 +processEnvInEffect_skipFile from 231 to 249 +processEnvInEffect_skipNextLine from 373 to 395 +processEnvInEffect_skipFile from 373 to 395 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect.ts.output new file mode 100644 index 00000000..739a747f --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect.ts.output @@ -0,0 +1,5 @@ +process.env.SECRET +7:9 - 7:27 | 0 | This Effect code reads from `process.env`, environment configuration in Effect code is represented through `Config` from Effect. effect(processEnvInEffect) + +process.env["API_KEY"] +12:9 - 12:31 | 0 | This Effect code reads from `process.env`, environment configuration in Effect code is represented through `Config` from Effect. effect(processEnvInEffect) \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect_preview.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect_preview.ts.codefixes new file mode 100644 index 00000000..887a8ebb --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect_preview.ts.codefixes @@ -0,0 +1,2 @@ +processEnvInEffect_skipNextLine from 200 to 216 +processEnvInEffect_skipFile from 200 to 216 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect_preview.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect_preview.ts.output new file mode 100644 index 00000000..2929504c --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnvInEffect_preview.ts.output @@ -0,0 +1,2 @@ +process.env.PORT +7:9 - 7:25 | 0 | This Effect code reads from `process.env`, environment configuration in Effect code is represented through `Config` from Effect. effect(processEnvInEffect) \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv_preview.ts.codefixes b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv_preview.ts.codefixes new file mode 100644 index 00000000..46826369 --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv_preview.ts.codefixes @@ -0,0 +1,2 @@ +processEnv_skipNextLine from 126 to 142 +processEnv_skipFile from 126 to 142 \ No newline at end of file diff --git a/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv_preview.ts.output b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv_preview.ts.output new file mode 100644 index 00000000..c2f80bdb --- /dev/null +++ b/packages/harness-effect-v3/__snapshots__/diagnostics/processEnv_preview.ts.output @@ -0,0 +1,2 @@ +process.env.PORT +5:23 - 5:39 | 0 | This code reads from `process.env`, environment configuration is represented through `Config` from Effect. effect(processEnv) \ No newline at end of file diff --git a/packages/harness-effect-v3/examples/diagnostics/processEnv.ts b/packages/harness-effect-v3/examples/diagnostics/processEnv.ts new file mode 100644 index 00000000..70c956e0 --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/processEnv.ts @@ -0,0 +1,20 @@ +// @effect-diagnostics processEnv:warning +/// +import { Effect } from "effect" + +// Should trigger - process.env at module level +const _moduleEnv = process.env.PORT + +// Should trigger - bracket access in regular function +const _regularEnv = () => process.env["HOST"] + +// Should NOT trigger - process.env directly inside Effect.gen +export const envInGen = Effect.gen(function*() { + return process.env.SECRET +}) + +// Should trigger - process.env inside nested arrow in generator +export const nestedArrowInGen = Effect.gen(function*() { + const fn = () => process.env.API_KEY + return fn +}) diff --git a/packages/harness-effect-v3/examples/diagnostics/processEnvInEffect.ts b/packages/harness-effect-v3/examples/diagnostics/processEnvInEffect.ts new file mode 100644 index 00000000..89fe6635 --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/processEnvInEffect.ts @@ -0,0 +1,25 @@ +// @effect-diagnostics processEnvInEffect:warning +/// +import { Effect } from "effect" + +// Should trigger - process.env directly inside Effect.gen +export const envInGen = Effect.gen(function*() { + return process.env.SECRET +}) + +// Should trigger - bracket access inside Effect.fn +export const envInFn = Effect.fn("envInFn")(function*() { + return process.env["API_KEY"] +}) + +// Should NOT trigger - process.env at module level +const _moduleEnv = process.env.PORT + +// Should NOT trigger - process.env in regular function +const _regularEnv = () => process.env["HOST"] + +// Should NOT trigger - process.env inside nested arrow in generator +export const nestedArrowInGen = Effect.gen(function*() { + const fn = () => process.env.NODE_ENV + return fn +}) diff --git a/packages/harness-effect-v3/examples/diagnostics/processEnvInEffect_preview.ts b/packages/harness-effect-v3/examples/diagnostics/processEnvInEffect_preview.ts new file mode 100644 index 00000000..bcbaf45f --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/processEnvInEffect_preview.ts @@ -0,0 +1,8 @@ +// @effect-diagnostics *:off +// @effect-diagnostics processEnvInEffect:warning +/// +import { Effect } from "effect" + +export const preview = Effect.gen(function*() { + return process.env.PORT +}) diff --git a/packages/harness-effect-v3/examples/diagnostics/processEnv_preview.ts b/packages/harness-effect-v3/examples/diagnostics/processEnv_preview.ts new file mode 100644 index 00000000..723593e2 --- /dev/null +++ b/packages/harness-effect-v3/examples/diagnostics/processEnv_preview.ts @@ -0,0 +1,5 @@ +// @effect-diagnostics *:off +// @effect-diagnostics processEnv:warning +/// + +export const preview = process.env.PORT diff --git a/packages/harness-effect-v3/package.json b/packages/harness-effect-v3/package.json index 72cf620f..07f47853 100644 --- a/packages/harness-effect-v3/package.json +++ b/packages/harness-effect-v3/package.json @@ -12,5 +12,8 @@ "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.14" + }, + "devDependencies": { + "@types/node": "^25.0.6" } } diff --git a/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap b/packages/harness-effect-v4/__snapshots__/completions.test.ts.snap index 740b947d..6778c6ba 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,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,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,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", "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,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,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,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", "isSnippet": true, "kind": "string", "name": "@effect-diagnostics-next-line", diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv.ts.codefixes new file mode 100644 index 00000000..f210bbdc --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv.ts.codefixes @@ -0,0 +1,6 @@ +processEnv_skipNextLine from 173 to 189 +processEnv_skipFile from 173 to 189 +processEnv_skipNextLine from 272 to 291 +processEnv_skipFile from 272 to 291 +processEnv_skipNextLine from 578 to 597 +processEnv_skipFile from 578 to 597 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv.ts.output new file mode 100644 index 00000000..6dd5235b --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv.ts.output @@ -0,0 +1,8 @@ +process.env.PORT +6:19 - 6:35 | 0 | This code reads from `process.env`, environment configuration is represented through `Config` from Effect. effect(processEnv) + +process.env["HOST"] +9:26 - 9:45 | 0 | This code reads from `process.env`, environment configuration is represented through `Config` from Effect. effect(processEnv) + +process.env.API_KEY +18:19 - 18:38 | 0 | This code reads from `process.env`, environment configuration is represented through `Config` from Effect. effect(processEnv) \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect.ts.codefixes new file mode 100644 index 00000000..f0a0d60c --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect.ts.codefixes @@ -0,0 +1,4 @@ +processEnvInEffect_skipNextLine from 231 to 249 +processEnvInEffect_skipFile from 231 to 249 +processEnvInEffect_skipNextLine from 373 to 395 +processEnvInEffect_skipFile from 373 to 395 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect.ts.output new file mode 100644 index 00000000..739a747f --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect.ts.output @@ -0,0 +1,5 @@ +process.env.SECRET +7:9 - 7:27 | 0 | This Effect code reads from `process.env`, environment configuration in Effect code is represented through `Config` from Effect. effect(processEnvInEffect) + +process.env["API_KEY"] +12:9 - 12:31 | 0 | This Effect code reads from `process.env`, environment configuration in Effect code is represented through `Config` from Effect. effect(processEnvInEffect) \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect_preview.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect_preview.ts.codefixes new file mode 100644 index 00000000..887a8ebb --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect_preview.ts.codefixes @@ -0,0 +1,2 @@ +processEnvInEffect_skipNextLine from 200 to 216 +processEnvInEffect_skipFile from 200 to 216 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect_preview.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect_preview.ts.output new file mode 100644 index 00000000..2929504c --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnvInEffect_preview.ts.output @@ -0,0 +1,2 @@ +process.env.PORT +7:9 - 7:25 | 0 | This Effect code reads from `process.env`, environment configuration in Effect code is represented through `Config` from Effect. effect(processEnvInEffect) \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv_preview.ts.codefixes b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv_preview.ts.codefixes new file mode 100644 index 00000000..46826369 --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv_preview.ts.codefixes @@ -0,0 +1,2 @@ +processEnv_skipNextLine from 126 to 142 +processEnv_skipFile from 126 to 142 \ No newline at end of file diff --git a/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv_preview.ts.output b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv_preview.ts.output new file mode 100644 index 00000000..c2f80bdb --- /dev/null +++ b/packages/harness-effect-v4/__snapshots__/diagnostics/processEnv_preview.ts.output @@ -0,0 +1,2 @@ +process.env.PORT +5:23 - 5:39 | 0 | This code reads from `process.env`, environment configuration is represented through `Config` from Effect. effect(processEnv) \ No newline at end of file diff --git a/packages/harness-effect-v4/examples/diagnostics/processEnv.ts b/packages/harness-effect-v4/examples/diagnostics/processEnv.ts new file mode 100644 index 00000000..70c956e0 --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/processEnv.ts @@ -0,0 +1,20 @@ +// @effect-diagnostics processEnv:warning +/// +import { Effect } from "effect" + +// Should trigger - process.env at module level +const _moduleEnv = process.env.PORT + +// Should trigger - bracket access in regular function +const _regularEnv = () => process.env["HOST"] + +// Should NOT trigger - process.env directly inside Effect.gen +export const envInGen = Effect.gen(function*() { + return process.env.SECRET +}) + +// Should trigger - process.env inside nested arrow in generator +export const nestedArrowInGen = Effect.gen(function*() { + const fn = () => process.env.API_KEY + return fn +}) diff --git a/packages/harness-effect-v4/examples/diagnostics/processEnvInEffect.ts b/packages/harness-effect-v4/examples/diagnostics/processEnvInEffect.ts new file mode 100644 index 00000000..89fe6635 --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/processEnvInEffect.ts @@ -0,0 +1,25 @@ +// @effect-diagnostics processEnvInEffect:warning +/// +import { Effect } from "effect" + +// Should trigger - process.env directly inside Effect.gen +export const envInGen = Effect.gen(function*() { + return process.env.SECRET +}) + +// Should trigger - bracket access inside Effect.fn +export const envInFn = Effect.fn("envInFn")(function*() { + return process.env["API_KEY"] +}) + +// Should NOT trigger - process.env at module level +const _moduleEnv = process.env.PORT + +// Should NOT trigger - process.env in regular function +const _regularEnv = () => process.env["HOST"] + +// Should NOT trigger - process.env inside nested arrow in generator +export const nestedArrowInGen = Effect.gen(function*() { + const fn = () => process.env.NODE_ENV + return fn +}) diff --git a/packages/harness-effect-v4/examples/diagnostics/processEnvInEffect_preview.ts b/packages/harness-effect-v4/examples/diagnostics/processEnvInEffect_preview.ts new file mode 100644 index 00000000..bcbaf45f --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/processEnvInEffect_preview.ts @@ -0,0 +1,8 @@ +// @effect-diagnostics *:off +// @effect-diagnostics processEnvInEffect:warning +/// +import { Effect } from "effect" + +export const preview = Effect.gen(function*() { + return process.env.PORT +}) diff --git a/packages/harness-effect-v4/examples/diagnostics/processEnv_preview.ts b/packages/harness-effect-v4/examples/diagnostics/processEnv_preview.ts new file mode 100644 index 00000000..723593e2 --- /dev/null +++ b/packages/harness-effect-v4/examples/diagnostics/processEnv_preview.ts @@ -0,0 +1,5 @@ +// @effect-diagnostics *:off +// @effect-diagnostics processEnv:warning +/// + +export const preview = process.env.PORT diff --git a/packages/harness-effect-v4/package.json b/packages/harness-effect-v4/package.json index 63ea37c8..2b122b54 100644 --- a/packages/harness-effect-v4/package.json +++ b/packages/harness-effect-v4/package.json @@ -7,5 +7,8 @@ "dependencies": { "@standard-schema/spec": "^1.1.0", "effect": "^4.0.0-beta.38" + }, + "devDependencies": { + "@types/node": "^25.0.6" } } diff --git a/packages/language-service/src/diagnostics.ts b/packages/language-service/src/diagnostics.ts index 5ead16e0..454638c1 100644 --- a/packages/language-service/src/diagnostics.ts +++ b/packages/language-service/src/diagnostics.ts @@ -45,6 +45,8 @@ import { outdatedApi } from "./diagnostics/outdatedApi.js" import { outdatedEffectCodegen } from "./diagnostics/outdatedEffectCodegen.js" import { overriddenSchemaConstructor } from "./diagnostics/overriddenSchemaConstructor.js" import { preferSchemaOverJson } from "./diagnostics/preferSchemaOverJson.js" +import { processEnv } from "./diagnostics/processEnv.js" +import { processEnvInEffect } from "./diagnostics/processEnvInEffect.js" import { redundantSchemaTagIdentifier } from "./diagnostics/redundantSchemaTagIdentifier.js" import { returnEffectInGen } from "./diagnostics/returnEffectInGen.js" import { runEffectInsideEffect } from "./diagnostics/runEffectInsideEffect.js" @@ -88,6 +90,8 @@ export const diagnostics = [ genericEffectServices, globalFetch, globalFetchInEffect, + processEnv, + processEnvInEffect, returnEffectInGen, tryCatchInEffectGen, importFromBarrel, diff --git a/packages/language-service/src/diagnostics/processEnv.ts b/packages/language-service/src/diagnostics/processEnv.ts new file mode 100644 index 00000000..74f00433 --- /dev/null +++ b/packages/language-service/src/diagnostics/processEnv.ts @@ -0,0 +1,13 @@ +import * as LSP from "../core/LSP.js" +import { makeProcessEnvApply } from "./processEnvInEffect.js" + +export const processEnv = LSP.createDiagnostic({ + name: "processEnv", + code: 64, + description: "Warns when reading process.env outside Effect generators instead of using Effect Config", + group: "effectNative", + severity: "off", + fixable: false, + supportedEffect: ["v3", "v4"], + apply: makeProcessEnvApply(false) +}) diff --git a/packages/language-service/src/diagnostics/processEnvInEffect.ts b/packages/language-service/src/diagnostics/processEnvInEffect.ts new file mode 100644 index 00000000..963086b4 --- /dev/null +++ b/packages/language-service/src/diagnostics/processEnvInEffect.ts @@ -0,0 +1,71 @@ +import type * as 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" + +const isEnvPropertyAccess = (tsApi: typeof ts, node: ts.Node): node is ts.PropertyAccessExpression => + tsApi.isPropertyAccessExpression(node) && tsApi.idText(node.name) === "env" + +const isProcessEnvMemberAccess = ( + tsApi: typeof ts, + node: ts.Node +): node is (ts.PropertyAccessExpression | ts.ElementAccessExpression) & { expression: ts.PropertyAccessExpression } => + (tsApi.isPropertyAccessExpression(node) || tsApi.isElementAccessExpression(node)) && + isEnvPropertyAccess(tsApi, node.expression) + +export const makeProcessEnvApply = (checkInEffect: boolean) => + Nano.fn(`processEnv${checkInEffect ? "InEffect" : ""}.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 processSymbol = typeChecker.resolveName("process", undefined, ts.SymbolFlags.Value, false) + if (!processSymbol) return + + 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 (!isProcessEnvMemberAccess(ts, node)) continue + + const processNode = node.expression.expression + if (!ts.isIdentifier(processNode) || ts.idText(processNode) !== "process") continue + + const symbol = typeChecker.getSymbolAtLocation(processNode) + if (!symbol) continue + if (typeCheckerUtils.resolveToGlobalSymbol(symbol) !== processSymbol) continue + + const { inEffect } = yield* typeParser.findEnclosingScopes(node) + if (inEffect !== checkInEffect) continue + + report({ + location: node, + messageText: checkInEffect + ? "This Effect code reads from `process.env`, environment configuration in Effect code is represented through `Config` from Effect." + : "This code reads from `process.env`, environment configuration is represented through `Config` from Effect.", + fixes: [] + }) + } + }) + +export const processEnvInEffect = LSP.createDiagnostic({ + name: "processEnvInEffect", + code: 65, + description: "Warns when reading process.env inside Effect generators instead of using Effect Config", + group: "effectNative", + severity: "off", + fixable: false, + supportedEffect: ["v3", "v4"], + apply: makeProcessEnvApply(true) +}) diff --git a/packages/language-service/src/metadata.json b/packages/language-service/src/metadata.json index d6f0b761..44c59e03 100644 --- a/packages/language-service/src/metadata.json +++ b/packages/language-service/src/metadata.json @@ -29,6 +29,8 @@ "instanceOfSchema": "warning", "globalFetch": "warning", "globalFetchInEffect": "warning", + "processEnv": "warning", + "processEnvInEffect": "warning", "preferSchemaOverJson": "warning", "extendsNativeError": "warning", "nodeBuiltinImport": "warning", @@ -1040,6 +1042,48 @@ ] } }, + { + "name": "processEnv", + "group": "effectNative", + "description": "Warns when reading process.env outside Effect generators instead of using Effect Config", + "defaultSeverity": "off", + "fixable": false, + "supportedEffect": [ + "v3", + "v4" + ], + "preview": { + "sourceText": "/// \n\nexport const preview = process.env.PORT\n", + "diagnostics": [ + { + "start": 55, + "end": 71, + "text": "This code reads from `process.env`, environment configuration is represented through `Config` from Effect. effect(processEnv)" + } + ] + } + }, + { + "name": "processEnvInEffect", + "group": "effectNative", + "description": "Warns when reading process.env inside Effect generators instead of using Effect Config", + "defaultSeverity": "off", + "fixable": false, + "supportedEffect": [ + "v3", + "v4" + ], + "preview": { + "sourceText": "/// \nimport { Effect } from \"effect\"\n\nexport const preview = Effect.gen(function*() {\n return process.env.PORT\n})\n", + "diagnostics": [ + { + "start": 121, + "end": 137, + "text": "This Effect code reads from `process.env`, environment configuration in Effect code is represented through `Config` from Effect. effect(processEnvInEffect)" + } + ] + } + }, { "name": "catchAllToMapError", "group": "style", diff --git a/packages/language-service/test/utils/mocks.ts b/packages/language-service/test/utils/mocks.ts index 23fdcade..2b84bdeb 100644 --- a/packages/language-service/test/utils/mocks.ts +++ b/packages/language-service/test/utils/mocks.ts @@ -9,7 +9,7 @@ export function createMockLanguageServiceHost( sourceText: string, compilerOptionsOverrides: ts.CompilerOptions = {} ): ts.LanguageServiceHost { - const realPath = (fileName: string) => path.resolve(harnessDir, fileName) + const realPath = (fileName: string) => path.isAbsolute(fileName) ? fileName : path.resolve(harnessDir, fileName) return { getCompilationSettings() { @@ -36,7 +36,12 @@ export function createMockLanguageServiceHost( if (_fileName === fileName) { return ts.ScriptSnapshot.fromString(sourceText) } - return ts.ScriptSnapshot.fromString(fs.readFileSync(realPath(_fileName)).toString()) + const resolved = realPath(_fileName) + if (fs.existsSync(resolved)) { + return ts.ScriptSnapshot.fromString(fs.readFileSync(resolved).toString()) + } + const text = ts.sys.readFile(_fileName) + return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text) }, getCurrentDirectory: () => ".", getDefaultLibFileName(options) { @@ -44,11 +49,15 @@ export function createMockLanguageServiceHost( }, fileExists: (_fileName) => { if (_fileName === fileName) return true - return fs.existsSync(realPath(_fileName)) + return fs.existsSync(realPath(_fileName)) || ts.sys.fileExists(_fileName) }, readFile: (_fileName) => { if (_fileName === fileName) return sourceText - return fs.readFileSync(realPath(_fileName)).toString() + const resolved = realPath(_fileName) + if (fs.existsSync(resolved)) { + return fs.readFileSync(resolved).toString() + } + return ts.sys.readFile(_fileName) } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ed4cbef..a5bd55b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,10 @@ importers: effect: specifier: ^3.19.14 version: 3.19.14 + devDependencies: + '@types/node': + specifier: ^25.0.6 + version: 25.3.3 packages/harness-effect-v4: dependencies: @@ -122,6 +126,10 @@ importers: effect: specifier: ^4.0.0-beta.38 version: 4.0.0-beta.38 + devDependencies: + '@types/node': + specifier: ^25.0.6 + version: 25.3.3 packages/language-service: devDependencies: @@ -1102,7 +1110,7 @@ packages: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.0.6 + '@types/node': 25.3.3 '@types/yargs': 17.0.35 chalk: 4.1.2 dev: true @@ -1561,7 +1569,7 @@ packages: resolution: {integrity: sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==} dependencies: '@types/minimatch': 6.0.0 - '@types/node': 25.0.6 + '@types/node': 25.3.3 dev: true /@types/istanbul-lib-coverage@2.0.6: @@ -4261,7 +4269,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 25.0.6 + '@types/node': 25.3.3 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 diff --git a/schema.json b/schema.json index e62767d5..3de3db81 100644 --- a/schema.json +++ b/schema.json @@ -2723,6 +2723,30 @@ "default": "suggestion", "description": "Suggests using Effect Schema for JSON operations instead of JSON.parse/JSON.stringify which may throw Default severity: suggestion." }, + "processEnv": { + "type": "string", + "enum": [ + "off", + "error", + "warning", + "message", + "suggestion" + ], + "default": "off", + "description": "Warns when reading process.env outside Effect generators instead of using Effect Config Default severity: off." + }, + "processEnvInEffect": { + "type": "string", + "enum": [ + "off", + "error", + "warning", + "message", + "suggestion" + ], + "default": "off", + "description": "Warns when reading process.env inside Effect generators instead of using Effect Config Default severity: off." + }, "redundantSchemaTagIdentifier": { "type": "string", "enum": [