@doubleloop-io/oxlint-plugin-effect 0.0.0-stage → 0.1.0-rc.0
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 +210 -2
- package/RULES.md +911 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +71 -0
- package/dist/presets/all.d.ts +7 -0
- package/dist/presets/all.js +9 -0
- package/dist/presets/all.json +37 -0
- package/dist/rules/adapter-import-boundary.d.ts +7 -0
- package/dist/rules/adapter-import-boundary.js +103 -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/boundary-entrypoint-shape.d.ts +1 -0
- package/dist/rules/boundary-entrypoint-shape.js +52 -0
- package/dist/rules/error-handling-in-effect-fn-handler.d.ts +1 -0
- package/dist/rules/error-handling-in-effect-fn-handler.js +66 -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 +34 -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 +58 -0
- package/dist/rules/no-manual-discriminant-construction.d.ts +2 -0
- package/dist/rules/no-manual-discriminant-construction.js +90 -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 +94 -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/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 +127 -0
- package/dist/rules/run-only-at-entry.d.ts +1 -0
- package/dist/rules/run-only-at-entry.js +50 -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 +77 -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 +9 -0
- package/dist/shared/effect-ast.js +52 -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 +56 -5
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { eslintCompatPlugin } from "@oxlint/plugins";
|
|
3
|
+
import { adapterImportBoundaryRule } from "./rules/adapter-import-boundary.js";
|
|
4
|
+
import { antiSlopNoManualEffectErrorTagRule } from "./rules/anti-slop-no-manual-effect-error-tag.js";
|
|
5
|
+
import { antiSlopNoServiceConstructorImportsRule } from "./rules/anti-slop-no-service-constructor-imports.js";
|
|
6
|
+
import { antiSlopPreferEffectMatchRule } from "./rules/anti-slop-prefer-effect-match.js";
|
|
7
|
+
import { boundaryEntrypointShapeRule } from "./rules/boundary-entrypoint-shape.js";
|
|
8
|
+
import { errorHandlingInEffectFnHandlerRule } from "./rules/error-handling-in-effect-fn-handler.js";
|
|
9
|
+
import { exportedSchemaIdentifierRule } from "./rules/exported-schema-identifier.js";
|
|
10
|
+
import { noAsymmetricMatchHandlersRule } from "./rules/no-asymmetric-match-handlers.js";
|
|
11
|
+
import { noBareErrorInEffectRule } from "./rules/no-bare-error-in-effect.js";
|
|
12
|
+
import { noEffectPromiseRule } from "./rules/no-effect-promise.js";
|
|
13
|
+
import { noEffectReturnTypeRule } from "./rules/no-effect-return-type.js";
|
|
14
|
+
import { noInlineSchemaCodecRule } from "./rules/no-inline-schema-codec.js";
|
|
15
|
+
import { noInlineYieldStarRule } from "./rules/no-inline-yield-star.js";
|
|
16
|
+
import { noManualDiscriminantComparisonRule } from "./rules/no-manual-discriminant-comparison.js";
|
|
17
|
+
import { noManualDiscriminantConstructionRule } from "./rules/no-manual-discriminant-construction.js";
|
|
18
|
+
import { noNestedMatchRule } from "./rules/no-nested-match.js";
|
|
19
|
+
import { noParameterlessCatchAllRule } from "./rules/no-parameterless-catch-all.js";
|
|
20
|
+
import { noProvideOnDefinitionRule } from "./rules/no-provide-on-definition.js";
|
|
21
|
+
import { noTransportTypesInGeneratorRule } from "./rules/no-transport-types-in-generator.js";
|
|
22
|
+
import { noYieldedMatchRule } from "./rules/no-yielded-match.js";
|
|
23
|
+
import { preferEffectFnRule } from "./rules/prefer-effect-fn.js";
|
|
24
|
+
import { provideNamedLayerRule } from "./rules/provide-named-layer.js";
|
|
25
|
+
import { redactedSecretNamesRule } from "./rules/redacted-secret-names.js";
|
|
26
|
+
import { runOnlyAtEntryRule } from "./rules/run-only-at-entry.js";
|
|
27
|
+
import { serviceBuiltWithOfRule } from "./rules/service-built-with-of.js";
|
|
28
|
+
import { serviceKeyMatchesNameRule } from "./rules/service-key-matches-name.js";
|
|
29
|
+
import { servicePortShapeOnlyRule } from "./rules/service-port-shape-only.js";
|
|
30
|
+
import { singleStylePerFunctionRule } from "./rules/single-style-per-function.js";
|
|
31
|
+
import { tryObjectFormRule } from "./rules/try-object-form.js";
|
|
32
|
+
import { untracedRecursionRule } from "./rules/untraced-recursion.js";
|
|
33
|
+
export const pluginName = "dl-effect";
|
|
34
|
+
// Resolved at runtime so it points at this file both from the sources and from `dist/`.
|
|
35
|
+
export const pluginPath = fileURLToPath(import.meta.url);
|
|
36
|
+
const dlEffectPlugin = eslintCompatPlugin({
|
|
37
|
+
meta: { name: pluginName },
|
|
38
|
+
rules: {
|
|
39
|
+
"anti-slop-no-manual-effect-error-tag": antiSlopNoManualEffectErrorTagRule,
|
|
40
|
+
"anti-slop-no-service-constructor-imports": antiSlopNoServiceConstructorImportsRule,
|
|
41
|
+
"anti-slop-prefer-effect-match": antiSlopPreferEffectMatchRule,
|
|
42
|
+
"adapter-import-boundary": adapterImportBoundaryRule,
|
|
43
|
+
"boundary-entrypoint-shape": boundaryEntrypointShapeRule,
|
|
44
|
+
"error-handling-in-effect-fn-handler": errorHandlingInEffectFnHandlerRule,
|
|
45
|
+
"exported-schema-identifier": exportedSchemaIdentifierRule,
|
|
46
|
+
"no-asymmetric-match-handlers": noAsymmetricMatchHandlersRule,
|
|
47
|
+
"no-bare-error-in-effect": noBareErrorInEffectRule,
|
|
48
|
+
"no-effect-promise": noEffectPromiseRule,
|
|
49
|
+
"no-effect-return-type": noEffectReturnTypeRule,
|
|
50
|
+
"no-inline-schema-codec": noInlineSchemaCodecRule,
|
|
51
|
+
"no-inline-yield-star": noInlineYieldStarRule,
|
|
52
|
+
"no-manual-discriminant-comparison": noManualDiscriminantComparisonRule,
|
|
53
|
+
"no-manual-discriminant-construction": noManualDiscriminantConstructionRule,
|
|
54
|
+
"no-nested-match": noNestedMatchRule,
|
|
55
|
+
"no-parameterless-catch-all": noParameterlessCatchAllRule,
|
|
56
|
+
"no-provide-on-definition": noProvideOnDefinitionRule,
|
|
57
|
+
"no-transport-types-in-generator": noTransportTypesInGeneratorRule,
|
|
58
|
+
"no-yielded-match": noYieldedMatchRule,
|
|
59
|
+
"prefer-effect-fn": preferEffectFnRule,
|
|
60
|
+
"provide-named-layer": provideNamedLayerRule,
|
|
61
|
+
"redacted-secret-names": redactedSecretNamesRule,
|
|
62
|
+
"run-only-at-entry": runOnlyAtEntryRule,
|
|
63
|
+
"service-built-with-of": serviceBuiltWithOfRule,
|
|
64
|
+
"service-key-matches-name": serviceKeyMatchesNameRule,
|
|
65
|
+
"service-port-shape-only": servicePortShapeOnlyRule,
|
|
66
|
+
"single-style-per-function": singleStylePerFunctionRule,
|
|
67
|
+
"try-object-form": tryObjectFormRule,
|
|
68
|
+
"untraced-recursion": untracedRecursionRule,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
export default dlEffectPlugin;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import dlEffectPlugin, { pluginName, pluginPath } from "../index.js";
|
|
2
|
+
const allRules = Object.fromEntries(Object.keys(dlEffectPlugin.rules).map((rule) => [`${pluginName}/${rule}`, "warn"]));
|
|
3
|
+
// Absolute because an imported config object carries no location of its own: a relative
|
|
4
|
+
// path would resolve against the consumer's config file, not this package.
|
|
5
|
+
const all = {
|
|
6
|
+
jsPlugins: [pluginPath],
|
|
7
|
+
rules: allRules,
|
|
8
|
+
};
|
|
9
|
+
export default all;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"jsPlugins": [
|
|
3
|
+
"../index.js"
|
|
4
|
+
],
|
|
5
|
+
"rules": {
|
|
6
|
+
"dl-effect/anti-slop-no-manual-effect-error-tag": "warn",
|
|
7
|
+
"dl-effect/anti-slop-no-service-constructor-imports": "warn",
|
|
8
|
+
"dl-effect/anti-slop-prefer-effect-match": "warn",
|
|
9
|
+
"dl-effect/adapter-import-boundary": "warn",
|
|
10
|
+
"dl-effect/boundary-entrypoint-shape": "warn",
|
|
11
|
+
"dl-effect/error-handling-in-effect-fn-handler": "warn",
|
|
12
|
+
"dl-effect/exported-schema-identifier": "warn",
|
|
13
|
+
"dl-effect/no-asymmetric-match-handlers": "warn",
|
|
14
|
+
"dl-effect/no-bare-error-in-effect": "warn",
|
|
15
|
+
"dl-effect/no-effect-promise": "warn",
|
|
16
|
+
"dl-effect/no-effect-return-type": "warn",
|
|
17
|
+
"dl-effect/no-inline-schema-codec": "warn",
|
|
18
|
+
"dl-effect/no-inline-yield-star": "warn",
|
|
19
|
+
"dl-effect/no-manual-discriminant-comparison": "warn",
|
|
20
|
+
"dl-effect/no-manual-discriminant-construction": "warn",
|
|
21
|
+
"dl-effect/no-nested-match": "warn",
|
|
22
|
+
"dl-effect/no-parameterless-catch-all": "warn",
|
|
23
|
+
"dl-effect/no-provide-on-definition": "warn",
|
|
24
|
+
"dl-effect/no-transport-types-in-generator": "warn",
|
|
25
|
+
"dl-effect/no-yielded-match": "warn",
|
|
26
|
+
"dl-effect/prefer-effect-fn": "warn",
|
|
27
|
+
"dl-effect/provide-named-layer": "warn",
|
|
28
|
+
"dl-effect/redacted-secret-names": "warn",
|
|
29
|
+
"dl-effect/run-only-at-entry": "warn",
|
|
30
|
+
"dl-effect/service-built-with-of": "warn",
|
|
31
|
+
"dl-effect/service-key-matches-name": "warn",
|
|
32
|
+
"dl-effect/service-port-shape-only": "warn",
|
|
33
|
+
"dl-effect/single-style-per-function": "warn",
|
|
34
|
+
"dl-effect/try-object-form": "warn",
|
|
35
|
+
"dl-effect/untraced-recursion": "warn"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
type Deps = {
|
|
2
|
+
readonly isAdapterDir: (dir: string) => boolean;
|
|
3
|
+
};
|
|
4
|
+
export declare const buildsLayer: (source: string) => boolean;
|
|
5
|
+
export declare const makeAdapterImportBoundaryRule: (deps: Deps) => import("@oxlint/plugins").Rule;
|
|
6
|
+
export declare const adapterImportBoundaryRule: import("@oxlint/plugins").Rule;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { defineRule } from "@oxlint/plugins";
|
|
4
|
+
import { isEntryFile } from "../shared/entry-files.js";
|
|
5
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
6
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
7
|
+
// Only the APIs that build a service implementation, so composition of existing layers
|
|
8
|
+
// (`mergeAll`, `provide`, `merge`) leaves the entry point out. Longest names first: the
|
|
9
|
+
// alternation is ordered, and `effect` would otherwise shadow `effectContext`.
|
|
10
|
+
// `scoped*`, `context`, `service` and `function` exist in Effect 3 only; `unwrap` and
|
|
11
|
+
// `mock` in Effect 4 only. Naming the API instead of the binding keeps `FooLive` covered.
|
|
12
|
+
const LAYER_CONSTRUCTORS = [
|
|
13
|
+
"effectContext",
|
|
14
|
+
"effectDiscard",
|
|
15
|
+
"succeedContext",
|
|
16
|
+
"scopedContext",
|
|
17
|
+
"scopedDiscard",
|
|
18
|
+
"syncContext",
|
|
19
|
+
"function",
|
|
20
|
+
"service",
|
|
21
|
+
"context",
|
|
22
|
+
"succeed",
|
|
23
|
+
"scoped",
|
|
24
|
+
"effect",
|
|
25
|
+
"unwrap",
|
|
26
|
+
"sync",
|
|
27
|
+
"mock",
|
|
28
|
+
];
|
|
29
|
+
// Like the other rules, names are matched literally: `import * as L from "effect/Layer"` escapes.
|
|
30
|
+
const LAYER_CONSTRUCTION = new RegExp(`\\bLayer\\.(?:${LAYER_CONSTRUCTORS.join("|")})\\s*\\(`, "u");
|
|
31
|
+
export const buildsLayer = (source) => LAYER_CONSTRUCTION.test(source);
|
|
32
|
+
// Test files are skipped so a fixture layer cannot promote a domain directory to an adapter.
|
|
33
|
+
const isSource = (entry) => entry.endsWith(".ts") && !entry.endsWith(".test.ts");
|
|
34
|
+
const buildsLayerIn = (dir) => {
|
|
35
|
+
try {
|
|
36
|
+
return readdirSync(dir)
|
|
37
|
+
.filter(isSource)
|
|
38
|
+
.some((entry) => buildsLayer(readFileSync(resolve(dir, entry), "utf8")));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const cachedBuildsLayerIn = () => {
|
|
45
|
+
const known = new Map();
|
|
46
|
+
return (dir) => {
|
|
47
|
+
const seen = known.get(dir);
|
|
48
|
+
if (seen !== undefined)
|
|
49
|
+
return seen;
|
|
50
|
+
const verdict = buildsLayerIn(dir);
|
|
51
|
+
known.set(dir, verdict);
|
|
52
|
+
return verdict;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
const isRelativeImport = (source) => source.startsWith("./") || source.startsWith("../");
|
|
56
|
+
const toPosix = (path) => path.split(sep).join("/");
|
|
57
|
+
export const makeAdapterImportBoundaryRule = (deps) => defineRule({
|
|
58
|
+
meta: {
|
|
59
|
+
type: "problem",
|
|
60
|
+
docs: {
|
|
61
|
+
description: "Import adapters only at the entry point, and never from another adapter. Entry and test files are skipped.",
|
|
62
|
+
},
|
|
63
|
+
messages: {
|
|
64
|
+
adapterOutsideEntry: "`{{adapter}}` is an adapter: depend on its port instead and let the entry point provide the Layer.",
|
|
65
|
+
crossAdapter: "`{{adapter}}` is another adapter: depend on its port instead and let the entry point compose the Layers.",
|
|
66
|
+
},
|
|
67
|
+
schema: [
|
|
68
|
+
{
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
entryFiles: filePatternsSchema,
|
|
72
|
+
testFiles: filePatternsSchema,
|
|
73
|
+
},
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
create(context) {
|
|
79
|
+
const ownDir = dirname(context.filename);
|
|
80
|
+
// A test composes the Layers it exercises, so it is an entry point too.
|
|
81
|
+
const composesLayers = isEntryFile(context) || isTestFile(context);
|
|
82
|
+
return {
|
|
83
|
+
ImportDeclaration(node) {
|
|
84
|
+
const source = node.source.value;
|
|
85
|
+
if (composesLayers || !isRelativeImport(source))
|
|
86
|
+
return;
|
|
87
|
+
const targetDir = dirname(resolve(ownDir, source));
|
|
88
|
+
if (targetDir === ownDir || !deps.isAdapterDir(targetDir))
|
|
89
|
+
return;
|
|
90
|
+
context.report({
|
|
91
|
+
node,
|
|
92
|
+
messageId: deps.isAdapterDir(ownDir)
|
|
93
|
+
? "crossAdapter"
|
|
94
|
+
: "adapterOutsideEntry",
|
|
95
|
+
data: { adapter: toPosix(relative(context.cwd, targetDir)) },
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
export const adapterImportBoundaryRule = makeAdapterImportBoundaryRule({
|
|
102
|
+
isAdapterDir: cachedBuildsLayerIn(),
|
|
103
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const antiSlopNoManualEffectErrorTagRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { isInsideBroadEffectHandler, isReasonTagMember, isTagMember, tagMemberFromComparison, } from "../shared/tagged-values.js";
|
|
3
|
+
export const antiSlopNoManualEffectErrorTagRule = defineRule({
|
|
4
|
+
meta: {
|
|
5
|
+
type: "problem",
|
|
6
|
+
docs: {
|
|
7
|
+
description: "Use Effect tagged error handlers instead of manually branching on `_tag` in a catch handler.",
|
|
8
|
+
},
|
|
9
|
+
messages: {
|
|
10
|
+
tag: "Use Effect.catchTag or Effect.catchTags instead of manually discriminating a tagged error in a broad Effect catch handler.",
|
|
11
|
+
reason: "Use Effect.catchReason or Effect.catchReasons instead of manually discriminating a tagged `reason` in a broad Effect catch handler.",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
createOnce(context) {
|
|
15
|
+
return {
|
|
16
|
+
BinaryExpression(node) {
|
|
17
|
+
const tagMember = tagMemberFromComparison(node);
|
|
18
|
+
if (tagMember === undefined || !isInsideBroadEffectHandler(node))
|
|
19
|
+
return;
|
|
20
|
+
context.report({ node, messageId: isReasonTagMember(tagMember) ? "reason" : "tag" });
|
|
21
|
+
},
|
|
22
|
+
SwitchStatement(node) {
|
|
23
|
+
if (!isTagMember(node.discriminant) || !isInsideBroadEffectHandler(node))
|
|
24
|
+
return;
|
|
25
|
+
context.report({
|
|
26
|
+
node,
|
|
27
|
+
messageId: isReasonTagMember(node.discriminant) ? "reason" : "tag",
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const antiSlopNoServiceConstructorImportsRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
3
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
4
|
+
const serviceConstructorName = /^make[A-Z]/u;
|
|
5
|
+
const isProjectLocalImport = (source) => source.startsWith("./") || source.startsWith("../");
|
|
6
|
+
const importedName = (specifier) => specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
|
|
7
|
+
export const antiSlopNoServiceConstructorImportsRule = defineRule({
|
|
8
|
+
meta: {
|
|
9
|
+
type: "problem",
|
|
10
|
+
docs: {
|
|
11
|
+
description: "Disallow project-local make<CapabilityName> imports outside test files.",
|
|
12
|
+
},
|
|
13
|
+
messages: {
|
|
14
|
+
serviceConstructorImport: 'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.',
|
|
15
|
+
},
|
|
16
|
+
schema: [
|
|
17
|
+
{
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: { testFiles: filePatternsSchema },
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
createOnce(context) {
|
|
25
|
+
let inTestFile = false;
|
|
26
|
+
return {
|
|
27
|
+
// Tests build services from their constructors to inject fakes.
|
|
28
|
+
before() {
|
|
29
|
+
inTestFile = isTestFile(context);
|
|
30
|
+
},
|
|
31
|
+
ImportDeclaration(node) {
|
|
32
|
+
if (inTestFile || !isProjectLocalImport(node.source.value))
|
|
33
|
+
return;
|
|
34
|
+
for (const specifier of node.specifiers) {
|
|
35
|
+
if (specifier.type !== "ImportSpecifier")
|
|
36
|
+
continue;
|
|
37
|
+
const name = importedName(specifier);
|
|
38
|
+
if (!serviceConstructorName.test(name))
|
|
39
|
+
continue;
|
|
40
|
+
context.report({
|
|
41
|
+
node: specifier,
|
|
42
|
+
messageId: "serviceConstructorImport",
|
|
43
|
+
data: { name },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const antiSlopPreferEffectMatchRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { isEqualityComparison } from "../shared/tagged-values.js";
|
|
3
|
+
const isLiteral = (node) => node.type === "Literal" || (node.type === "TemplateLiteral" && node.expressions.length === 0);
|
|
4
|
+
export const antiSlopPreferEffectMatchRule = defineRule({
|
|
5
|
+
meta: {
|
|
6
|
+
type: "problem",
|
|
7
|
+
docs: {
|
|
8
|
+
description: "Use Match from Effect for chained literal ternaries over the same value.",
|
|
9
|
+
},
|
|
10
|
+
messages: {
|
|
11
|
+
preferMatch: "Use Match from Effect instead of a chained literal ternary.",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
createOnce(context) {
|
|
15
|
+
// The compared value is identified by its source text: no type or binding analysis.
|
|
16
|
+
const comparedValue = (node) => {
|
|
17
|
+
if (!isEqualityComparison(node))
|
|
18
|
+
return undefined;
|
|
19
|
+
if (isLiteral(node.left))
|
|
20
|
+
return context.sourceCode.getText(node.right);
|
|
21
|
+
if (isLiteral(node.right))
|
|
22
|
+
return context.sourceCode.getText(node.left);
|
|
23
|
+
return undefined;
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
ConditionalExpression(node) {
|
|
27
|
+
if (node.parent?.type === "ConditionalExpression")
|
|
28
|
+
return;
|
|
29
|
+
const value = comparedValue(node.test);
|
|
30
|
+
if (value === undefined)
|
|
31
|
+
return;
|
|
32
|
+
let alternate = node.alternate;
|
|
33
|
+
let literalChecks = 1;
|
|
34
|
+
while (alternate.type === "ConditionalExpression") {
|
|
35
|
+
if (comparedValue(alternate.test) !== value)
|
|
36
|
+
return;
|
|
37
|
+
literalChecks += 1;
|
|
38
|
+
alternate = alternate.alternate;
|
|
39
|
+
}
|
|
40
|
+
if (literalChecks > 1)
|
|
41
|
+
context.report({ node, messageId: "preferMatch" });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const boundaryEntrypointShapeRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const errorHandlingInEffectFnHandlerRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { isInsideEffectGeneratorBody, memberOf } from "../shared/effect-ast.js";
|
|
3
|
+
import { filePatternsSchema } from "../shared/file-globs.js";
|
|
4
|
+
import { isTestFile } from "../shared/test-files.js";
|
|
5
|
+
// mapError, orElse and orElseSucceed stay allowed: they name a failure or give an alternative
|
|
6
|
+
// without opening a handling branch inside the success path.
|
|
7
|
+
const errorHandlers = new Set([
|
|
8
|
+
"catch",
|
|
9
|
+
"catchAll",
|
|
10
|
+
"catchCause",
|
|
11
|
+
"catchCauseFilter",
|
|
12
|
+
"catchCauseIf",
|
|
13
|
+
"catchDefect",
|
|
14
|
+
"catchEager",
|
|
15
|
+
"catchFilter",
|
|
16
|
+
"catchIf",
|
|
17
|
+
"catchReason",
|
|
18
|
+
"catchReasons",
|
|
19
|
+
"catchTag",
|
|
20
|
+
"catchTags",
|
|
21
|
+
"either",
|
|
22
|
+
"exit",
|
|
23
|
+
"match",
|
|
24
|
+
"matchCause",
|
|
25
|
+
"matchCauseEffect",
|
|
26
|
+
"matchEffect",
|
|
27
|
+
"result",
|
|
28
|
+
"sandbox",
|
|
29
|
+
]);
|
|
30
|
+
export const errorHandlingInEffectFnHandlerRule = defineRule({
|
|
31
|
+
meta: {
|
|
32
|
+
type: "suggestion",
|
|
33
|
+
docs: {
|
|
34
|
+
description: "Keep only the success path in Effect generators and handle errors in the second argument of `Effect.fn`. Test files are skipped.",
|
|
35
|
+
},
|
|
36
|
+
messages: {
|
|
37
|
+
handlerInGenerator: "Move `Effect.{{name}}` out of the generator: keep the success path in the body and handle errors in the pipeable arguments of `Effect.fn`.",
|
|
38
|
+
},
|
|
39
|
+
schema: [
|
|
40
|
+
{
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: { testFiles: filePatternsSchema },
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
createOnce(context) {
|
|
48
|
+
let inTestFile = false;
|
|
49
|
+
return {
|
|
50
|
+
// In a test the failure is the observed outcome, not a branch of the success path.
|
|
51
|
+
before() {
|
|
52
|
+
inTestFile = isTestFile(context);
|
|
53
|
+
},
|
|
54
|
+
MemberExpression(node) {
|
|
55
|
+
const name = memberOf(node, "Effect");
|
|
56
|
+
if (inTestFile ||
|
|
57
|
+
name === undefined ||
|
|
58
|
+
!errorHandlers.has(name) ||
|
|
59
|
+
!isInsideEffectGeneratorBody(node)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
context.report({ node, messageId: "handlerInGenerator", data: { name } });
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const exportedSchemaIdentifierRule: import("@oxlint/plugins").Rule;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { calleeOf } from "../shared/effect-ast.js";
|
|
3
|
+
// `Schema.Class` and `Schema.TaggedError` carry their name already.
|
|
4
|
+
const anonymousSchemas = new Set(["Struct", "Union", "Array"]);
|
|
5
|
+
const chainedMethod = (node) => node.type === "CallExpression" &&
|
|
6
|
+
node.callee.type === "MemberExpression" &&
|
|
7
|
+
!node.callee.computed &&
|
|
8
|
+
node.callee.property.type === "Identifier"
|
|
9
|
+
? { name: node.callee.property.name, receiver: node.callee.object, call: node }
|
|
10
|
+
: undefined;
|
|
11
|
+
// `X.pipe(Schema.annotate(a))` and `X.annotate(a)`, in any order along the chain.
|
|
12
|
+
const annotationsOf = (init) => {
|
|
13
|
+
const annotations = [];
|
|
14
|
+
let current = init;
|
|
15
|
+
while (calleeOf(current, "Schema") === undefined) {
|
|
16
|
+
const link = chainedMethod(current);
|
|
17
|
+
if (link === undefined)
|
|
18
|
+
return undefined;
|
|
19
|
+
if (link.name === "annotate")
|
|
20
|
+
annotations.push(...link.call.arguments);
|
|
21
|
+
else if (link.name === "pipe") {
|
|
22
|
+
for (const argument of link.call.arguments) {
|
|
23
|
+
if (calleeOf(argument, "Schema") === "annotate" &&
|
|
24
|
+
argument.type === "CallExpression") {
|
|
25
|
+
annotations.push(...argument.arguments);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else if (link.name !== "check")
|
|
30
|
+
return undefined;
|
|
31
|
+
current = link.receiver;
|
|
32
|
+
}
|
|
33
|
+
const root = calleeOf(current, "Schema");
|
|
34
|
+
return root !== undefined && anonymousSchemas.has(root) ? annotations : undefined;
|
|
35
|
+
};
|
|
36
|
+
const identifierOf = (annotations) => {
|
|
37
|
+
for (const annotation of annotations) {
|
|
38
|
+
if (annotation.type !== "ObjectExpression")
|
|
39
|
+
continue;
|
|
40
|
+
for (const property of annotation.properties) {
|
|
41
|
+
if (property.type === "Property" &&
|
|
42
|
+
!property.computed &&
|
|
43
|
+
((property.key.type === "Identifier" && property.key.name === "identifier") ||
|
|
44
|
+
(property.key.type === "Literal" && property.key.value === "identifier"))) {
|
|
45
|
+
return property.value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
};
|
|
51
|
+
export const exportedSchemaIdentifierRule = defineRule({
|
|
52
|
+
meta: {
|
|
53
|
+
type: "suggestion",
|
|
54
|
+
docs: {
|
|
55
|
+
description: "Annotate every exported `Schema.Struct`, `Schema.Union` and `Schema.Array` with an `identifier` equal to its name.",
|
|
56
|
+
},
|
|
57
|
+
hasSuggestions: true,
|
|
58
|
+
messages: {
|
|
59
|
+
missingIdentifier: 'Annotate `{{name}}` with `identifier: "{{name}}"`, so errors, JSON Schema and arbitraries name it.',
|
|
60
|
+
wrongIdentifier: 'The `identifier` of `{{name}}` is `{{identifier}}`: use `"{{name}}"`, the name the schema is imported by.',
|
|
61
|
+
addIdentifier: 'Append `.annotate({ identifier: "{{name}}" })`.',
|
|
62
|
+
renameIdentifier: 'Use `"{{name}}"` as identifier.',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
createOnce(context) {
|
|
66
|
+
const check = (declarator) => {
|
|
67
|
+
const { id, init } = declarator;
|
|
68
|
+
if (id.type !== "Identifier" || !init)
|
|
69
|
+
return;
|
|
70
|
+
const annotations = annotationsOf(init);
|
|
71
|
+
if (annotations === undefined)
|
|
72
|
+
return;
|
|
73
|
+
const name = id.name;
|
|
74
|
+
const identifier = identifierOf(annotations);
|
|
75
|
+
if (identifier === undefined) {
|
|
76
|
+
context.report({
|
|
77
|
+
node: id,
|
|
78
|
+
messageId: "missingIdentifier",
|
|
79
|
+
data: { name },
|
|
80
|
+
suggest: [
|
|
81
|
+
{
|
|
82
|
+
messageId: "addIdentifier",
|
|
83
|
+
data: { name },
|
|
84
|
+
fix: (fixer) => fixer.insertTextAfter(init, `.annotate({ identifier: "${name}" })`),
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// A computed identifier cannot be checked without evaluating it.
|
|
91
|
+
if (identifier.type !== "Literal" || typeof identifier.value !== "string")
|
|
92
|
+
return;
|
|
93
|
+
if (identifier.value === name)
|
|
94
|
+
return;
|
|
95
|
+
context.report({
|
|
96
|
+
node: identifier,
|
|
97
|
+
messageId: "wrongIdentifier",
|
|
98
|
+
data: { name, identifier: identifier.value },
|
|
99
|
+
suggest: [
|
|
100
|
+
{
|
|
101
|
+
messageId: "renameIdentifier",
|
|
102
|
+
data: { name },
|
|
103
|
+
fix: (fixer) => fixer.replaceText(identifier, `"${name}"`),
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
// Internal fragments, reused through `.fields`, never reach an error message alone.
|
|
110
|
+
ExportNamedDeclaration(node) {
|
|
111
|
+
if (node.declaration?.type !== "VariableDeclaration")
|
|
112
|
+
return;
|
|
113
|
+
node.declaration.declarations.forEach(check);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noAsymmetricMatchHandlersRule: import("@oxlint/plugins").Rule;
|