1import { defineRule } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
5
6const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
7const EFFECT_RUNTIME_METHODS = new Set([
8 "runCallback",
9 "runCallbackWith",
10 "runFork",
11 "runForkWith",
12 "runPromise",
13 "runPromiseExit",
14 "runPromiseExitWith",
15 "runPromiseWith",
16 "runSync",
17 "runSyncExit",
18 "runSyncExitWith",
19 "runSyncWith",
20]);
21
22// The lint config can set `maxOccurrences` per file to track existing manual
23// runners as debt. The rule permits no net-new occurrences in those files,
24// while every other test file must have zero.
25const readMaxOccurrences = (options: ReadonlyArray<unknown>): number => {
26 const [first] = options;
27 return typeof first === "object" &&
28 first !== null &&
29 "maxOccurrences" in first &&
30 typeof first.maxOccurrences === "number"
31 ? first.maxOccurrences
32 : 0;
33};
34
35const manualRunnerName = (callee: unknown): Option.Option<string> => {
36 const expression = unwrapExpression(callee);
37 if (Option.isNone(expression) || expression.value.type !== "MemberExpression") {
38 return Option.none();
39 }
40
41 const object = unwrapExpression(expression.value.object);
42 const property = getPropertyName(expression.value.property);
43 if (Option.isNone(property)) return Option.none();
44
45 if (isIdentifier(object, "Effect") && EFFECT_RUNTIME_METHODS.has(property.value)) {
46 return Option.some(`Effect.${property.value}`);
47 }
48
49 if (isIdentifier(object, "ManagedRuntime") && property.value === "make") {
50 return Option.some("ManagedRuntime.make");
51 }
52
53 return Option.none();
54};
55
56export default defineRule({
57 meta: {
58 type: "problem",
59 docs: {
60 description:
61 "Disallow manually creating or running Effect runtimes in tests; use @effect/vitest.",
62 },
63 schema: [
64 {
65 type: "object",
66 properties: {
67 maxOccurrences: {
68 type: "integer",
69 minimum: 0,
70 description:
71 "Legacy debt ceiling for this file: occurrences beyond this count are reported.",
72 },
73 },
74 additionalProperties: false,
75 },
76 ],
77 defaultOptions: [{ maxOccurrences: 0 }],
78 },
79 create(context) {
80 if (!TEST_FILE_PATTERN.test(context.filename)) return {};
81
82 const allowedCount = readMaxOccurrences(context.options);
83 let occurrenceCount = 0;
84
85 return {
86 CallExpression(node) {
87 const runner = manualRunnerName(node.callee);
88 if (Option.isNone(runner)) return;
89
90 occurrenceCount++;
91 if (occurrenceCount <= allowedCount) return;
92
93 context.report({
94 node: node.callee,
95 message: `Do not use ${runner.value} in tests. Use @effect/vitest with it.effect(...) and test layers instead.`,
96 });
97 },
98 };
99 },
100});