@doubleloop-io/oxlint-plugin-effect 0.1.0-rc.0 → 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -36
- package/RULES.md +87 -67
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -4
- package/dist/presets/all.json +3 -2
- package/dist/rules/all-errors-handled-at-boundary.d.ts +1 -0
- package/dist/rules/all-errors-handled-at-boundary.js +111 -0
- package/dist/rules/error-handling-in-effect-fn-handler.js +80 -2
- package/dist/rules/no-bare-error-in-effect.js +17 -2
- package/dist/rules/no-manual-discriminant-comparison.js +33 -10
- package/dist/rules/no-manual-discriminant-construction.js +47 -5
- package/dist/rules/no-provide-on-definition.js +30 -1
- package/dist/rules/prefer-effect-vitest.d.ts +1 -0
- package/dist/rules/prefer-effect-vitest.js +52 -0
- package/dist/rules/redacted-secret-names.js +2 -4
- package/dist/rules/run-only-in-runner.d.ts +1 -0
- package/dist/rules/{run-only-at-entry.js → run-only-in-runner.js} +10 -20
- package/dist/rules/single-style-per-function.js +89 -21
- package/dist/shared/effect-ast.d.ts +1 -0
- package/dist/shared/effect-ast.js +15 -4
- package/dist/shared/effect-runners.d.ts +2 -0
- package/dist/shared/effect-runners.js +14 -0
- package/dist/shared/entry-files.d.ts +1 -1
- package/dist/shared/test-files.d.ts +1 -1
- package/package.json +62 -56
- package/dist/rules/boundary-entrypoint-shape.d.ts +0 -1
- package/dist/rules/boundary-entrypoint-shape.js +0 -52
- package/dist/rules/run-only-at-entry.d.ts +0 -1
|
@@ -1,20 +1,40 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
|
-
import { isEffectGenerator, memberOf } from "../shared/effect-ast.js";
|
|
2
|
+
import { isEffectGenerator, isServerComponent, memberOf, } from "../shared/effect-ast.js";
|
|
3
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
4
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
3
5
|
const isThenCall = (node) => node.callee.type === "MemberExpression" &&
|
|
4
6
|
!node.callee.computed &&
|
|
5
7
|
node.callee.property.type === "Identifier" &&
|
|
6
8
|
node.callee.property.name === "then";
|
|
7
9
|
const testRunners = new Set(["test", "it"]);
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
const testHooks = new Set(["beforeAll", "afterAll", "beforeEach", "afterEach"]);
|
|
11
|
+
// `test(…)`, `test.only(…)`, `test.each(table)(…)`: the callee chain roots at the runner or hook.
|
|
12
|
+
const testRoleOf = (fn) => {
|
|
10
13
|
const call = fn.parent;
|
|
11
14
|
if (call?.type !== "CallExpression" || !call.arguments.some((arg) => arg === fn))
|
|
12
|
-
return
|
|
15
|
+
return undefined;
|
|
13
16
|
let callee = call.callee;
|
|
14
17
|
while (callee.type === "MemberExpression" || callee.type === "CallExpression") {
|
|
15
18
|
callee = callee.type === "MemberExpression" ? callee.object : callee.callee;
|
|
16
19
|
}
|
|
17
|
-
|
|
20
|
+
if (callee.type !== "Identifier")
|
|
21
|
+
return undefined;
|
|
22
|
+
if (testRunners.has(callee.name))
|
|
23
|
+
return "test";
|
|
24
|
+
if (testHooks.has(callee.name))
|
|
25
|
+
return "hook";
|
|
26
|
+
return undefined;
|
|
27
|
+
};
|
|
28
|
+
// Awaiting promise-based fixtures and then one Effect program reads as Arrange/Act/Assert;
|
|
29
|
+
// a second Effect step is where promise and Effect sequencing start to interleave.
|
|
30
|
+
const interleavesEffectSteps = (frame) => {
|
|
31
|
+
const effectSteps = frame.promiseSteps.filter((step) => step.involvesEffect);
|
|
32
|
+
return (effectSteps.length > 1 || effectSteps.some((step) => step.node.type !== "AwaitExpression"));
|
|
33
|
+
};
|
|
34
|
+
const reportFor = {
|
|
35
|
+
test: "asyncEffectTest",
|
|
36
|
+
hook: "asyncEffectHook",
|
|
37
|
+
helper: "asyncEffectHelper",
|
|
18
38
|
};
|
|
19
39
|
export const singleStylePerFunctionRule = defineRule({
|
|
20
40
|
meta: {
|
|
@@ -25,31 +45,66 @@ export const singleStylePerFunctionRule = defineRule({
|
|
|
25
45
|
messages: {
|
|
26
46
|
asyncEffect: "This function builds Effects: drop `async` and lift promises with `Effect.tryPromise({ try, catch })`.",
|
|
27
47
|
promiseStep: "Do not mix promises with Effect in the same function: lift the promise with `Effect.tryPromise({ try, catch })`.",
|
|
28
|
-
asyncEffectTest: "This test
|
|
48
|
+
asyncEffectTest: "This test chains promises around Effect: await a single program and assert on its result, or make its body one Effect with `it.effect` from `@effect/vitest`.",
|
|
49
|
+
asyncEffectHook: "This hook chains promises around Effect: await a single program, lifting the promises into it with `Effect.tryPromise({ try, catch })`, or build the fixture as a Layer with `layer` from `@effect/vitest`.",
|
|
50
|
+
asyncEffectHelper: "This test helper chains promises around Effect: await a single program, lifting the promises into it with `Effect.tryPromise({ try, catch })`.",
|
|
29
51
|
},
|
|
52
|
+
schema: [
|
|
53
|
+
{
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: { testFiles: filePatternsSchema },
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
30
59
|
},
|
|
31
60
|
createOnce(context) {
|
|
32
61
|
const frames = [];
|
|
33
62
|
const current = () => frames.at(-1);
|
|
63
|
+
const openSteps = [];
|
|
64
|
+
let inTestFile = false;
|
|
65
|
+
// In a test file a helper such as `probe` or `captureLines` is a promise/Effect boundary
|
|
66
|
+
// like the test that calls it, so it gets the same allowance.
|
|
67
|
+
const roleOf = (fn) => testRoleOf(fn) ?? (inTestFile && !isEffectGenerator(fn) ? "helper" : undefined);
|
|
34
68
|
const enter = (fn) => {
|
|
35
|
-
frames.push({
|
|
69
|
+
frames.push({
|
|
70
|
+
fn,
|
|
71
|
+
effectful: isEffectGenerator(fn),
|
|
72
|
+
rendersJsx: false,
|
|
73
|
+
promiseSteps: [],
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const openStep = (node) => {
|
|
77
|
+
const step = { node, involvesEffect: false };
|
|
78
|
+
current()?.promiseSteps.push(step);
|
|
79
|
+
openSteps.push(step);
|
|
36
80
|
};
|
|
37
81
|
const exit = () => {
|
|
38
82
|
const frame = frames.pop();
|
|
39
|
-
if (frame === undefined
|
|
83
|
+
if (frame === undefined)
|
|
40
84
|
return;
|
|
41
|
-
// One report per test: the remedy is
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
85
|
+
// One report per test or hook: the remedy is its shape, not each promise in it.
|
|
86
|
+
const role = roleOf(frame.fn);
|
|
87
|
+
if (role !== undefined) {
|
|
88
|
+
if (interleavesEffectSteps(frame))
|
|
89
|
+
context.report({ node: frame.fn, messageId: reportFor[role] });
|
|
45
90
|
return;
|
|
46
91
|
}
|
|
92
|
+
if (!frame.effectful || isServerComponent(frame.fn, frame.rendersJsx))
|
|
93
|
+
return;
|
|
47
94
|
if (frame.fn.async)
|
|
48
95
|
context.report({ node: frame.fn, messageId: "asyncEffect" });
|
|
49
|
-
for (const
|
|
50
|
-
context.report({ node, messageId: "promiseStep" });
|
|
96
|
+
for (const step of frame.promiseSteps)
|
|
97
|
+
context.report({ node: step.node, messageId: "promiseStep" });
|
|
98
|
+
};
|
|
99
|
+
const markJsx = () => {
|
|
100
|
+
const frame = current();
|
|
101
|
+
if (frame !== undefined)
|
|
102
|
+
frame.rendersJsx = true;
|
|
51
103
|
};
|
|
52
104
|
return {
|
|
105
|
+
before() {
|
|
106
|
+
inTestFile = isTestFile(context);
|
|
107
|
+
},
|
|
53
108
|
ArrowFunctionExpression: enter,
|
|
54
109
|
FunctionExpression: enter,
|
|
55
110
|
FunctionDeclaration: enter,
|
|
@@ -57,20 +112,33 @@ export const singleStylePerFunctionRule = defineRule({
|
|
|
57
112
|
"FunctionExpression:exit": exit,
|
|
58
113
|
"FunctionDeclaration:exit": exit,
|
|
59
114
|
MemberExpression(node) {
|
|
60
|
-
const frame = current();
|
|
61
115
|
const name = memberOf(node, "Effect");
|
|
116
|
+
if (name === undefined)
|
|
117
|
+
return;
|
|
118
|
+
// A step involves Effect wherever it appears in it, nested callbacks included.
|
|
119
|
+
for (const step of openSteps)
|
|
120
|
+
step.involvesEffect = true;
|
|
121
|
+
const frame = current();
|
|
122
|
+
if (frame === undefined)
|
|
123
|
+
return;
|
|
62
124
|
// `Effect.run*` is how an async boundary legitimately hands off to Effect (P27);
|
|
63
|
-
// where it may appear is run-only-
|
|
64
|
-
if (
|
|
125
|
+
// where it may appear is run-only-in-runner's business.
|
|
126
|
+
if (!name.startsWith("run"))
|
|
65
127
|
frame.effectful = true;
|
|
66
|
-
}
|
|
67
128
|
},
|
|
68
|
-
|
|
69
|
-
|
|
129
|
+
JSXElement: markJsx,
|
|
130
|
+
JSXFragment: markJsx,
|
|
131
|
+
AwaitExpression: openStep,
|
|
132
|
+
"AwaitExpression:exit"() {
|
|
133
|
+
openSteps.pop();
|
|
70
134
|
},
|
|
71
135
|
CallExpression(node) {
|
|
72
136
|
if (isThenCall(node))
|
|
73
|
-
|
|
137
|
+
openStep(node);
|
|
138
|
+
},
|
|
139
|
+
"CallExpression:exit"(node) {
|
|
140
|
+
if (isThenCall(node))
|
|
141
|
+
openSteps.pop();
|
|
74
142
|
},
|
|
75
143
|
};
|
|
76
144
|
},
|
|
@@ -7,3 +7,4 @@ export declare const isEffectFnApplication: (node: ESTree.Node | null | undefine
|
|
|
7
7
|
export declare const isEffectGenerator: (fn: FunctionNode) => boolean;
|
|
8
8
|
export declare const isInsideEffectGeneratorBody: (node: ESTree.Node) => boolean;
|
|
9
9
|
export declare const nearestFunction: (node: ESTree.Node) => FunctionNode | undefined;
|
|
10
|
+
export declare const isServerComponent: (fn: FunctionNode, rendersJsx: boolean) => boolean;
|
|
@@ -28,8 +28,12 @@ export const isEffectGenerator = (fn) => {
|
|
|
28
28
|
return call.arguments.some((arg) => arg === fn);
|
|
29
29
|
return isEffectFnApplication(call) && call.arguments[0] === fn;
|
|
30
30
|
};
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const isEffectCombinatorCallback = (fn) => fn.parent?.type === "CallExpression" &&
|
|
32
|
+
memberOf(fn.parent.callee, "Effect") !== undefined &&
|
|
33
|
+
fn.parent.arguments.some((arg) => arg === fn);
|
|
34
|
+
// Callbacks handed to an `Effect.*` combinator inside the generator still belong to its success
|
|
35
|
+
// path; the pipeable arguments of an `Effect.fn` nested in it are the handler slot, so they do
|
|
36
|
+
// not, and neither does a function the generator only defines (an HTTP handler, a service method).
|
|
33
37
|
export const isInsideEffectGeneratorBody = (node) => {
|
|
34
38
|
let child = node;
|
|
35
39
|
for (let current = node.parent; current; current = current.parent) {
|
|
@@ -37,8 +41,12 @@ export const isInsideEffectGeneratorBody = (node) => {
|
|
|
37
41
|
current.arguments.findIndex((arg) => arg === child) > 0) {
|
|
38
42
|
return false;
|
|
39
43
|
}
|
|
40
|
-
if (isFunctionNode(current)
|
|
41
|
-
|
|
44
|
+
if (isFunctionNode(current)) {
|
|
45
|
+
if (current.generator)
|
|
46
|
+
return isEffectGenerator(current);
|
|
47
|
+
if (!isEffectCombinatorCallback(current))
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
42
50
|
child = current;
|
|
43
51
|
}
|
|
44
52
|
return false;
|
|
@@ -50,3 +58,6 @@ export const nearestFunction = (node) => {
|
|
|
50
58
|
}
|
|
51
59
|
return undefined;
|
|
52
60
|
};
|
|
61
|
+
// An async function that renders JSX is a React Server Component: it hands its outcome to React,
|
|
62
|
+
// and how failures become UI is the application's call, not a rule's.
|
|
63
|
+
export const isServerComponent = (fn, rendersJsx) => fn.async && rendersJsx;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { memberOf } from "./effect-ast.js";
|
|
2
|
+
const runtimeEntries = [
|
|
3
|
+
["NodeRuntime", "runMain"],
|
|
4
|
+
["BunRuntime", "runMain"],
|
|
5
|
+
["ManagedRuntime", "make"],
|
|
6
|
+
];
|
|
7
|
+
// The qualified name of the runner `node` refers to, when it runs an Effect program.
|
|
8
|
+
export const runnerName = (node) => {
|
|
9
|
+
const effectMember = memberOf(node, "Effect");
|
|
10
|
+
if (effectMember?.startsWith("run"))
|
|
11
|
+
return `Effect.${effectMember}`;
|
|
12
|
+
const entry = runtimeEntries.find(([object, property]) => memberOf(node, object) === property);
|
|
13
|
+
return entry === undefined ? undefined : `${entry[0]}.${entry[1]}`;
|
|
14
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const defaultTestFiles:
|
|
1
|
+
export declare const defaultTestFiles: readonly ["**/*.test.*", "**/*.spec.*", "**/__tests__/**", "**/test/**", "**/tests/**"];
|
|
2
2
|
export declare const isTestFile: (context: {
|
|
3
3
|
readonly filename: string;
|
|
4
4
|
readonly cwd: string;
|
package/package.json
CHANGED
|
@@ -1,57 +1,63 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"
|
|
57
|
-
|
|
2
|
+
"name": "@doubleloop-io/oxlint-plugin-effect",
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "Oxlint rules for Effect 4 code",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/doubleloop-io/oxlint-plugin-effect.git"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/",
|
|
12
|
+
"RULES.md",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"LICENSE.anti-slop"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./presets/all": {
|
|
24
|
+
"types": "./dist/presets/all.d.ts",
|
|
25
|
+
"default": "./dist/presets/all.js"
|
|
26
|
+
},
|
|
27
|
+
"./presets/all.json": "./dist/presets/all.json"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@oxlint/plugins": "^1.82.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^24.13.6",
|
|
37
|
+
"oxfmt": "0.67.0",
|
|
38
|
+
"oxlint": "1.82.0",
|
|
39
|
+
"publint": "^0.3.25",
|
|
40
|
+
"typescript": "^7.0.2"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"oxlint": "^1.82.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": "^22.20.0 || >=24.8.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"format": "oxfmt",
|
|
50
|
+
"format:check": "oxfmt --check",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"lint": "oxlint --deny-warnings",
|
|
53
|
+
"test": "node --test install-skill.test.ts link-consumer.test.ts rules-md.test.ts rules/*.test.ts",
|
|
54
|
+
"check": "pnpm format:check && pnpm typecheck && pnpm lint && pnpm test",
|
|
55
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && node presets/all-json.ts",
|
|
56
|
+
"build:w": "node --watch-path=index.ts --watch-path=rules --watch-path=presets --watch-path=shared --watch-path=tsconfig.build.json scripts/build.ts",
|
|
57
|
+
"pack:check": "node --test presets/all-dist.test.ts && node scripts/pack-check.ts",
|
|
58
|
+
"build-and-pack": "pnpm check && pnpm build && pnpm pack:check && pnpm smoke-test",
|
|
59
|
+
"smoke-test": "node scripts/smoke-test.ts",
|
|
60
|
+
"consumer:link": "./link-consumer.sh link",
|
|
61
|
+
"consumer:unlink": "./link-consumer.sh unlink"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const boundaryEntrypointShapeRule: import("@oxlint/plugins").Rule;
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { defineRule } from "@oxlint/plugins";
|
|
2
|
-
import { calleeOf, nearestFunction } from "../shared/effect-ast.js";
|
|
3
|
-
// The *Exit runners hand failures back as values, so an open error channel cannot leak there.
|
|
4
|
-
const effectRunners = new Set(["runPromise", "runSync", "runFork", "runCallback"]);
|
|
5
|
-
const runtimes = ["NodeRuntime", "BunRuntime"];
|
|
6
|
-
const isRunnerCall = (node) => effectRunners.has(calleeOf(node, "Effect") ?? "") ||
|
|
7
|
-
runtimes.some((runtime) => calleeOf(node, runtime) === "runMain");
|
|
8
|
-
const isEffectTypeName = (name) => (name.type === "Identifier" && name.name === "Effect") ||
|
|
9
|
-
(name.type === "TSQualifiedName" &&
|
|
10
|
-
name.left.type === "Identifier" &&
|
|
11
|
-
name.left.name === "Effect" &&
|
|
12
|
-
name.right.name === "Effect");
|
|
13
|
-
const isClosedEffectType = (type) => type?.type === "TSTypeReference" &&
|
|
14
|
-
isEffectTypeName(type.typeName) &&
|
|
15
|
-
type.typeArguments?.params[1]?.type === "TSNeverKeyword";
|
|
16
|
-
const parameterType = (identifier) => {
|
|
17
|
-
for (let fn = nearestFunction(identifier); fn; fn = nearestFunction(fn)) {
|
|
18
|
-
for (const param of fn.params) {
|
|
19
|
-
if (param.type === "Identifier" && param.name === identifier.name) {
|
|
20
|
-
return param.typeAnnotation?.typeAnnotation;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return undefined;
|
|
25
|
-
};
|
|
26
|
-
const hasClosedErrorChannel = (node) => {
|
|
27
|
-
if (node?.type === "TSSatisfiesExpression")
|
|
28
|
-
return isClosedEffectType(node.typeAnnotation);
|
|
29
|
-
if (node?.type === "Identifier")
|
|
30
|
-
return isClosedEffectType(parameterType(node));
|
|
31
|
-
return false;
|
|
32
|
-
};
|
|
33
|
-
export const boundaryEntrypointShapeRule = defineRule({
|
|
34
|
-
meta: {
|
|
35
|
-
type: "problem",
|
|
36
|
-
docs: {
|
|
37
|
-
description: "Close the error channel to `never` before running an Effect at the boundary.",
|
|
38
|
-
},
|
|
39
|
-
messages: {
|
|
40
|
-
openErrorChannel: "Close the error channel before running: `program satisfies Effect.Effect<A, never>`, or a runner parameter typed `Effect.Effect<A, never, never>`.",
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
createOnce(context) {
|
|
44
|
-
return {
|
|
45
|
-
CallExpression(node) {
|
|
46
|
-
if (isRunnerCall(node) && !hasClosedErrorChannel(node.arguments[0])) {
|
|
47
|
-
context.report({ node, messageId: "openErrorChannel" });
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
},
|
|
52
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare const runOnlyAtEntryRule: import("@oxlint/plugins").Rule;
|