@doubleloop-io/oxlint-plugin-effect 0.0.0-stage → 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/LICENSE +21 -0
- package/LICENSE.anti-slop +21 -0
- package/README.md +216 -2
- package/RULES.md +931 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +75 -0
- package/dist/presets/all.d.ts +7 -0
- package/dist/presets/all.js +9 -0
- package/dist/presets/all.json +38 -0
- package/dist/rules/adapter-import-boundary.d.ts +7 -0
- package/dist/rules/adapter-import-boundary.js +103 -0
- 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/anti-slop-no-manual-effect-error-tag.d.ts +1 -0
- package/dist/rules/anti-slop-no-manual-effect-error-tag.js +32 -0
- package/dist/rules/anti-slop-no-service-constructor-imports.d.ts +1 -0
- package/dist/rules/anti-slop-no-service-constructor-imports.js +49 -0
- package/dist/rules/anti-slop-prefer-effect-match.d.ts +1 -0
- package/dist/rules/anti-slop-prefer-effect-match.js +45 -0
- package/dist/rules/error-handling-in-effect-fn-handler.d.ts +1 -0
- package/dist/rules/error-handling-in-effect-fn-handler.js +144 -0
- package/dist/rules/exported-schema-identifier.d.ts +1 -0
- package/dist/rules/exported-schema-identifier.js +117 -0
- package/dist/rules/no-asymmetric-match-handlers.d.ts +1 -0
- package/dist/rules/no-asymmetric-match-handlers.js +74 -0
- package/dist/rules/no-bare-error-in-effect.d.ts +1 -0
- package/dist/rules/no-bare-error-in-effect.js +49 -0
- package/dist/rules/no-effect-promise.d.ts +1 -0
- package/dist/rules/no-effect-promise.js +57 -0
- package/dist/rules/no-effect-return-type.d.ts +1 -0
- package/dist/rules/no-effect-return-type.js +130 -0
- package/dist/rules/no-inline-schema-codec.d.ts +1 -0
- package/dist/rules/no-inline-schema-codec.js +46 -0
- package/dist/rules/no-inline-yield-star.d.ts +1 -0
- package/dist/rules/no-inline-yield-star.js +230 -0
- package/dist/rules/no-manual-discriminant-comparison.d.ts +2 -0
- package/dist/rules/no-manual-discriminant-comparison.js +81 -0
- package/dist/rules/no-manual-discriminant-construction.d.ts +2 -0
- package/dist/rules/no-manual-discriminant-construction.js +132 -0
- package/dist/rules/no-nested-match.d.ts +1 -0
- package/dist/rules/no-nested-match.js +24 -0
- package/dist/rules/no-parameterless-catch-all.d.ts +1 -0
- package/dist/rules/no-parameterless-catch-all.js +29 -0
- package/dist/rules/no-provide-on-definition.d.ts +1 -0
- package/dist/rules/no-provide-on-definition.js +123 -0
- package/dist/rules/no-transport-types-in-generator.d.ts +1 -0
- package/dist/rules/no-transport-types-in-generator.js +63 -0
- package/dist/rules/no-yielded-match.d.ts +1 -0
- package/dist/rules/no-yielded-match.js +91 -0
- package/dist/rules/prefer-effect-fn.d.ts +1 -0
- package/dist/rules/prefer-effect-fn.js +102 -0
- package/dist/rules/prefer-effect-vitest.d.ts +1 -0
- package/dist/rules/prefer-effect-vitest.js +52 -0
- package/dist/rules/provide-named-layer.d.ts +1 -0
- package/dist/rules/provide-named-layer.js +55 -0
- package/dist/rules/redacted-secret-names.d.ts +1 -0
- package/dist/rules/redacted-secret-names.js +125 -0
- package/dist/rules/run-only-in-runner.d.ts +1 -0
- package/dist/rules/run-only-in-runner.js +40 -0
- package/dist/rules/service-built-with-of.d.ts +1 -0
- package/dist/rules/service-built-with-of.js +74 -0
- package/dist/rules/service-key-matches-name.d.ts +1 -0
- package/dist/rules/service-key-matches-name.js +69 -0
- package/dist/rules/service-port-shape-only.d.ts +1 -0
- package/dist/rules/service-port-shape-only.js +65 -0
- package/dist/rules/single-style-per-function.d.ts +1 -0
- package/dist/rules/single-style-per-function.js +145 -0
- package/dist/rules/try-object-form.d.ts +1 -0
- package/dist/rules/try-object-form.js +26 -0
- package/dist/rules/untraced-recursion.d.ts +1 -0
- package/dist/rules/untraced-recursion.js +48 -0
- package/dist/shared/effect-ast.d.ts +10 -0
- package/dist/shared/effect-ast.js +63 -0
- package/dist/shared/effect-runners.d.ts +2 -0
- package/dist/shared/effect-runners.js +14 -0
- package/dist/shared/entry-files.d.ts +6 -0
- package/dist/shared/entry-files.js +5 -0
- package/dist/shared/file-globs.d.ts +14 -0
- package/dist/shared/file-globs.js +17 -0
- package/dist/shared/match-ast.d.ts +5 -0
- package/dist/shared/match-ast.js +52 -0
- package/dist/shared/tagged-values.d.ts +17 -0
- package/dist/shared/tagged-values.js +67 -0
- package/dist/shared/test-files.d.ts +6 -0
- package/dist/shared/test-files.js +10 -0
- package/package.json +60 -3
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { isInsideEffectGeneratorBody } from "../shared/effect-ast.js";
|
|
3
|
+
const defaultTransportTypes = ["Response", "NextResponse"];
|
|
4
|
+
const configuredTransportTypes = (options) => {
|
|
5
|
+
const [first] = options;
|
|
6
|
+
if (typeof first !== "object" || first === null || !("transportTypes" in first)) {
|
|
7
|
+
return new Set(defaultTransportTypes);
|
|
8
|
+
}
|
|
9
|
+
const types = first.transportTypes;
|
|
10
|
+
return new Set(Array.isArray(types) ? types.filter((type) => typeof type === "string") : []);
|
|
11
|
+
};
|
|
12
|
+
// `new Response(…)`, `Response.json(…)` and a plain factory call `toDto(…)` all build one.
|
|
13
|
+
const constructedTypeName = (node) => {
|
|
14
|
+
const callee = node.callee;
|
|
15
|
+
if (callee.type === "Identifier")
|
|
16
|
+
return callee.name;
|
|
17
|
+
if (node.type === "CallExpression" &&
|
|
18
|
+
callee.type === "MemberExpression" &&
|
|
19
|
+
callee.object.type === "Identifier") {
|
|
20
|
+
return callee.object.name;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
};
|
|
24
|
+
export const noTransportTypesInGeneratorRule = defineRule({
|
|
25
|
+
meta: {
|
|
26
|
+
type: "suggestion",
|
|
27
|
+
docs: {
|
|
28
|
+
description: "Return domain values from Effect generators and convert them to transport types in the second argument of `Effect.fn`.",
|
|
29
|
+
},
|
|
30
|
+
messages: {
|
|
31
|
+
transportInGenerator: "Do not build `{{name}}` inside the generator: return a domain value and convert it with `Effect.map`/`Effect.catchTags` in the pipeable arguments of `Effect.fn`.",
|
|
32
|
+
},
|
|
33
|
+
schema: [
|
|
34
|
+
{
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: {
|
|
37
|
+
transportTypes: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
38
|
+
},
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
createOnce(context) {
|
|
44
|
+
let transportTypes = new Set();
|
|
45
|
+
const check = (node) => {
|
|
46
|
+
const name = constructedTypeName(node);
|
|
47
|
+
if (name === undefined ||
|
|
48
|
+
!transportTypes.has(name) ||
|
|
49
|
+
!isInsideEffectGeneratorBody(node)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
context.report({ node, messageId: "transportInGenerator", data: { name } });
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
// Options are per file, so they can only be read once a file starts.
|
|
56
|
+
before() {
|
|
57
|
+
transportTypes = configuredTransportTypes(context.options);
|
|
58
|
+
},
|
|
59
|
+
NewExpression: check,
|
|
60
|
+
CallExpression: check,
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noYieldedMatchRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, isEffectGenerator, isFunctionNode, memberOf, nearestFunction, } from "../shared/effect-ast.js";
|
|
3
|
+
import { handlersOf, nestedBranches } from "../shared/match-ast.js";
|
|
4
|
+
const guards = { Option: "Option.isNone", Result: "Result.isFailure" };
|
|
5
|
+
const matchedModule = (node) => {
|
|
6
|
+
if (node?.type !== "CallExpression")
|
|
7
|
+
return undefined;
|
|
8
|
+
if (memberOf(node.callee, "Option") === "match")
|
|
9
|
+
return "Option";
|
|
10
|
+
if (memberOf(node.callee, "Result") === "match")
|
|
11
|
+
return "Result";
|
|
12
|
+
return undefined;
|
|
13
|
+
};
|
|
14
|
+
const isPipe = (callee) => (callee.type === "Identifier" && callee.name === "pipe") ||
|
|
15
|
+
(callee.type === "MemberExpression" &&
|
|
16
|
+
!callee.computed &&
|
|
17
|
+
callee.property.type === "Identifier" &&
|
|
18
|
+
callee.property.name === "pipe");
|
|
19
|
+
// Data-first `Option.match(x, {…})`, or a match as the last step of `x.pipe(…)`/`pipe(x, …)`.
|
|
20
|
+
const yieldedMatch = (node) => {
|
|
21
|
+
if (node?.type !== "CallExpression")
|
|
22
|
+
return undefined;
|
|
23
|
+
if (matchedModule(node) !== undefined && node.arguments.length === 2)
|
|
24
|
+
return node;
|
|
25
|
+
if (!isPipe(node.callee))
|
|
26
|
+
return undefined;
|
|
27
|
+
const last = node.arguments.at(-1);
|
|
28
|
+
return matchedModule(last) !== undefined && last?.type === "CallExpression" ? last : undefined;
|
|
29
|
+
};
|
|
30
|
+
const handlerNamed = (match, name) => {
|
|
31
|
+
for (const argument of match.arguments) {
|
|
32
|
+
if (argument.type !== "ObjectExpression")
|
|
33
|
+
continue;
|
|
34
|
+
for (const property of argument.properties) {
|
|
35
|
+
if (property.type === "Property" &&
|
|
36
|
+
!property.computed &&
|
|
37
|
+
property.key.type === "Identifier" &&
|
|
38
|
+
property.key.name === name) {
|
|
39
|
+
return property.value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
};
|
|
45
|
+
// `onSome: Effect.succeed` or `onSome: (x) => Effect.succeed(x)`.
|
|
46
|
+
const resultsIn = (handler, constructor) => {
|
|
47
|
+
if (memberOf(handler, "Effect") === constructor)
|
|
48
|
+
return true;
|
|
49
|
+
return (isFunctionNode(handler) &&
|
|
50
|
+
handler.body?.type !== "BlockStatement" &&
|
|
51
|
+
calleeOf(handler.body, "Effect") === constructor);
|
|
52
|
+
};
|
|
53
|
+
// `option-match-to-from-option` (effecttsgo) proposes `Effect.fromOption` for this shape.
|
|
54
|
+
const isFromOptionShape = (match) => matchedModule(match) === "Option" &&
|
|
55
|
+
resultsIn(handlerNamed(match, "onNone"), "fail") &&
|
|
56
|
+
resultsIn(handlerNamed(match, "onSome"), "succeed");
|
|
57
|
+
export const noYieldedMatchRule = defineRule({
|
|
58
|
+
meta: {
|
|
59
|
+
type: "suggestion",
|
|
60
|
+
docs: {
|
|
61
|
+
description: "Inside an Effect generator, return early on the empty or failed case instead of yielding an `Option.match`/`Result.match`.",
|
|
62
|
+
},
|
|
63
|
+
messages: {
|
|
64
|
+
yieldedMatch: "This `yield*` of a `{{module}}.match` splits the generator into two branches: return early with `if ({{guard}}(…)) return …` and keep one main path.",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
createOnce(context) {
|
|
68
|
+
return {
|
|
69
|
+
YieldExpression(node) {
|
|
70
|
+
if (!node.delegate)
|
|
71
|
+
return;
|
|
72
|
+
const match = yieldedMatch(node.argument);
|
|
73
|
+
const module = matchedModule(match);
|
|
74
|
+
if (match === undefined || module === undefined)
|
|
75
|
+
return;
|
|
76
|
+
const generator = nearestFunction(node);
|
|
77
|
+
if (generator === undefined || !isEffectGenerator(generator))
|
|
78
|
+
return;
|
|
79
|
+
if (isFromOptionShape(match))
|
|
80
|
+
return;
|
|
81
|
+
if (handlersOf(match).some((handler) => nestedBranches(handler).length > 0))
|
|
82
|
+
return;
|
|
83
|
+
context.report({
|
|
84
|
+
node: match,
|
|
85
|
+
messageId: "yieldedMatch",
|
|
86
|
+
data: { module, guard: guards[module] },
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const preferEffectFnRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, isEffectFnApplication, isFunctionNode, memberOf, } from "../shared/effect-ast.js";
|
|
3
|
+
const isEffectGenChain = (node) => {
|
|
4
|
+
if (node?.type !== "CallExpression")
|
|
5
|
+
return false;
|
|
6
|
+
if (calleeOf(node, "Effect") === "gen")
|
|
7
|
+
return true;
|
|
8
|
+
const callee = node.callee;
|
|
9
|
+
return (callee.type === "MemberExpression" &&
|
|
10
|
+
!callee.computed &&
|
|
11
|
+
callee.property.type === "Identifier" &&
|
|
12
|
+
callee.property.name === "pipe" &&
|
|
13
|
+
isEffectGenChain(callee.object));
|
|
14
|
+
};
|
|
15
|
+
const returnedExpression = (fn) => {
|
|
16
|
+
if (fn.body?.type !== "BlockStatement")
|
|
17
|
+
return fn.body;
|
|
18
|
+
const statements = fn.body.body;
|
|
19
|
+
const [only] = statements;
|
|
20
|
+
return statements.length === 1 && only?.type === "ReturnStatement" ? only.argument : undefined;
|
|
21
|
+
};
|
|
22
|
+
// Callbacks such as `it.effect("…", () => Effect.gen(…))` are not named functions: only
|
|
23
|
+
// declarations, variables and object members get a span name worth having.
|
|
24
|
+
const isNamedFunction = (fn) => {
|
|
25
|
+
const parent = fn.parent;
|
|
26
|
+
return (fn.type === "FunctionDeclaration" ||
|
|
27
|
+
(parent?.type === "VariableDeclarator" && parent.init === fn) ||
|
|
28
|
+
((parent?.type === "Property" || parent?.type === "PropertyDefinition") &&
|
|
29
|
+
parent.value === fn));
|
|
30
|
+
};
|
|
31
|
+
const isPipeCall = (node) => node.callee.type === "MemberExpression" &&
|
|
32
|
+
!node.callee.computed &&
|
|
33
|
+
node.callee.property.type === "Identifier" &&
|
|
34
|
+
node.callee.property.name === "pipe";
|
|
35
|
+
// A value keeps its span through `Effect.withSpan`: `Effect.fn` would add only a call-site frame
|
|
36
|
+
// that always points at the same line.
|
|
37
|
+
const hasSpan = (node) => node?.type === "CallExpression" &&
|
|
38
|
+
isPipeCall(node) &&
|
|
39
|
+
node.callee.type === "MemberExpression" &&
|
|
40
|
+
(node.arguments.some((argument) => calleeOf(argument, "Effect") === "withSpan") ||
|
|
41
|
+
hasSpan(node.callee.object));
|
|
42
|
+
const isUntracedValue = (node) => isEffectGenChain(node) && !hasSpan(node);
|
|
43
|
+
export const preferEffectFnRule = defineRule({
|
|
44
|
+
meta: {
|
|
45
|
+
type: "suggestion",
|
|
46
|
+
docs: {
|
|
47
|
+
description: "Write effectful functions with a named `Effect.fn` instead of returning `Effect.gen`.",
|
|
48
|
+
},
|
|
49
|
+
messages: {
|
|
50
|
+
useEffectFn: 'Use `Effect.fn("name")(function* (...) {...})` instead of a function that returns `Effect.gen`.',
|
|
51
|
+
missingName: 'Give `Effect.fn` a span name: `Effect.fn("name")(function* (...) {...})`.',
|
|
52
|
+
useEffectFnForValue: 'Give `{{name}}` a span: declare it as `Effect.fn("{{name}}")(function* () {...})` and call it, or append `.pipe(Effect.withSpan("{{name}}"))`.',
|
|
53
|
+
pipeOnEffectFn: "Pass combinators as extra arguments of `Effect.fn(...)(body, ...)` instead of calling `.pipe` on it.",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
createOnce(context) {
|
|
57
|
+
const checkFunction = (fn) => {
|
|
58
|
+
if (fn.generator || !isNamedFunction(fn))
|
|
59
|
+
return;
|
|
60
|
+
if (isEffectGenChain(returnedExpression(fn))) {
|
|
61
|
+
context.report({ node: fn, messageId: "useEffectFn" });
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
VariableDeclarator(node) {
|
|
66
|
+
if (node.id.type === "Identifier" && isUntracedValue(node.init)) {
|
|
67
|
+
context.report({
|
|
68
|
+
node,
|
|
69
|
+
messageId: "useEffectFnForValue",
|
|
70
|
+
data: { name: node.id.name },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
Property(node) {
|
|
75
|
+
if (node.key.type === "Identifier" && isUntracedValue(node.value)) {
|
|
76
|
+
context.report({
|
|
77
|
+
node,
|
|
78
|
+
messageId: "useEffectFnForValue",
|
|
79
|
+
data: { name: node.key.name },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
ArrowFunctionExpression: checkFunction,
|
|
84
|
+
FunctionExpression: checkFunction,
|
|
85
|
+
FunctionDeclaration: checkFunction,
|
|
86
|
+
CallExpression(node) {
|
|
87
|
+
if (memberOf(node.callee, "Effect") === "fn" && isFunctionNode(node.arguments[0])) {
|
|
88
|
+
context.report({ node, messageId: "missingName" });
|
|
89
|
+
}
|
|
90
|
+
const callee = node.callee;
|
|
91
|
+
if (callee.type === "MemberExpression" &&
|
|
92
|
+
!callee.computed &&
|
|
93
|
+
callee.property.type === "Identifier" &&
|
|
94
|
+
callee.property.name === "pipe" &&
|
|
95
|
+
isEffectFnApplication(callee.object) &&
|
|
96
|
+
isFunctionNode(callee.object.arguments[0])) {
|
|
97
|
+
context.report({ node, messageId: "pipeOnEffectFn" });
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const preferEffectVitestRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { defineRule } from "@oxlint/plugins";
|
|
4
|
+
import { runnerName } from "../shared/effect-runners.js";
|
|
5
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
6
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
7
|
+
// Without `@effect/vitest` the remedy is out of reach, so the rule stays silent. Not cached:
|
|
8
|
+
// a long-lived linter would miss a later install, and only test files pay for the lookup.
|
|
9
|
+
const isEffectVitestInstalled = (filename) => {
|
|
10
|
+
for (let dir = dirname(filename);; dir = dirname(dir)) {
|
|
11
|
+
if (existsSync(join(dir, "node_modules", "@effect", "vitest", "package.json")))
|
|
12
|
+
return true;
|
|
13
|
+
if (dirname(dir) === dir)
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
// Reports references too, not only calls: `const run = Effect.runPromise` escapes nothing.
|
|
18
|
+
export const preferEffectVitestRule = defineRule({
|
|
19
|
+
meta: {
|
|
20
|
+
type: "suggestion",
|
|
21
|
+
docs: {
|
|
22
|
+
description: "Run Effects in tests through `@effect/vitest`. Only test files of projects that install it are checked.",
|
|
23
|
+
},
|
|
24
|
+
messages: {
|
|
25
|
+
runInTest: "`{{name}}` runs an Effect program in a test: let `@effect/vitest` run it, so the test gets the test runtime and its fixtures become Layers.",
|
|
26
|
+
},
|
|
27
|
+
schema: [
|
|
28
|
+
{
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: { testFiles: filePatternsSchema },
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
createOnce(context) {
|
|
36
|
+
let checked = false;
|
|
37
|
+
return {
|
|
38
|
+
before() {
|
|
39
|
+
checked =
|
|
40
|
+
isTestFile(context) &&
|
|
41
|
+
isEffectVitestInstalled(resolve(context.cwd, context.filename));
|
|
42
|
+
},
|
|
43
|
+
MemberExpression(node) {
|
|
44
|
+
if (!checked)
|
|
45
|
+
return;
|
|
46
|
+
const name = runnerName(node);
|
|
47
|
+
if (name !== undefined)
|
|
48
|
+
context.report({ node, messageId: "runInTest", data: { name } });
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const provideNamedLayerRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf } from "../shared/effect-ast.js";
|
|
3
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
4
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
5
|
+
const matchers = new Set(["match", "$match"]);
|
|
6
|
+
// Factories and module members (`planLayer(csv)`, `CsvSheetReader.layer(path)`) are names;
|
|
7
|
+
// only composition written in the argument itself is reported.
|
|
8
|
+
const isInlineComposition = (layer) => {
|
|
9
|
+
if (layer?.type === "ConditionalExpression" || layer?.type === "LogicalExpression")
|
|
10
|
+
return true;
|
|
11
|
+
if (layer?.type !== "CallExpression" || layer.callee.type !== "MemberExpression")
|
|
12
|
+
return false;
|
|
13
|
+
const { callee } = layer;
|
|
14
|
+
if (callee.object.type === "Identifier" && callee.object.name === "Layer")
|
|
15
|
+
return true;
|
|
16
|
+
return (!callee.computed &&
|
|
17
|
+
callee.property.type === "Identifier" &&
|
|
18
|
+
matchers.has(callee.property.name));
|
|
19
|
+
};
|
|
20
|
+
export const provideNamedLayerRule = defineRule({
|
|
21
|
+
meta: {
|
|
22
|
+
type: "suggestion",
|
|
23
|
+
docs: {
|
|
24
|
+
description: "Provide named layers instead of composing them inside `Effect.provide`. Test files are skipped.",
|
|
25
|
+
},
|
|
26
|
+
messages: {
|
|
27
|
+
inlineLayer: "Name the layer passed to `Effect.provide` (`const appLayer = Layer.mergeAll(...)`) instead of composing it inline.",
|
|
28
|
+
},
|
|
29
|
+
schema: [
|
|
30
|
+
{
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: { testFiles: filePatternsSchema },
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
createOnce(context) {
|
|
38
|
+
let inTestFile = false;
|
|
39
|
+
return {
|
|
40
|
+
// Tests build per-test fakes inline, which is the idiomatic form there.
|
|
41
|
+
before() {
|
|
42
|
+
inTestFile = isTestFile(context);
|
|
43
|
+
},
|
|
44
|
+
CallExpression(node) {
|
|
45
|
+
if (inTestFile || calleeOf(node, "Effect") !== "provide")
|
|
46
|
+
return;
|
|
47
|
+
// Data-first `Effect.provide(effect, layer)` and data-last `Effect.provide(layer)`.
|
|
48
|
+
const layer = node.arguments.at(-1);
|
|
49
|
+
if (isInlineComposition(layer)) {
|
|
50
|
+
context.report({ node: layer, messageId: "inlineLayer" });
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const redactedSecretNamesRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, memberOf } from "../shared/effect-ast.js";
|
|
3
|
+
import { propertyName } from "../shared/tagged-values.js";
|
|
4
|
+
// A naming heuristic: `_JSON` is here because a service account key travels as inline JSON.
|
|
5
|
+
const secretName = /(KEY|SECRET|TOKEN|PASSWORD|CREDENTIALS?|_JSON)$/;
|
|
6
|
+
// No generic `token`: `nextPageToken` and `tokenType` are not secrets.
|
|
7
|
+
const defaultSecretFields = [
|
|
8
|
+
"password",
|
|
9
|
+
"secret",
|
|
10
|
+
"apiKey",
|
|
11
|
+
"accessToken",
|
|
12
|
+
"refreshToken",
|
|
13
|
+
"idToken",
|
|
14
|
+
];
|
|
15
|
+
const nullableWrappers = new Set(["optional", "optionalKey", "NullOr", "UndefinedOr", "NullishOr"]);
|
|
16
|
+
const chainMethods = new Set(["pipe", "check", "annotate"]);
|
|
17
|
+
const isRedactedSchema = (node) => node?.type === "CallExpression" &&
|
|
18
|
+
(memberOf(node.callee, "Schema")?.startsWith("Redacted") ?? false);
|
|
19
|
+
const isRedactedConfig = (node) => {
|
|
20
|
+
const name = calleeOf(node, "Config");
|
|
21
|
+
return name === "Redacted" || (name === "schema" && isRedactedSchema(node.arguments[0]));
|
|
22
|
+
};
|
|
23
|
+
const isRedactedReference = (node) => (memberOf(node, "Schema") ?? calleeOf(node, "Schema"))?.startsWith("Redacted") ?? false;
|
|
24
|
+
// `Schema.String`, `Schema.NonEmptyString.check(…)`, `Schema.optional(Schema.String)`: a schema
|
|
25
|
+
// that is visibly plain text. A named schema (`AccessToken`) may already be `Redacted`.
|
|
26
|
+
const isPlainSchema = (node) => {
|
|
27
|
+
if (node?.type === "MemberExpression") {
|
|
28
|
+
const name = memberOf(node, "Schema");
|
|
29
|
+
return name !== undefined && !name.startsWith("Redacted");
|
|
30
|
+
}
|
|
31
|
+
if (node?.type !== "CallExpression")
|
|
32
|
+
return false;
|
|
33
|
+
const wrapper = calleeOf(node, "Schema");
|
|
34
|
+
if (wrapper !== undefined)
|
|
35
|
+
return nullableWrappers.has(wrapper) && isPlainSchema(node.arguments[0]);
|
|
36
|
+
const { callee } = node;
|
|
37
|
+
if (callee.type !== "MemberExpression" ||
|
|
38
|
+
callee.computed ||
|
|
39
|
+
callee.property.type !== "Identifier" ||
|
|
40
|
+
!chainMethods.has(callee.property.name)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return !node.arguments.some(isRedactedReference) && isPlainSchema(callee.object);
|
|
44
|
+
};
|
|
45
|
+
// `Schema.Struct({…})` and `Schema.Class<X>("X")({…})`.
|
|
46
|
+
const fieldsOf = (node) => {
|
|
47
|
+
const isStruct = calleeOf(node, "Schema") === "Struct";
|
|
48
|
+
const isClass = calleeOf(node.callee, "Schema") === "Class";
|
|
49
|
+
const fields = isStruct || isClass ? node.arguments[0] : undefined;
|
|
50
|
+
return fields?.type === "ObjectExpression" ? fields : undefined;
|
|
51
|
+
};
|
|
52
|
+
// `password` matches `password` and `dbPassword`, not `passwordPolicy`.
|
|
53
|
+
const matchesSecretField = (name, secretFields) => secretFields.some((field) => name === field || name.endsWith(`${field.charAt(0).toUpperCase()}${field.slice(1)}`));
|
|
54
|
+
const configuredSecretFields = (options) => {
|
|
55
|
+
const [first] = options;
|
|
56
|
+
if (typeof first !== "object" || first === null || !("secretFields" in first)) {
|
|
57
|
+
return defaultSecretFields;
|
|
58
|
+
}
|
|
59
|
+
const names = first.secretFields;
|
|
60
|
+
return Array.isArray(names) ? names.filter((name) => typeof name === "string") : [];
|
|
61
|
+
};
|
|
62
|
+
export const redactedSecretNamesRule = defineRule({
|
|
63
|
+
meta: {
|
|
64
|
+
type: "suggestion",
|
|
65
|
+
docs: {
|
|
66
|
+
description: "Read configuration values and declare Schema fields whose name looks like a secret as `Redacted`.",
|
|
67
|
+
},
|
|
68
|
+
messages: {
|
|
69
|
+
notRedacted: "`{{name}}` looks like a secret: read it with `Config.Redacted` or `Config.schema(Schema.Redacted(...), name)`.",
|
|
70
|
+
fieldNotRedacted: "`{{name}}` looks like a secret: declare it as `Schema.Redacted(...)` so it stays out of logs.",
|
|
71
|
+
},
|
|
72
|
+
schema: [
|
|
73
|
+
{
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {
|
|
76
|
+
secretFields: { type: "array", items: { type: "string" }, uniqueItems: true },
|
|
77
|
+
},
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
createOnce(context) {
|
|
83
|
+
let secretFields = defaultSecretFields;
|
|
84
|
+
const checkConfig = (node) => {
|
|
85
|
+
if (calleeOf(node, "Config") === undefined || isRedactedConfig(node))
|
|
86
|
+
return;
|
|
87
|
+
for (const argument of node.arguments) {
|
|
88
|
+
if (argument.type === "Literal" &&
|
|
89
|
+
typeof argument.value === "string" &&
|
|
90
|
+
secretName.test(argument.value)) {
|
|
91
|
+
context.report({
|
|
92
|
+
node: argument,
|
|
93
|
+
messageId: "notRedacted",
|
|
94
|
+
data: { name: argument.value },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const checkSchemaFields = (node) => {
|
|
100
|
+
for (const property of fieldsOf(node)?.properties ?? []) {
|
|
101
|
+
if (property.type !== "Property")
|
|
102
|
+
continue;
|
|
103
|
+
const name = propertyName(property);
|
|
104
|
+
if (name === undefined || !matchesSecretField(name, secretFields))
|
|
105
|
+
continue;
|
|
106
|
+
if (!isPlainSchema(property.value))
|
|
107
|
+
continue;
|
|
108
|
+
context.report({
|
|
109
|
+
node: property.key,
|
|
110
|
+
messageId: "fieldNotRedacted",
|
|
111
|
+
data: { name },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
before() {
|
|
117
|
+
secretFields = configuredSecretFields(context.options);
|
|
118
|
+
},
|
|
119
|
+
CallExpression(node) {
|
|
120
|
+
checkConfig(node);
|
|
121
|
+
checkSchemaFields(node);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const runOnlyInRunnerRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { runnerName } from "../shared/effect-runners.js";
|
|
3
|
+
import { isEntryFile } from "../shared/entry-files.js";
|
|
4
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
5
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
6
|
+
// Reports references too, not only calls: `const run = Effect.runPromise` escapes nothing.
|
|
7
|
+
export const runOnlyInRunnerRule = defineRule({
|
|
8
|
+
meta: {
|
|
9
|
+
type: "problem",
|
|
10
|
+
docs: {
|
|
11
|
+
description: "Run Effect programs only at the entry point. Entry and test files are skipped.",
|
|
12
|
+
},
|
|
13
|
+
messages: {
|
|
14
|
+
runOutsideEntry: "`{{name}}` runs an Effect program: only the entry point may do that.",
|
|
15
|
+
},
|
|
16
|
+
schema: [
|
|
17
|
+
{
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: { entryFiles: filePatternsSchema, testFiles: filePatternsSchema },
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
createOnce(context) {
|
|
25
|
+
let skipped = false;
|
|
26
|
+
return {
|
|
27
|
+
// How tests run their Effects is `prefer-effect-vitest`'s concern.
|
|
28
|
+
before() {
|
|
29
|
+
skipped = isEntryFile(context) || isTestFile(context);
|
|
30
|
+
},
|
|
31
|
+
MemberExpression(node) {
|
|
32
|
+
if (skipped)
|
|
33
|
+
return;
|
|
34
|
+
const name = runnerName(node);
|
|
35
|
+
if (name !== undefined)
|
|
36
|
+
context.report({ node, messageId: "runOutsideEntry", data: { name } });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const serviceBuiltWithOfRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf, isFunctionNode } from "../shared/effect-ast.js";
|
|
3
|
+
const layerConstructors = new Set(["succeed", "sync", "effect", "scoped"]);
|
|
4
|
+
// `Layer.succeed(T, impl)` and the curried `Layer.succeed(T)(impl)`.
|
|
5
|
+
const layerApplication = (node) => {
|
|
6
|
+
const dataFirst = calleeOf(node, "Layer");
|
|
7
|
+
if (dataFirst !== undefined && node.arguments.length === 2) {
|
|
8
|
+
return { constructor: dataFirst, tag: node.arguments[0], impl: node.arguments[1] };
|
|
9
|
+
}
|
|
10
|
+
const curried = node.callee.type === "CallExpression" ? calleeOf(node.callee, "Layer") : undefined;
|
|
11
|
+
if (curried !== undefined && node.callee.type === "CallExpression") {
|
|
12
|
+
return { constructor: curried, tag: node.callee.arguments[0], impl: node.arguments[0] };
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
};
|
|
16
|
+
const returnedObjects = (fn) => {
|
|
17
|
+
if (fn.body?.type === "ObjectExpression")
|
|
18
|
+
return [fn.body];
|
|
19
|
+
if (fn.body?.type !== "BlockStatement")
|
|
20
|
+
return [];
|
|
21
|
+
return fn.body.body.flatMap((statement) => statement.type === "ReturnStatement" && statement.argument?.type === "ObjectExpression"
|
|
22
|
+
? [statement.argument]
|
|
23
|
+
: []);
|
|
24
|
+
};
|
|
25
|
+
const implementationObjects = (constructor, impl) => {
|
|
26
|
+
if (constructor === "succeed")
|
|
27
|
+
return impl?.type === "ObjectExpression" ? [impl] : [];
|
|
28
|
+
if (constructor === "sync")
|
|
29
|
+
return isFunctionNode(impl) ? returnedObjects(impl) : [];
|
|
30
|
+
if (impl?.type !== "CallExpression" || calleeOf(impl, "Effect") !== "gen")
|
|
31
|
+
return [];
|
|
32
|
+
return impl.arguments.flatMap((argument) => isFunctionNode(argument) && argument.generator ? returnedObjects(argument) : []);
|
|
33
|
+
};
|
|
34
|
+
export const serviceBuiltWithOfRule = defineRule({
|
|
35
|
+
meta: {
|
|
36
|
+
type: "suggestion",
|
|
37
|
+
docs: {
|
|
38
|
+
description: "Build the service a Layer provides with `Tag.of({…})` instead of a bare object literal.",
|
|
39
|
+
},
|
|
40
|
+
hasSuggestions: true,
|
|
41
|
+
messages: {
|
|
42
|
+
bareLiteral: "Build the service with `{{tag}}.of({…})`: it names the service the object implements and keeps its type checked if the literal moves out of the Layer.",
|
|
43
|
+
wrapWithOf: "Wrap the literal in `{{tag}}.of(…)`.",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
createOnce(context) {
|
|
47
|
+
return {
|
|
48
|
+
CallExpression(node) {
|
|
49
|
+
const application = layerApplication(node);
|
|
50
|
+
if (application === undefined || !layerConstructors.has(application.constructor)) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const { tag, impl } = application;
|
|
54
|
+
if (tag === undefined || tag.type === "SpreadElement")
|
|
55
|
+
return;
|
|
56
|
+
const tagText = context.sourceCode.getText(tag);
|
|
57
|
+
for (const literal of implementationObjects(application.constructor, impl)) {
|
|
58
|
+
context.report({
|
|
59
|
+
node: literal,
|
|
60
|
+
messageId: "bareLiteral",
|
|
61
|
+
data: { tag: tagText },
|
|
62
|
+
suggest: [
|
|
63
|
+
{
|
|
64
|
+
messageId: "wrapWithOf",
|
|
65
|
+
data: { tag: tagText },
|
|
66
|
+
fix: (fixer) => fixer.replaceText(literal, `${tagText}.of(${context.sourceCode.getText(literal)})`),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const serviceKeyMatchesNameRule: import("@oxlint/plugins").Rule;
|