@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.
@@ -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
- // `test(…)`, `test.only(…)`, `test.each(table)(…)`: the callee chain roots at `test` or `it`.
9
- const isTestCallback = (fn) => {
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 false;
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
- return callee.type === "Identifier" && testRunners.has(callee.name);
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 mixes promises with Effect: make its body one Effect, with `it.effect` from `@effect/vitest` or by returning a single test runner call from a non-async callback.",
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({ fn, effectful: isEffectGenerator(fn), promiseSteps: [] });
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 || !frame.effectful)
83
+ if (frame === undefined)
40
84
  return;
41
- // One report per test: the remedy is the test's shape, not each promise in it.
42
- if (isTestCallback(frame.fn)) {
43
- if (frame.fn.async || frame.promiseSteps.length > 0)
44
- context.report({ node: frame.fn, messageId: "asyncEffectTest" });
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 node of frame.promiseSteps)
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-at-entry's business.
64
- if (frame !== undefined && name !== undefined && !name.startsWith("run")) {
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
- AwaitExpression(node) {
69
- current()?.promiseSteps.push(node);
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
- current()?.promiseSteps.push(node);
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
- // Plain callbacks inside the generator still belong to its success path; the pipeable
32
- // arguments of an `Effect.fn` nested in it are the handler slot, so they do not.
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) && current.generator)
41
- return isEffectGenerator(current);
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,2 @@
1
+ import type { ESTree } from "@oxlint/plugins";
2
+ export declare const runnerName: (node: ESTree.MemberExpression) => string | undefined;
@@ -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 defaultEntryFiles: string[];
1
+ export declare const defaultEntryFiles: readonly ["**/main.ts", "**/cli.ts", "**/bin/**"];
2
2
  export declare const isEntryFile: (context: {
3
3
  readonly filename: string;
4
4
  readonly cwd: string;
@@ -1,4 +1,4 @@
1
- export declare const defaultTestFiles: string[];
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
- "name": "@doubleloop-io/oxlint-plugin-effect",
3
- "version": "0.1.0-rc.0",
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
- "./presets/all": {
20
- "types": "./dist/presets/all.d.ts",
21
- "default": "./dist/presets/all.js"
22
- },
23
- "./presets/all.json": "./dist/presets/all.json"
24
- },
25
- "publishConfig": {
26
- "access": "public"
27
- },
28
- "scripts": {
29
- "format": "oxfmt",
30
- "format:check": "oxfmt --check",
31
- "typecheck": "tsc --noEmit",
32
- "lint": "oxlint --deny-warnings",
33
- "test": "node --test install-skill.test.ts rules-md.test.ts rules/*.test.ts",
34
- "check": "pnpm format:check && pnpm typecheck && pnpm lint && pnpm test",
35
- "build": "rm -rf dist && tsc -p tsconfig.build.json && node presets/all-json.ts",
36
- "pack:check": "node --test presets/all-dist.test.ts && node scripts/pack-check.ts",
37
- "build-and-pack": "pnpm check && pnpm build && pnpm pack:check && pnpm smoke-test",
38
- "smoke-test": "node scripts/smoke-test.ts"
39
- },
40
- "dependencies": {
41
- "@oxlint/plugins": "^1.82.0"
42
- },
43
- "devDependencies": {
44
- "@types/node": "^24.13.6",
45
- "oxfmt": "0.67.0",
46
- "oxlint": "1.82.0",
47
- "publint": "^0.3.25",
48
- "typescript": "^7.0.2"
49
- },
50
- "peerDependencies": {
51
- "oxlint": "^1.82.0"
52
- },
53
- "engines": {
54
- "node": "^22.20.0 || >=24.8.0"
55
- },
56
- "packageManager": "pnpm@12.4.1"
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;