Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/unsafe-effect-type-assertion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@effect/tsgo": minor
---

Add the `unsafeEffectTypeAssertion` diagnostic and quick-fix to detect assertions that unsafely narrow Effect error or requirements channels.

This also ports the matching v3/v4 examples, preview coverage, and baselines for the new rule.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ Some diagnostics are off by default or have a default severity of suggestion, bu
<tr><td><code>preferSchemaOverJson</code></td><td>💡</td><td></td><td>Suggests using Effect Schema for JSON operations instead of JSON.parse/JSON.stringify</td><td>✓</td><td>✓</td></tr>
<tr><td><code>processEnv</code></td><td>➖</td><td></td><td>Warns when reading process.env outside Effect generators instead of using Effect Config</td><td>✓</td><td>✓</td></tr>
<tr><td><code>processEnvInEffect</code></td><td>➖</td><td></td><td>Warns when reading process.env inside Effect generators instead of using Effect Config</td><td>✓</td><td>✓</td></tr>
<tr><td><code>unsafeEffectTypeAssertion</code></td><td>➖</td><td>🔧</td><td>Detects unsafe type assertions that narrow Effect error or requirements channels</td><td>✓</td><td>✓</td></tr>
<tr><td colspan="6"><strong>Style</strong> <em>Cleanup, consistency, and idiomatic Effect code.</em></td></tr>
<tr><td><code>catchAllToMapError</code></td><td>💡</td><td>🔧</td><td>Suggests using Effect.mapError instead of Effect.catch + Effect.fail</td><td>✓</td><td>✓</td></tr>
<tr><td><code>deterministicKeys</code></td><td>➖</td><td>🔧</td><td>Enforces deterministic naming for service/tag/error identifiers based on class names</td><td>✓</td><td>✓</td></tr>
Expand Down
27 changes: 26 additions & 1 deletion _packages/tsgo/src/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
"nodeBuiltinImport": "warning",
"preferSchemaOverJson": "warning",
"processEnv": "warning",
"processEnvInEffect": "warning"
"processEnvInEffect": "warning",
"unsafeEffectTypeAssertion": "warning"
}
}
],
Expand Down Expand Up @@ -1313,6 +1314,30 @@
]
}
},
{
"name": "unsafeEffectTypeAssertion",
"group": "effectNative",
"description": "Detects unsafe type assertions that narrow Effect error or requirements channels",
"defaultSeverity": "off",
"fixable": true,
"supportedEffect": [
"v3",
"v4"
],
"codes": [
377075
],
"preview": {
"sourceText": "import { Effect } from \"effect\"\n\ndeclare const program: Effect.Effect\u003cstring, \"boom\", \"service\"\u003e\n\nexport const preview = program as Effect.Effect\u003cstring, never, never\u003e\n",
"diagnostics": [
{
"start": 121,
"end": 167,
"text": "This type assertion unsafely narrows the Effect error or requirements channels. effect(unsafeEffectTypeAssertion)"
}
]
}
},
{
"name": "catchAllToMapError",
"group": "style",
Expand Down
8 changes: 8 additions & 0 deletions internal/diagnostics/effectDiagnosticMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@
"category": "Suggestion",
"code": 377074
},
"This type assertion unsafely narrows the Effect error or requirements channels. effect(unsafeEffectTypeAssertion)": {
"category": "Warning",
"code": 377075
},
"The {0} channel is narrowed from `{1}` to `{2}`. effect(unsafeEffectTypeAssertion)": {
"category": "Warning",
"code": 377087
},
"This code uses `JSON.parse` or `JSON.stringify`. Effect Schema provides Effect-aware APIs for JSON parsing and stringifying. effect(preferSchemaOverJson)": {
"category": "Suggestion",
"code": 377026
Expand Down
1 change: 1 addition & 0 deletions internal/fixables/fixables.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var All = []fixable.Fixable{
OverriddenSchemaConstructorFix,
InstanceOfSchemaFix,
LayerMergeAllWithDependenciesFix,
UnsafeEffectTypeAssertionFix,
MissingEffectErrorCatchFix,
MultipleEffectProvideFix,
SchemaStructWithTagFix,
Expand Down
39 changes: 39 additions & 0 deletions internal/fixables/unsafe_effect_type_assertion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package fixables

import (
"github.com/effect-ts/tsgo/internal/fixable"
"github.com/effect-ts/tsgo/internal/rewriter"
"github.com/effect-ts/tsgo/internal/rules"
tsdiag "github.com/microsoft/typescript-go/shim/diagnostics"
"github.com/microsoft/typescript-go/shim/ls"
)

var UnsafeEffectTypeAssertionFix = fixable.Fixable{
Name: "unsafeEffectTypeAssertion",
Description: "Remove the unsafe Effect assertion",
ErrorCodes: []int32{tsdiag.This_type_assertion_unsafely_narrows_the_Effect_error_or_requirements_channels_effect_unsafeEffectTypeAssertion.Code()},
FixIDs: []string{"unsafeEffectTypeAssertion_fix"},
Run: runUnsafeEffectTypeAssertionFix,
}

func runUnsafeEffectTypeAssertionFix(ctx *fixable.Context) []ls.CodeAction {
matches := rules.AnalyzeUnsafeEffectTypeAssertion(ctx.TypeParser, ctx.Checker, ctx.SourceFile)
for _, match := range matches {
diagRange := match.Location
if !diagRange.Intersects(ctx.Span) && !ctx.Span.ContainedBy(diagRange) {
continue
}

if action := ctx.NewFixAction(fixable.FixAction{
Description: "Remove the unsafe Effect assertion",
Run: func(tracker *rewriter.Tracker) {
tracker.ReplaceNode(ctx.SourceFile, match.AssertionNode, match.ExpressionNode, nil)
},
}); action != nil {
return []ls.CodeAction{*action}
}
return nil
}

return nil
}
1 change: 1 addition & 0 deletions internal/rules/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ var All = []rule.Rule{
AsyncFunction,
ScopeInLayerEffect,
StrictEffectProvide,
UnsafeEffectTypeAssertion,
MultipleEffectProvide,
MissingLayerContext,
LayerMergeAllWithDependencies,
Expand Down
158 changes: 158 additions & 0 deletions internal/rules/unsafe_effect_type_assertion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package rules

import (
"github.com/effect-ts/tsgo/etscore"
"github.com/effect-ts/tsgo/internal/rule"
"github.com/effect-ts/tsgo/internal/typeparser"
"github.com/microsoft/typescript-go/shim/ast"
"github.com/microsoft/typescript-go/shim/checker"
"github.com/microsoft/typescript-go/shim/core"
tsdiag "github.com/microsoft/typescript-go/shim/diagnostics"
"github.com/microsoft/typescript-go/shim/scanner"
)

var UnsafeEffectTypeAssertion = rule.Rule{
Name: "unsafeEffectTypeAssertion",
Group: "effectNative",
Description: "Detects unsafe type assertions that narrow Effect error or requirements channels",
DefaultSeverity: etscore.SeverityOff,
SupportedEffect: []string{"v3", "v4"},
Codes: []int32{tsdiag.This_type_assertion_unsafely_narrows_the_Effect_error_or_requirements_channels_effect_unsafeEffectTypeAssertion.Code()},
Run: func(ctx *rule.Context) []*ast.Diagnostic {
matches := AnalyzeUnsafeEffectTypeAssertion(ctx.TypeParser, ctx.Checker, ctx.SourceFile)
diags := make([]*ast.Diagnostic, len(matches))
for i, match := range matches {
diags[i] = ctx.NewDiagnostic(
match.SourceFile,
match.Location,
tsdiag.This_type_assertion_unsafely_narrows_the_Effect_error_or_requirements_channels_effect_unsafeEffectTypeAssertion,
unsafeEffectTypeAssertionRelatedInformation(ctx, match),
)
}
return diags
},
}

type UnsafeEffectTypeAssertionChannel struct {
Name string
Original string
Asserted string
}

type UnsafeEffectTypeAssertionMatch struct {
SourceFile *ast.SourceFile
Location core.TextRange
AssertionNode *ast.Node
ExpressionNode *ast.Node
LocationNode *ast.Node
Channels []UnsafeEffectTypeAssertionChannel
}

func AnalyzeUnsafeEffectTypeAssertion(tp *typeparser.TypeParser, c *checker.Checker, sf *ast.SourceFile) []UnsafeEffectTypeAssertionMatch {
if tp == nil || c == nil || sf == nil {
return nil
}

var matches []UnsafeEffectTypeAssertionMatch
nodesToVisit := make([]*ast.Node, 0)
pushChild := func(child *ast.Node) bool {
nodesToVisit = append(nodesToVisit, child)
return false
}
sf.AsNode().ForEachChild(pushChild)

for len(nodesToVisit) > 0 {
node := nodesToVisit[len(nodesToVisit)-1]
nodesToVisit = nodesToVisit[:len(nodesToVisit)-1]
node.ForEachChild(pushChild)

if node.Kind != ast.KindAsExpression && node.Kind != ast.KindTypeAssertionExpression {
continue
}

expr := node.Expression()
if expr == nil {
continue
}

originalType := tp.GetTypeAtLocation(expr)
assertedType := tp.GetTypeAtLocation(node)
if originalType == nil || assertedType == nil {
continue
}

originalEffect := tp.EffectType(originalType, expr)
if originalEffect == nil {
continue
}

assertedEffect := tp.EffectType(assertedType, node)
if assertedEffect == nil {
continue
}

channels := make([]UnsafeEffectTypeAssertionChannel, 0, 2)
if originalEffect.E != nil && assertedEffect.E != nil && !isAnyType(originalEffect.E) && !checker.Checker_isTypeAssignableTo(c, originalEffect.E, assertedEffect.E) {
channels = append(channels, UnsafeEffectTypeAssertionChannel{
Name: "error",
Original: c.TypeToString(originalEffect.E),
Asserted: c.TypeToString(assertedEffect.E),
})
}
if originalEffect.R != nil && assertedEffect.R != nil && !isAnyType(originalEffect.R) && !checker.Checker_isTypeAssignableTo(c, originalEffect.R, assertedEffect.R) {
channels = append(channels, UnsafeEffectTypeAssertionChannel{
Name: "requirements",
Original: c.TypeToString(originalEffect.R),
Asserted: c.TypeToString(assertedEffect.R),
})
}
if len(channels) == 0 {
continue
}

locationNode := node
if typeNode := node.Type(); typeNode != nil {
locationNode = typeNode
}

matches = append(matches, UnsafeEffectTypeAssertionMatch{
SourceFile: sf,
Location: scanner.GetErrorRangeForNode(sf, node),
AssertionNode: node,
ExpressionNode: expr,
LocationNode: locationNode,
Channels: channels,
})
}

return matches
}

func isAnyType(t *checker.Type) bool {
return t != nil && t.Flags()&checker.TypeFlagsAny != 0
}

func unsafeEffectTypeAssertionRelatedInformation(ctx *rule.Context, match UnsafeEffectTypeAssertionMatch) []*ast.Diagnostic {
if ctx == nil || len(match.Channels) == 0 {
return nil
}

locationNode := match.LocationNode
if locationNode == nil {
locationNode = match.AssertionNode
}

related := make([]*ast.Diagnostic, 0, len(match.Channels))
for _, channel := range match.Channels {
related = append(related, ctx.NewDiagnostic(
match.SourceFile,
scanner.GetErrorRangeForNode(match.SourceFile, locationNode),
tsdiag.The_0_channel_is_narrowed_from_1_to_2_effect_unsafeEffectTypeAssertion,
nil,
channel.Name,
channel.Original,
channel.Asserted,
))
}
return related
}
2 changes: 2 additions & 0 deletions shim/diagnostics/shim.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
=== Metadata ===
Effect version: 3.19.19

/.src/unsafeEffectTypeAssertion.ts(10,28): warning TS377075: This type assertion unsafely narrows the Effect error or requirements channels. effect(unsafeEffectTypeAssertion)
/.src/unsafeEffectTypeAssertion.ts(14,36): warning TS377075: This type assertion unsafely narrows the Effect error or requirements channels. effect(unsafeEffectTypeAssertion)
/.src/unsafeEffectTypeAssertion.ts(15,29): warning TS377075: This type assertion unsafely narrows the Effect error or requirements channels. effect(unsafeEffectTypeAssertion)


==== /.src/unsafeEffectTypeAssertion.ts (3 errors) ====
// @effect-diagnostics unsafeEffectTypeAssertion:warning
import { Effect } from "effect"

declare const program: Effect.Effect<string, "boom", "service">
declare const anyError: Effect.Effect<string, any, "service">
declare const anyRequirements: Effect.Effect<string, "boom", any>
declare const noRequirements: Effect.Effect<string, "boom", never>
declare const noError: Effect.Effect<string, never, "service">

export const narrowsBoth = program as Effect.Effect<string, never, never>
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
!!! warning TS377075: This type assertion unsafely narrows the Effect error or requirements channels. effect(unsafeEffectTypeAssertion)
!!! related TS377087 /.src/unsafeEffectTypeAssertion.ts(10,39): The error channel is narrowed from `"boom"` to `never`. effect(unsafeEffectTypeAssertion)
!!! related TS377087 /.src/unsafeEffectTypeAssertion.ts(10,39): The requirements channel is narrowed from `"service"` to `never`. effect(unsafeEffectTypeAssertion)
export const skipsAnyError = anyError as Effect.Effect<string, never, "service">
export const skipsAnyRequirements = anyRequirements as Effect.Effect<string, "boom", never>
export const safeWiden = program as Effect.Effect<string, "boom" | "other", "service">
export const narrowsRequirements = noError as Effect.Effect<string, never, never>
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
!!! warning TS377075: This type assertion unsafely narrows the Effect error or requirements channels. effect(unsafeEffectTypeAssertion)
!!! related TS377087 /.src/unsafeEffectTypeAssertion.ts(14,47): The requirements channel is narrowed from `"service"` to `never`. effect(unsafeEffectTypeAssertion)
export const narrowsError = noRequirements as Effect.Effect<string, never, never>
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
!!! warning TS377075: This type assertion unsafely narrows the Effect error or requirements channels. effect(unsafeEffectTypeAssertion)
!!! related TS377087 /.src/unsafeEffectTypeAssertion.ts(15,47): The error channel is narrowed from `"boom"` to `never`. effect(unsafeEffectTypeAssertion)
export const notEffect = 1 as number

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/.src/unsafeEffectTypeAssertion.ts -> unsafeEffectTypeAssertion.flows.unsafeEffectTypeAssertion.mermaid
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
flowchart TB
0[/"type: Effect#lt;string, never, never#gt;<br/>node: program as Effect.Effect#lt;string, never, never#gt;"/]
1[/"type: Effect#lt;string, never, #quot;service#quot;#gt;<br/>node: anyError as Effect.Effect#lt;string, never, #quot;service#quot;#gt;"/]
2[/"type: Effect#lt;string, #quot;boom#quot;, never#gt;<br/>node: anyRequirements as Effect.Effect#lt;string, #quot;boom#quot;, never#gt;"/]
3[/"type: Effect#lt;string, #quot;boom#quot; #124; #quot;other#quot;, #quot;service#quot;#gt;<br/>node: program as Effect.Effect#lt;string, #quot;boom#quot; #124; #quot;other#quot;, #quot;service#quot;#gt;"/]
4[/"type: Effect#lt;string, never, never#gt;<br/>node: noError as Effect.Effect#lt;string, never, never#gt;"/]
5[/"type: Effect#lt;string, never, never#gt;<br/>node: noRequirements as Effect.Effect#lt;string, never, never#gt;"/]
6[/"type: number<br/>node: 1 as number"/]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
==== /.src/unsafeEffectTypeAssertion.ts (0 layer exports) ====
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
==== /.src/unsafeEffectTypeAssertion.ts (0 flows) ====
Loading
Loading