feat(effect): prefer Match for literal branches

This commit is contained in:
Kyle Mistele
2026-08-31 16:59:59 -07:00
committed by Dillon Mulroy
parent 01b8f46979
commit e6676e8d0b
8 changed files with 163 additions and 2 deletions
+25 -1
View File
@@ -95,7 +95,11 @@ export default defineConfig({
}
],
rules: {
"anti-slop-effect/no-service-constructor-imports": "error"
"anti-slop-effect/no-manual-effect-error-tag": "error",
"anti-slop-effect/no-manual-tag-comparison": "error",
"anti-slop-effect/no-manual-tagged-construction": "error",
"anti-slop-effect/no-service-constructor-imports": "error",
"anti-slop-effect/prefer-effect-match": "error"
}
});
```
@@ -125,7 +129,11 @@ export default defineConfig({
### Effect rules
- `no-manual-effect-error-tag` — rejects manual `_tag` comparisons and switches inside broad `Effect.catch`, `Effect.catchAll`, and `Effect.catchIf` handlers in favor of tagged error handlers.
- `no-manual-tag-comparison` — rejects direct `_tag` comparisons and `_tag` switches in favor of `Match`, `Predicate.isTagged`, or tagged-enum matching.
- `no-manual-tagged-construction` — rejects literal `_tag` object construction in favor of Schema, tagged class/error, or `Data.taggedEnum` constructors. `Match.when` and `Match.not` patterns remain allowed.
- `no-service-constructor-imports` — rejects named `make<CapabilityName>` imports from relative project modules outside `*.test.*` and `*.spec.*` files. Runtime callers should import the owning Layer and yield the contextual service instead. Package and path-alias imports, default imports, and static constructors such as `WorkspaceName.make` are outside the rule.
- `prefer-effect-match` — rejects chained literal ternaries over the same value in favor of Effect's `Match` API.
### Analysis boundaries
@@ -284,6 +292,22 @@ import { makeIssueService } from "./issue-service.ts";
Import the owning Layer and yield `IssueService` instead. Focused `*.test.*` and `*.spec.*` files may import the constructor directly.
### Effect: tagged values and matching
```ts
if (result._tag === "Ready") useReady(result);
const result = { _tag: "Ready", value };
Effect.catch((error) =>
error._tag === "NotFound" ? recover : Effect.fail(error)
);
const label = kind === "a" ? "A" : kind === "b" ? "B" : "Other";
```
Use `Predicate.isTagged` for reusable predicates, `Match` or tagged-enum matching for branching, tagged constructors for values, and `Effect.catchTag`/`Effect.catchTags` for tagged errors.
### `no-unknown-parameters`
```ts
+1 -1
View File
@@ -11,7 +11,7 @@
"scripts": {
"check": "pnpm lint && pnpm test && pnpm typecheck && pnpm check:skill-assets",
"lint": "oxlint src",
"test": "tsx src/rules/require-readable-spacing.test.ts && tsx src/rules/require-readable-spacing-cli.test.ts && tsx src/rules/no-array-filter-map.test.ts && tsx src/rules/no-reduce-accumulator-copy.test.ts && tsx src/rules/no-chained-type-assertions.test.ts && tsx src/rules/no-conditional-empty-object-spread.test.ts && tsx src/rules/no-unsafe-dictionary-type.test.ts && tsx src/rules/no-known-value-widening.test.ts && tsx src/rules/no-object-parameters.test.ts && tsx src/rules/no-unknown-parameters.test.ts && tsx src/rules/no-runtime-typeof.test.ts && tsx src/rules/no-shape-in-symbol-names.test.ts && tsx src/rules/no-unknown-returns.test.ts && tsx src/rules/no-unknown-type-aliases.test.ts && tsx src/rules/no-widen-then-assert.test.ts && tsx src/rules/no-reflect-get.test.ts && tsx src/rules/no-reflect-apply.test.ts && tsx src/rules/no-module-mocking.test.ts && tsx src/rules/require-safety-comment-for-type-assertion.test.ts && tsx src/effect/rules/no-service-constructor-imports.test.ts && tsx src/effect/rules/no-manual-tag-comparison.test.ts && tsx src/effect/rules/no-manual-effect-error-tag.test.ts && tsx src/effect/rules/no-manual-tagged-construction.test.ts",
"test": "tsx src/rules/require-readable-spacing.test.ts && tsx src/rules/require-readable-spacing-cli.test.ts && tsx src/rules/no-array-filter-map.test.ts && tsx src/rules/no-reduce-accumulator-copy.test.ts && tsx src/rules/no-chained-type-assertions.test.ts && tsx src/rules/no-conditional-empty-object-spread.test.ts && tsx src/rules/no-unsafe-dictionary-type.test.ts && tsx src/rules/no-known-value-widening.test.ts && tsx src/rules/no-object-parameters.test.ts && tsx src/rules/no-unknown-parameters.test.ts && tsx src/rules/no-runtime-typeof.test.ts && tsx src/rules/no-shape-in-symbol-names.test.ts && tsx src/rules/no-unknown-returns.test.ts && tsx src/rules/no-unknown-type-aliases.test.ts && tsx src/rules/no-widen-then-assert.test.ts && tsx src/rules/no-reflect-get.test.ts && tsx src/rules/no-reflect-apply.test.ts && tsx src/rules/no-module-mocking.test.ts && tsx src/rules/require-safety-comment-for-type-assertion.test.ts && tsx src/effect/rules/no-service-constructor-imports.test.ts && tsx src/effect/rules/no-manual-tag-comparison.test.ts && tsx src/effect/rules/no-manual-effect-error-tag.test.ts && tsx src/effect/rules/no-manual-tagged-construction.test.ts && tsx src/effect/rules/prefer-effect-match.test.ts",
"typecheck": "tsc --noEmit",
"sync:skill-assets": "node scripts/sync-skill-assets.mjs",
"check:skill-assets": "node scripts/sync-skill-assets.mjs --check"
+4
View File
@@ -104,7 +104,11 @@ Complete when the operation and target path are established and pre-existing wor
},
],
rules: {
"anti-slop-effect/no-manual-effect-error-tag": "error",
"anti-slop-effect/no-manual-tag-comparison": "error",
"anti-slop-effect/no-manual-tagged-construction": "error",
"anti-slop-effect/no-service-constructor-imports": "error",
"anti-slop-effect/prefer-effect-match": "error",
},
```
@@ -4,6 +4,7 @@ import { noManualEffectErrorTagRule } from "./rules/no-manual-effect-error-tag.t
import { noManualTagComparisonRule } from "./rules/no-manual-tag-comparison.ts";
import { noManualTaggedConstructionRule } from "./rules/no-manual-tagged-construction.ts";
import { noServiceConstructorImportsRule } from "./rules/no-service-constructor-imports.ts";
import { preferEffectMatchRule } from "./rules/prefer-effect-match.ts";
/** Opt-in Oxlint rules for Effect service and Layer architecture. */
const antiSlopEffectPlugin = eslintCompatPlugin({
@@ -13,6 +14,7 @@ const antiSlopEffectPlugin = eslintCompatPlugin({
"no-manual-tag-comparison": noManualTagComparisonRule,
"no-manual-tagged-construction": noManualTaggedConstructionRule,
"no-service-constructor-imports": noServiceConstructorImportsRule,
"prefer-effect-match": preferEffectMatchRule,
},
});
@@ -0,0 +1,54 @@
import { defineRule, type ESTree } from "@oxlint/plugins";
const equalityOperators = new Set(["==", "===", "!=", "!=="]);
export const preferEffectMatchRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Use Match from Effect for chained literal ternaries over the same value.",
},
messages: {
preferMatch:
"Use Match from Effect instead of a chained literal ternary.",
},
},
createOnce(context) {
const isLiteral = (node: ESTree.Node): boolean =>
node.type === "Literal" ||
(node.type === "TemplateLiteral" && node.expressions.length === 0);
const comparedValue = (node: ESTree.Expression): string | undefined => {
if (
node.type !== "BinaryExpression" ||
!equalityOperators.has(node.operator)
) {
return undefined;
}
if (isLiteral(node.left)) return context.sourceCode.getText(node.right);
if (isLiteral(node.right)) return context.sourceCode.getText(node.left);
return undefined;
};
return {
ConditionalExpression(node) {
if (node.parent?.type === "ConditionalExpression") return;
const value = comparedValue(node.test);
if (value === undefined) return;
let alternate = node.alternate;
let literalChecks = 1;
while (alternate.type === "ConditionalExpression") {
if (comparedValue(alternate.test) !== value) return;
literalChecks += 1;
alternate = alternate.alternate;
}
if (literalChecks > 1) {
context.report({ node, messageId: "preferMatch" });
}
},
};
},
});
+2
View File
@@ -4,6 +4,7 @@ import { noManualEffectErrorTagRule } from "./rules/no-manual-effect-error-tag.t
import { noManualTagComparisonRule } from "./rules/no-manual-tag-comparison.ts";
import { noManualTaggedConstructionRule } from "./rules/no-manual-tagged-construction.ts";
import { noServiceConstructorImportsRule } from "./rules/no-service-constructor-imports.ts";
import { preferEffectMatchRule } from "./rules/prefer-effect-match.ts";
/** Opt-in Oxlint rules for Effect service and Layer architecture. */
const antiSlopEffectPlugin = eslintCompatPlugin({
@@ -13,6 +14,7 @@ const antiSlopEffectPlugin = eslintCompatPlugin({
"no-manual-tag-comparison": noManualTagComparisonRule,
"no-manual-tagged-construction": noManualTaggedConstructionRule,
"no-service-constructor-imports": noServiceConstructorImportsRule,
"prefer-effect-match": preferEffectMatchRule,
},
});
@@ -0,0 +1,21 @@
import { RuleTester } from "oxlint/plugins-dev";
import { preferEffectMatchRule } from "./prefer-effect-match.ts";
new RuleTester().run("prefer-effect-match", preferEffectMatchRule, {
valid: [
'kind === "a" ? first : fallback;',
'kind === "a" ? first : other === "b" ? second : fallback;',
"condition ? first : otherCondition ? second : fallback;",
],
invalid: [
{
code: 'kind === "a" ? first : kind === "b" ? second : fallback;',
errors: [{ messageId: "preferMatch" }],
},
{
code: '`a` !== kind ? first : `b` === kind ? second : fallback;',
errors: [{ messageId: "preferMatch" }],
},
],
});
+54
View File
@@ -0,0 +1,54 @@
import { defineRule, type ESTree } from "@oxlint/plugins";
const equalityOperators = new Set(["==", "===", "!=", "!=="]);
export const preferEffectMatchRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Use Match from Effect for chained literal ternaries over the same value.",
},
messages: {
preferMatch:
"Use Match from Effect instead of a chained literal ternary.",
},
},
createOnce(context) {
const isLiteral = (node: ESTree.Node): boolean =>
node.type === "Literal" ||
(node.type === "TemplateLiteral" && node.expressions.length === 0);
const comparedValue = (node: ESTree.Expression): string | undefined => {
if (
node.type !== "BinaryExpression" ||
!equalityOperators.has(node.operator)
) {
return undefined;
}
if (isLiteral(node.left)) return context.sourceCode.getText(node.right);
if (isLiteral(node.right)) return context.sourceCode.getText(node.left);
return undefined;
};
return {
ConditionalExpression(node) {
if (node.parent?.type === "ConditionalExpression") return;
const value = comparedValue(node.test);
if (value === undefined) return;
let alternate = node.alternate;
let literalChecks = 1;
while (alternate.type === "ConditionalExpression") {
if (comparedValue(alternate.test) !== value) return;
literalChecks += 1;
alternate = alternate.alternate;
}
if (literalChecks > 1) {
context.report({ node, messageId: "preferMatch" });
}
},
};
},
});