diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index f7326312463a0..ede9f4ed948f9 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -19,6 +19,7 @@ import noTestInLoop from "./rules/no-test-in-loop.ts"; import noRpcPermissionBypass from "./rules/no-rpc-permission-bypass.ts"; import noUnscopedHas from "./rules/no-unscoped-has.ts"; import preferCatchTags from "./rules/prefer-catch-tags.ts"; +import preferEffectFn from "./rules/prefer-effect-fn.ts"; import requireCenteredScrollGutter from "./rules/require-centered-scroll-gutter.ts"; import requireSuppressionReason from "./rules/require-suppression-reason.ts"; @@ -46,6 +47,7 @@ export default definePlugin({ "no-rpc-permission-bypass": noRpcPermissionBypass, "no-unscoped-has": noUnscopedHas, "prefer-catch-tags": preferCatchTags, + "prefer-effect-fn": preferEffectFn, "require-centered-scroll-gutter": requireCenteredScrollGutter, "require-suppression-reason": requireSuppressionReason, }, diff --git a/oxlint-plugin-t3code/rules/prefer-effect-fn.test.ts b/oxlint-plugin-t3code/rules/prefer-effect-fn.test.ts new file mode 100644 index 0000000000000..ba48fc6ad5273 --- /dev/null +++ b/oxlint-plugin-t3code/rules/prefer-effect-fn.test.ts @@ -0,0 +1,202 @@ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/prefer-effect-fn"); + +describe("t3code/prefer-effect-fn", () => { + rule.valid( + "allows inline programs and layer construction", + ` + import * as Effect from "effect/Effect"; + import * as Layer from "effect/Layer"; + export const program = Effect.gen(function* () { yield* Effect.void; }); + export const layer = Layer.effectDiscard(Effect.gen(function* () { yield* Effect.void; })); + `, + ); + + rule.valid( + "allows named functions and deliberate untraced functions", + ` + import * as Effect from "effect/Effect"; + export const read = Effect.fn("Files.read")(function* () { yield* Effect.void; }); + export const chunk = Effect.fnUntraced(function* () { yield* Effect.void; }); + `, + ); + + rule.valid( + "allows inline combinator callbacks", + ` + import * as Effect from "effect/Effect"; + export const program = Effect.void.pipe(Effect.flatMap(() => Effect.gen(function* () { + yield* Effect.void; + }))); + `, + ); + + rule.valid( + "allows inline option and error callbacks", + ` + import * as Effect from "effect/Effect"; + import * as Option from "effect/Option"; + export const program = Option.match(Option.none(), { + onNone: () => Effect.gen(function* () { yield* Effect.void; }), + onSome: () => Effect.void, + }); + export const recovered = Effect.fail({ _tag: "Failed" }).pipe(Effect.catchTags({ + Failed: () => Effect.gen(function* () { yield* Effect.void; }), + })); + `, + ); + + rule.valid( + "allows reference default-value callbacks", + ` + import * as Context from "effect/Context"; + import * as Effect from "effect/Effect"; + export const Default = Context.Reference("Default", { + defaultValue: () => Effect.gen(function* () { yield* Effect.void; }), + }); + `, + ); + + for (const name of ["onSome", "onNone", "onSuccess", "onFailure", "defaultValue"]) { + rule.invalid( + `reports service members named ${name}`, + ` + import * as Effect from "effect/Effect"; + export const service = Service.of({ + ${name}: () => Effect.gen(function* () { yield* Effect.void; }), + }); + `, + ); + } + + rule.valid( + "does not mistake other namespaces or shadowed locals for Effect", + ` + import * as Effect from "effect/Effect"; + const Other = { gen: (body: () => unknown) => body() }; + export const other = () => Other.gen(function* () {}); + export function scoped(Effect: typeof Other) { + return Effect.gen(function* () {}); + } + export const effect = Effect.void; + `, + ); + + rule.valid( + "leaves functions with synchronous setup for manual review", + ` + import * as Effect from "effect/Effect"; + export const read = () => { + const state = new Map(); + return Effect.gen(function* () { yield* Effect.void; return state; }); + }; + `, + ); + + rule.invalid( + "reports a named arrow wrapper", + ` + import * as Effect from "effect/Effect"; + export const read = (path: string) => Effect.gen(function* () { + yield* Effect.void; + return path; + }); + `, + (output) => { + assert.match(output, /Effect\.fn/); + assert.match(output, /documented hot paths/); + }, + ); + + rule.invalid( + "reports a block-bodied wrapper", + ` + import * as Effect from "effect/Effect"; + export const read = () => { return Effect.gen(function* () { yield* Effect.void; }); }; + `, + ); + + rule.invalid( + "reports a declaration with pipe transforms", + ` + import * as Effect from "effect/Effect"; + export function read() { + return Effect.gen(function* () { yield* Effect.void; }).pipe(Effect.asVoid); + } + `, + ); + + rule.invalid( + "reports a function expression", + ` + import * as Effect from "effect/Effect"; + export const read = function() { return Effect.gen(function* () { yield* Effect.void; }); }; + `, + ); + + rule.invalid( + "reports service members", + ` + import * as Effect from "effect/Effect"; + export const service = { + read: () => Effect.gen(function* () { yield* Effect.void; }), + }; + `, + ); + + rule.invalid( + "reports class fields", + ` + import * as Effect from "effect/Effect"; + export class Service { + read = () => Effect.gen(function* () { yield* Effect.void; }); + } + `, + ); + + rule.invalid( + "recognizes namespace aliases", + ` + import * as Eff from "effect/Effect"; + export const read = () => Eff.gen(function* () { yield* Eff.void; }); + `, + ); + + rule.invalid( + "recognizes bare-package Effect imports", + ` + import { Effect } from "effect"; + export const read = () => Effect.gen(function* () { yield* Effect.void; }); + `, + ); + + rule.invalid( + "recognizes a direct gen import", + ` + import { gen, void as noop } from "effect/Effect"; + export const read = () => gen(function* () { yield* noop; }); + `, + ); + rule.invalid( + "reports class methods", + ` + import * as Effect from "effect/Effect"; + export class Service { + read() { return Effect.gen(function* () { yield* Effect.void; }); } + } + `, + ); + + rule.valid( + "allows getters that expose an effect value", + ` + import * as Effect from "effect/Effect"; + export class Service { + get read() { return Effect.gen(function* () { yield* Effect.void; }); } + } + `, + ); +}); diff --git a/oxlint-plugin-t3code/rules/prefer-effect-fn.ts b/oxlint-plugin-t3code/rules/prefer-effect-fn.ts new file mode 100644 index 0000000000000..f72cbb7c2dffc --- /dev/null +++ b/oxlint-plugin-t3code/rules/prefer-effect-fn.ts @@ -0,0 +1,116 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; + +export default defineRule({ + meta: { + type: "suggestion", + docs: { + description: "Use Effect.fn for reusable generator-based Effect functions.", + }, + }, + create(context) { + const isEffectImport = (identifier: ESTree.IdentifierReference, member: string) => { + let scope = context.sourceCode.getScope(identifier); + while (true) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) { + return variable.defs.some((def) => { + if (def.type !== "ImportBinding" || def.parent?.type !== "ImportDeclaration") { + return false; + } + const source = def.parent.source.value; + if (source === "effect/Effect") { + if (member === "") { + return ( + def.node.type === "ImportSpecifier" && + (def.node.imported.type === "Identifier" + ? def.node.imported.name + : def.node.imported.value) === "gen" + ); + } + return def.node.type === "ImportNamespaceSpecifier"; + } + return ( + source === "effect" && + def.node.type === "ImportSpecifier" && + (def.node.imported.type === "Identifier" + ? def.node.imported.name + : def.node.imported.value) === "Effect" + ); + }); + } + if (scope.upper === null) return false; + scope = scope.upper; + } + }; + + const isGeneratorCall = (expression: ESTree.Expression): boolean => { + if (expression.type !== "CallExpression") return false; + const callee = expression.callee; + if (callee.type === "Identifier") return isEffectImport(callee, ""); + if (callee.type !== "MemberExpression" || callee.computed) return false; + if (callee.property.type !== "Identifier") return false; + if (callee.property.name === "pipe") return isGeneratorCall(callee.object); + return ( + callee.property.name === "gen" && + callee.object.type === "Identifier" && + isEffectImport(callee.object, "gen") + ); + }; + + const check = (node: ESTree.ArrowFunctionExpression | ESTree.Function) => { + if (node.async || node.generator) return; + const parent = node.parent; + if (node.type !== "FunctionDeclaration") { + if (parent.type === "MethodDefinition") { + if (parent.kind !== "method") return; + } else if (parent.type === "Property") { + if (parent.kind !== "init") return; + const name = + parent.key.type === "Identifier" + ? parent.key.name + : parent.key.type === "Literal" + ? parent.key.value + : undefined; + const call = parent.parent.parent; + if (call?.type === "CallExpression") { + let callee = call.callee; + while (callee.type === "CallExpression") callee = callee.callee; + if (callee.type === "MemberExpression" && callee.property.type === "Identifier") { + if ( + ["catchTags", "match", "matchEffect", "matchCauseEffect"].includes( + callee.property.name, + ) || + (name === "defaultValue" && callee.property.name === "Reference") + ) { + return; + } + } + } + } else if (parent.type !== "VariableDeclarator" && parent.type !== "PropertyDefinition") { + return; + } + } + const body = node.body; + if (body === null) return; + let expression: ESTree.Expression | null; + if (body.type === "BlockStatement") { + if (body.body.length !== 1 || body.body[0]?.type !== "ReturnStatement") return; + expression = body.body[0].argument; + } else { + expression = body; + } + if (expression === null || !isGeneratorCall(expression)) return; + context.report({ + node, + message: + 'Use Effect.fn("Service.operation") for reusable Effect functions. Reserve Effect.fnUntraced for documented hot paths or an existing span; keep Effect.gen for inline programs.', + }); + }; + + return { + ArrowFunctionExpression: check, + FunctionExpression: check, + FunctionDeclaration: check, + }; + }, +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index 17efedde931b0..07c4224cf8d25 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -22,7 +22,9 @@ { "name": "@effect/language-service", "namespaceImportPackages": ["@effect/platform-node", "effect"], + "effectFn": ["span", "inferred-span", "suggested-span"], "diagnosticSeverity": { + "effectFnOpportunity": "suggestion", "anyUnknownInErrorContext": "error", "unsafeEffectTypeAssertion": "error", "instanceOfSchema": "error", @@ -59,7 +61,23 @@ "overrides": [ { "include": ["**/*.test.ts", "**/*.test.tsx"], - "options": { "diagnosticSeverity": { "preferSchemaOverJson": "off" } } + "options": { + "diagnosticSeverity": { + "preferSchemaOverJson": "off", + "effectFnOpportunity": "off" + } + } + }, + { + "include": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.testkit.ts", + "**/*.testkit.tsx", + "**/{test,tests,testkit,testing,fixtures,integration}/**", + "packages/provider-testing/**" + ], + "options": { "diagnosticSeverity": { "effectFnOpportunity": "off" } } } ] } diff --git a/vite.config.ts b/vite.config.ts index e2b3416d88c89..8866c952a5eaa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -172,10 +172,19 @@ export default defineConfig({ "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", "t3code/prefer-catch-tags": "error", + "t3code/prefer-effect-fn": "error", "t3code/require-centered-scroll-gutter": "error", "t3code/require-suppression-reason": "error", }, overrides: [ + { + files: [ + "**/*.{test,spec,testkit}.{ts,tsx}", + "**/{test,tests,testkit,testing,fixtures,integration}/**", + "packages/provider-testing/**", + ], + rules: { "t3code/prefer-effect-fn": "off" }, + }, { files: ["apps/**/src/**", "packages/**/src/**"], excludeFiles: DOM_TEST_ENVIRONMENT_ALLOWLIST,