@thethracian/oxlint-config 0.1.0 → 0.2.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/CHANGELOG.md +19 -0
- package/README.md +62 -2
- package/dist/index.d.ts +34 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +73 -2
- package/dist/index.js.map +1 -1
- package/dist/rules/acronym-case.d.ts.map +1 -1
- package/dist/rules/acronym-case.js +0 -2
- package/dist/rules/acronym-case.js.map +1 -1
- package/dist/rules/effect-default-helpers.d.ts +33 -0
- package/dist/rules/effect-default-helpers.d.ts.map +1 -0
- package/dist/rules/effect-default-helpers.js +536 -0
- package/dist/rules/effect-default-helpers.js.map +1 -0
- package/dist/rules/effect-default-resource-helpers.d.ts +5 -0
- package/dist/rules/effect-default-resource-helpers.d.ts.map +1 -0
- package/dist/rules/effect-default-resource-helpers.js +65 -0
- package/dist/rules/effect-default-resource-helpers.js.map +1 -0
- package/dist/rules/effect-default-test-helpers.d.ts +5 -0
- package/dist/rules/effect-default-test-helpers.d.ts.map +1 -0
- package/dist/rules/effect-default-test-helpers.js +46 -0
- package/dist/rules/effect-default-test-helpers.js.map +1 -0
- package/dist/rules/effect-default.d.ts +3 -0
- package/dist/rules/effect-default.d.ts.map +1 -0
- package/dist/rules/effect-default.js +882 -0
- package/dist/rules/effect-default.js.map +1 -0
- package/dist/rules/effect-path-options.d.ts +59 -0
- package/dist/rules/effect-path-options.d.ts.map +1 -0
- package/dist/rules/effect-path-options.js +114 -0
- package/dist/rules/effect-path-options.js.map +1 -0
- package/dist/rules/effect-rule-core.d.ts +60 -0
- package/dist/rules/effect-rule-core.d.ts.map +1 -0
- package/dist/rules/effect-rule-core.js +509 -0
- package/dist/rules/effect-rule-core.js.map +1 -0
- package/dist/rules/effect-rule-names.d.ts +4 -0
- package/dist/rules/effect-rule-names.d.ts.map +1 -0
- package/dist/rules/effect-rule-names.js +147 -0
- package/dist/rules/effect-rule-names.js.map +1 -0
- package/dist/rules/effect-source-helpers.d.ts +10 -0
- package/dist/rules/effect-source-helpers.d.ts.map +1 -0
- package/dist/rules/effect-source-helpers.js +359 -0
- package/dist/rules/effect-source-helpers.js.map +1 -0
- package/dist/rules/effect-source-scan.d.ts +8 -0
- package/dist/rules/effect-source-scan.d.ts.map +1 -0
- package/dist/rules/effect-source-scan.js +364 -0
- package/dist/rules/effect-source-scan.js.map +1 -0
- package/dist/rules/effect-strict-helpers.d.ts +34 -0
- package/dist/rules/effect-strict-helpers.d.ts.map +1 -0
- package/dist/rules/effect-strict-helpers.js +500 -0
- package/dist/rules/effect-strict-helpers.js.map +1 -0
- package/dist/rules/effect-strict.d.ts +3 -0
- package/dist/rules/effect-strict.d.ts.map +1 -0
- package/dist/rules/effect-strict.js +727 -0
- package/dist/rules/effect-strict.js.map +1 -0
- package/dist/rules/max-line-length.d.ts.map +1 -1
- package/dist/rules/max-line-length.js +25 -7
- package/dist/rules/max-line-length.js.map +1 -1
- package/dist/rules/plugin.d.ts +2 -121
- package/dist/rules/plugin.d.ts.map +1 -1
- package/dist/rules/plugin.js +94 -101
- package/dist/rules/plugin.js.map +1 -1
- package/dist/rules/require-function-doc.d.ts.map +1 -1
- package/dist/rules/require-function-doc.js +32 -21
- package/dist/rules/require-function-doc.js.map +1 -1
- package/dist/rules/source-cache.d.ts +11 -0
- package/dist/rules/source-cache.d.ts.map +1 -0
- package/dist/rules/source-cache.js +50 -0
- package/dist/rules/source-cache.js.map +1 -0
- package/package.json +13 -4
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import { hasBroadCatchAllWithoutRethrow, hasEffectInArrayForEach, hasEffectInPromiseCallback, hasForkDaemonWithoutCleanup, hasForkInUninterruptibleWithoutRestore, hasErrorMappingWithoutCause, hasCastAfterSchemaDecode, hasExternalJsonWithoutDecodeUnknown, hasFloatingEffect, hasJsonParsedBeforeSchemaStringDecode, hasMultipleCatchTagsInOnePipe, hasNestedFlatMap, hasParsedJsonNumberFromString, hasRecursiveEffectWithoutSuspend, hasReturnEffectInGen, hasRunForkWithoutObserver, hasRuntimeInEffect, hasSchemaPromiseDecode, hasSchemaSyncDecodeInEffectWorkflow, hasSyncForPromise, hasSyncForThrowingOps, hasTryPromiseWithoutTypedCatch, hasUnhandledSchemaEffectDecode, hasUnboundedEffectConcurrency, hasUnboundedFlatMapConcurrency, hasUnloggedIgnore, hasUnobservedFork, hasAsyncAwaitInEffect, hasThrowInEffect, hasUnsafeLazyEvaluation, hasYieldWithoutStarInGen, } from './effect-default-helpers.js';
|
|
2
|
+
import { hasUnreleasedAcquire, hasUnscopedAcquireRelease, hasUnscopedResourceWorkflow, } from './effect-default-resource-helpers.js';
|
|
3
|
+
import { hasForkBeforeTestClockAdjust, hasRealSleepWithoutTestClock, hasTestClockWithoutEffectContext, } from './effect-default-test-helpers.js';
|
|
4
|
+
import { isConfiguredPath, isEffectTestPath, strictPathOptionsSchema, } from './effect-path-options.js';
|
|
5
|
+
import { effectImportAliases, effectFunctionAliases, hasEffectSignal, hasRuntimeCall, makeRules, } from './effect-rule-core.js';
|
|
6
|
+
import { exportedCallableDeclarationSegments, exportedDeclarationSegments, findBalancedCallEnd, stripCommentsAndStrings, } from './effect-source-helpers.js';
|
|
7
|
+
const effectDefaultRuleTokens = [
|
|
8
|
+
'Effect',
|
|
9
|
+
'Schema',
|
|
10
|
+
'Config',
|
|
11
|
+
'Context',
|
|
12
|
+
'Queue',
|
|
13
|
+
'Stream',
|
|
14
|
+
'TestClock',
|
|
15
|
+
'from "effect"',
|
|
16
|
+
"from 'effect'",
|
|
17
|
+
'@effect/',
|
|
18
|
+
'"effect/',
|
|
19
|
+
"'effect/",
|
|
20
|
+
'it.effect',
|
|
21
|
+
'describe.effect',
|
|
22
|
+
'JSON.parse',
|
|
23
|
+
'response.json',
|
|
24
|
+
];
|
|
25
|
+
function reportAst(context, message, node) {
|
|
26
|
+
context.report({ message, node });
|
|
27
|
+
}
|
|
28
|
+
function nodeType(node) {
|
|
29
|
+
return typeof node === 'object' && node !== null ? node.type : undefined;
|
|
30
|
+
}
|
|
31
|
+
function identifierName(node) {
|
|
32
|
+
return nodeType(node) === 'Identifier' ? node.name : undefined;
|
|
33
|
+
}
|
|
34
|
+
function literalValue(node) {
|
|
35
|
+
return nodeType(node) === 'Literal' ? node.value : undefined;
|
|
36
|
+
}
|
|
37
|
+
function isStringLikeLiteral(node) {
|
|
38
|
+
if (typeof literalValue(node) === 'string') {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if (nodeType(node) !== 'TemplateLiteral') {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const { expressions } = node;
|
|
45
|
+
return expressions?.length === 0;
|
|
46
|
+
}
|
|
47
|
+
function memberParts(node) {
|
|
48
|
+
if (nodeType(node) !== 'MemberExpression') {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
const member = node;
|
|
52
|
+
return {
|
|
53
|
+
objectName: identifierName(member.object),
|
|
54
|
+
propertyName: identifierName(member.property),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function effectCallPredicate(source, names) {
|
|
58
|
+
const memberNames = new Set(names);
|
|
59
|
+
const importAliases = new Set(effectImportAliases(source));
|
|
60
|
+
const functionAliases = new Set(names.flatMap((name) => effectFunctionAliases(source, 'Effect', name)));
|
|
61
|
+
return (callee) => {
|
|
62
|
+
const { objectName, propertyName } = memberParts(callee);
|
|
63
|
+
if (objectName && propertyName) {
|
|
64
|
+
return importAliases.has(objectName) && memberNames.has(propertyName);
|
|
65
|
+
}
|
|
66
|
+
const calleeName = identifierName(callee);
|
|
67
|
+
return Boolean(calleeName && functionAliases.has(calleeName));
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function propertyKeyName(node) {
|
|
71
|
+
const value = literalValue(node);
|
|
72
|
+
return identifierName(node) ?? (typeof value === 'string' ? value : undefined);
|
|
73
|
+
}
|
|
74
|
+
function typeReferenceName(node) {
|
|
75
|
+
if (nodeType(node) !== 'TSTypeReference') {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const { typeName } = node;
|
|
79
|
+
if (nodeType(typeName) === 'Identifier') {
|
|
80
|
+
return identifierName(typeName);
|
|
81
|
+
}
|
|
82
|
+
if (nodeType(typeName) !== 'TSQualifiedName') {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const qualified = typeName;
|
|
86
|
+
const leftName = identifierName(qualified.left);
|
|
87
|
+
const rightName = identifierName(qualified.right);
|
|
88
|
+
return leftName && rightName ? `${leftName}.${rightName}` : undefined;
|
|
89
|
+
}
|
|
90
|
+
function firstTypeArgumentName(node) {
|
|
91
|
+
const params = node.typeArguments?.params;
|
|
92
|
+
return typeReferenceName(params?.[0]);
|
|
93
|
+
}
|
|
94
|
+
function effectServiceSelfName(superClass, source) {
|
|
95
|
+
if (nodeType(superClass) !== 'CallExpression') {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const outer = superClass;
|
|
99
|
+
const outerSelf = typeReferenceName(outer.typeArguments?.params?.[0]);
|
|
100
|
+
const inner = outer.callee;
|
|
101
|
+
if (nodeType(inner) !== 'CallExpression') {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const innerCall = inner;
|
|
105
|
+
const { objectName, propertyName } = memberParts(innerCall.callee);
|
|
106
|
+
if (objectName === 'Context' && propertyName === 'Tag') {
|
|
107
|
+
return outerSelf;
|
|
108
|
+
}
|
|
109
|
+
if (objectName &&
|
|
110
|
+
propertyName === 'Service' &&
|
|
111
|
+
effectImportAliases(source).includes(objectName)) {
|
|
112
|
+
return firstTypeArgumentName(inner);
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
function hasMixedEffectImportStyles(source) {
|
|
117
|
+
const hasNamedRootEffectImport = /import\s*{[^}]*\bEffect\b[^}]*}\s*from\s*['"]effect['"]/.test(source);
|
|
118
|
+
const hasNamespaceEffectImport = /import\s+\*\s+as\s+[A-Za-z_$][\w$]*\s+from\s*['"]effect(?:\/Effect)?['"]/.test(source);
|
|
119
|
+
return hasNamedRootEffectImport && hasNamespaceEffectImport;
|
|
120
|
+
}
|
|
121
|
+
function escapeRegExp(value) {
|
|
122
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
123
|
+
}
|
|
124
|
+
function hasTryCatchInEffectGen(source) {
|
|
125
|
+
const code = stripCommentsAndStrings(source);
|
|
126
|
+
const aliases = effectImportAliases(source).map(escapeRegExp).join('|');
|
|
127
|
+
if (!aliases) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
for (const match of code.matchAll(new RegExp(`\\b(?:${aliases})\\.gen\\s*\\(`, 'g'))) {
|
|
131
|
+
const openParenIndex = code.indexOf('(', match.index);
|
|
132
|
+
if (openParenIndex === -1) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const bodyStart = openParenIndex + 1;
|
|
136
|
+
const body = code.slice(bodyStart, findBalancedCallEnd(code, openParenIndex));
|
|
137
|
+
const tryCatchMatch = /\btry\s*{[\s\S]*?\bcatch\s*\(/.exec(body);
|
|
138
|
+
if (tryCatchMatch?.index !== undefined) {
|
|
139
|
+
return bodyStart + tryCatchMatch.index;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
function hasStringErrorFailure(source) {
|
|
145
|
+
const match = /\bEffect\.fail\s*\(\s*["'`]/.exec(stripCommentsAndStrings(source));
|
|
146
|
+
return match?.index ?? false;
|
|
147
|
+
}
|
|
148
|
+
function hasUntaggedErrorFailure(source) {
|
|
149
|
+
const code = stripCommentsAndStrings(source);
|
|
150
|
+
const nativeErrorMatch = /\bEffect\.fail\s*\(\s*new\s+Error\s*\(/.exec(code);
|
|
151
|
+
if (nativeErrorMatch) {
|
|
152
|
+
return nativeErrorMatch.index;
|
|
153
|
+
}
|
|
154
|
+
const objectErrorMatch = /\bEffect\.fail\s*\(\s*{(?![^}]*\b_tag\s*:)/.exec(code);
|
|
155
|
+
return objectErrorMatch?.index ?? false;
|
|
156
|
+
}
|
|
157
|
+
function hasNativeErrorClassInEffectModule(source) {
|
|
158
|
+
if (!hasEffectSignal(source)) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
const match = /\bclass\s+[A-Z][\w$]*\s+extends\s+Error\b/.exec(stripCommentsAndStrings(source));
|
|
162
|
+
return match?.index ?? false;
|
|
163
|
+
}
|
|
164
|
+
function hasUnsafeEffectTypeAssertion(source) {
|
|
165
|
+
const code = stripCommentsAndStrings(source);
|
|
166
|
+
const match = /\bas\s+Effect\.Effect\s*<[^>]*>/.exec(code);
|
|
167
|
+
return match?.index ?? false;
|
|
168
|
+
}
|
|
169
|
+
function hasServiceSelfMismatch(source) {
|
|
170
|
+
const code = stripCommentsAndStrings(source);
|
|
171
|
+
const legacyPattern = /\bclass\s+([A-Z][\w$]*)\s+extends\s+(?:Context\.Tag|Effect\.Service|Effect\.Tag)\s*\([^)]*\)\s*<\s*([A-Z][\w$]*)\b/g;
|
|
172
|
+
for (const match of code.matchAll(legacyPattern)) {
|
|
173
|
+
const [, className, selfName] = match;
|
|
174
|
+
if (className !== selfName) {
|
|
175
|
+
return match.index;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const servicePattern = /\bclass\s+([A-Z][\w$]*)\s+extends\s+Effect\.Service\s*<\s*([A-Z][\w$]*)\s*>\s*\(\s*\)/g;
|
|
179
|
+
for (const match of code.matchAll(servicePattern)) {
|
|
180
|
+
const [, className, selfName] = match;
|
|
181
|
+
if (className !== selfName) {
|
|
182
|
+
return match.index;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
function hasEffectFnIife(source) {
|
|
188
|
+
const code = stripCommentsAndStrings(source);
|
|
189
|
+
const match = /\bEffect\.fn(?:Untraced|UntracedEager)?\s*\([\s\S]*?\)\s*\([\s\S]*?\)\s*\(/.exec(code);
|
|
190
|
+
return match?.index ?? false;
|
|
191
|
+
}
|
|
192
|
+
const effectDefaultSpecs = [
|
|
193
|
+
{
|
|
194
|
+
name: 'effect-no-floating-effect',
|
|
195
|
+
message: 'Return, yield, assign, or compose Effect values; bare Effect calls never execute.',
|
|
196
|
+
check: hasFloatingEffect,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'effect-require-yield-star',
|
|
200
|
+
message: 'Use yield* inside Effect.gen so generator composition unwraps the Effect value.',
|
|
201
|
+
tokenGroups: [['gen'], ['yield']],
|
|
202
|
+
check: hasYieldWithoutStarInGen,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: 'effect-require-return-yield-star',
|
|
206
|
+
message: 'Do not return an Effect from Effect.gen; return a value or return yield* the Effect.',
|
|
207
|
+
tokenGroups: [['gen'], ['return']],
|
|
208
|
+
check: hasReturnEffectInGen,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'effect-prefer-gen-for-nested-flatmap',
|
|
212
|
+
message: 'Replace nested Effect.flatMap callbacks with Effect.gen for readable sequencing.',
|
|
213
|
+
tokens: ['flatMap'],
|
|
214
|
+
check: hasNestedFlatMap,
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'effect-no-function-returning-gen',
|
|
218
|
+
message: 'Use Effect.fn for exported effectful functions instead of returning Effect.gen.',
|
|
219
|
+
tokens: ['gen'],
|
|
220
|
+
check: (source) => exportedCallableDeclarationSegments(source).some((segment) => /(?:^|\breturn\s+)Effect\.gen\s*\(/.test(segment.trim())),
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: 'effect-prefer-effect-fn-for-exported-effects',
|
|
224
|
+
message: 'Exported effectful functions should use Effect.fn for tracing and stable contracts.',
|
|
225
|
+
tokens: ['export'],
|
|
226
|
+
check: (source) => exportedCallableDeclarationSegments(source).some((segment) => /(?:^|\breturn\s+)Effect\.(?!fn\b|gen\b|isEffect\b|serviceFunction\b|runPromise\b)/.test(segment.trim())),
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'effect-no-unnecessary-gen',
|
|
230
|
+
message: 'Do not wrap a single Effect in Effect.gen when direct composition is clearer.',
|
|
231
|
+
tokenGroups: [['gen'], ['yield']],
|
|
232
|
+
patterns: [/Effect\.gen\s*\(\s*function\*\s*\([^)]*\)\s*{\s*return\s+yield\*\s+Effect\./],
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: 'effect-no-effect-in-array-foreach',
|
|
236
|
+
message: 'Use Effect.forEach instead of Array.forEach with Effect-returning callbacks.',
|
|
237
|
+
tokens: ['forEach'],
|
|
238
|
+
check: hasEffectInArrayForEach,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: 'effect-no-effect-in-promise-callback',
|
|
242
|
+
message: 'Do not create Effect values inside Promise callbacks; compose at the Effect boundary.',
|
|
243
|
+
tokens: ['.then', '.catch'],
|
|
244
|
+
check: hasEffectInPromiseCallback,
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'effect-no-floating-fiber',
|
|
248
|
+
message: 'Forked fibers must be joined, interrupted, scoped, supervised, or returned.',
|
|
249
|
+
tokens: ['fork'],
|
|
250
|
+
check: hasUnobservedFork,
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: 'effect-require-suspend-for-recursion',
|
|
254
|
+
message: 'Recursive Effect construction must be wrapped in Effect.suspend.',
|
|
255
|
+
tokens: ['function', '=>'],
|
|
256
|
+
check: hasRecursiveEffectWithoutSuspend,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: 'effect-require-suspend-for-lazy-evaluation',
|
|
260
|
+
message: 'Use Effect.suspend when Effect construction must defer eager JavaScript work.',
|
|
261
|
+
tokens: ['Date.now', 'Math.random', 'new Date', 'JSON.parse'],
|
|
262
|
+
check: hasUnsafeLazyEvaluation,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: 'effect-no-async-await-in-effect',
|
|
266
|
+
message: 'Use Effect.tryPromise or Effect.promise boundaries instead of async/await in Effect code.',
|
|
267
|
+
tokens: ['async', 'await'],
|
|
268
|
+
check: hasAsyncAwaitInEffect,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: 'effect-no-promise-then-in-effect',
|
|
272
|
+
message: 'Use Effect combinators instead of Promise then/catch chains in Effect modules.',
|
|
273
|
+
tokens: ['.then', '.catch'],
|
|
274
|
+
ast: (context, source) => {
|
|
275
|
+
const isEffectModule = hasEffectSignal(source);
|
|
276
|
+
return {
|
|
277
|
+
CallExpression(node) {
|
|
278
|
+
if (!isEffectModule) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const { callee } = node;
|
|
282
|
+
const { propertyName } = memberParts(callee);
|
|
283
|
+
if (propertyName === 'then' || propertyName === 'catch') {
|
|
284
|
+
reportAst(context, 'Use Effect combinators instead of Promise then/catch chains in Effect modules.', node);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: 'effect-no-throw',
|
|
292
|
+
message: 'Use typed Effect failures instead of throw inside Effect workflows.',
|
|
293
|
+
tokens: ['throw'],
|
|
294
|
+
check: hasThrowInEffect,
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: 'effect-no-string-errors',
|
|
298
|
+
message: 'Use structured tagged errors instead of string failures.',
|
|
299
|
+
tokens: ['fail'],
|
|
300
|
+
check: hasStringErrorFailure,
|
|
301
|
+
ast: (context, source) => {
|
|
302
|
+
const isEffectFail = effectCallPredicate(source, ['fail']);
|
|
303
|
+
return {
|
|
304
|
+
CallExpression(node) {
|
|
305
|
+
const call = node;
|
|
306
|
+
if (isEffectFail(call.callee) && isStringLikeLiteral(call.arguments?.[0])) {
|
|
307
|
+
reportAst(context, 'Use structured tagged errors instead of string failures.', node);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
name: 'effect-no-untagged-errors',
|
|
315
|
+
message: 'Use tagged/data/schema errors in Effect.fail instead of global Error values.',
|
|
316
|
+
tokens: ['fail'],
|
|
317
|
+
check: hasUntaggedErrorFailure,
|
|
318
|
+
ast: (context, source) => {
|
|
319
|
+
const isEffectFail = effectCallPredicate(source, ['fail']);
|
|
320
|
+
return {
|
|
321
|
+
CallExpression(node) {
|
|
322
|
+
const call = node;
|
|
323
|
+
const firstArg = call.arguments?.[0];
|
|
324
|
+
if (!isEffectFail(call.callee)) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!firstArg) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (nodeType(firstArg) === 'NewExpression' &&
|
|
331
|
+
identifierName(firstArg.callee) === 'Error') {
|
|
332
|
+
reportAst(context, 'Use tagged/data/schema errors in Effect.fail instead of global Error values.', node);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (nodeType(firstArg) === 'ObjectExpression' &&
|
|
336
|
+
!firstArg.properties?.some((property) => propertyKeyName(property.key) === '_tag')) {
|
|
337
|
+
reportAst(context, 'Use tagged/data/schema errors in Effect.fail instead of global Error values.', node);
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: 'effect-no-silent-error-swallowing',
|
|
345
|
+
message: 'Do not erase Effect failures without recovery, logging, or explicit typed replacement.',
|
|
346
|
+
tokens: ['catchAll', 'ignore'],
|
|
347
|
+
patterns: [
|
|
348
|
+
/Effect\.(?:catchAll|ignore)\s*\([\s\S]*?(?:Effect\.void|Effect\.succeed\s*\(\s*undefined|undefined)/,
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: 'effect-require-typed-error-in-trypromise',
|
|
353
|
+
message: 'Use Effect.tryPromise({ try, catch }) so Promise failures become typed Effect errors.',
|
|
354
|
+
tokens: ['tryPromise'],
|
|
355
|
+
check: hasTryPromiseWithoutTypedCatch,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
name: 'effect-prefer-catchTag-over-catchAll',
|
|
359
|
+
message: 'Prefer catchTag or catchTags over broad catchAll recovery.',
|
|
360
|
+
tokens: ['catchAll'],
|
|
361
|
+
check: hasBroadCatchAllWithoutRethrow,
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
name: 'effect-no-catchAll-with-mapError',
|
|
365
|
+
message: 'Use mapError directly instead of catchAll when only transforming the error.',
|
|
366
|
+
tokens: ['catchAll'],
|
|
367
|
+
patterns: [/Effect\.catchAll\s*\([\s\S]*?=>\s*Effect\.fail\s*\(/],
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: 'effect-prefer-mapError-over-catchAll-rethrow',
|
|
371
|
+
message: 'Use Effect.mapError instead of catchAll followed by fail.',
|
|
372
|
+
tokens: ['catchAll'],
|
|
373
|
+
patterns: [/(?:^|[^\w$.])catchAll\s*\([\s\S]*?=>\s*Effect\.fail\s*\(/],
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: 'effect-require-error-cause-preserved',
|
|
377
|
+
message: 'Preserve the original cause when mapping or wrapping Effect errors.',
|
|
378
|
+
tokens: ['mapError', 'catchAll'],
|
|
379
|
+
check: hasErrorMappingWithoutCause,
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: 'effect-prefer-ignore-logged',
|
|
383
|
+
message: 'Ignored Effect failures must be logged or otherwise observable.',
|
|
384
|
+
tokens: ['ignore'],
|
|
385
|
+
check: hasUnloggedIgnore,
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
name: 'effect-prefer-catchTags-for-multiple-tags',
|
|
389
|
+
message: 'Use Effect.catchTags for multiple tagged Effect recoveries.',
|
|
390
|
+
tokens: ['catchTag'],
|
|
391
|
+
check: hasMultipleCatchTagsInOnePipe,
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
name: 'effect-no-error-channel-widening-to-unknown',
|
|
395
|
+
message: 'Do not widen Effect error channels to unknown.',
|
|
396
|
+
tokens: ['unknown'],
|
|
397
|
+
patterns: [/Effect\s*<[^>]*,\s*unknown\b/, /Effect\.fail\s*<\s*unknown\b/],
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: 'effect-no-run-inside-effect',
|
|
401
|
+
message: 'Do not run an Effect from inside another Effect; yield or compose it instead.',
|
|
402
|
+
tokens: ['run'],
|
|
403
|
+
check: hasRuntimeInEffect,
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
name: 'effect-no-runpromise-in-exported-api',
|
|
407
|
+
message: 'Exported APIs should expose Effect values instead of hiding execution behind Promise.',
|
|
408
|
+
tokens: ['runPromise'],
|
|
409
|
+
check: (source) => exportedDeclarationSegments(source).some((segment) => /Effect\.runPromise\s*\(/.test(segment)),
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: 'effect-no-runfork-without-observer',
|
|
413
|
+
message: 'Do not call runFork without explicit observation, supervision, or interruption.',
|
|
414
|
+
tokens: ['runFork'],
|
|
415
|
+
check: hasRunForkWithoutObserver,
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
name: 'effect-no-sync-for-promise',
|
|
419
|
+
message: 'Use Effect.tryPromise for Promise-returning code instead of Effect.sync.',
|
|
420
|
+
tokenGroups: [['sync'], ['async', 'fetch', 'Promise.']],
|
|
421
|
+
check: hasSyncForPromise,
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
name: 'effect-no-sync-for-throwing-ops',
|
|
425
|
+
message: 'Use Effect.try for synchronous code that can throw instead of Effect.sync.',
|
|
426
|
+
tokenGroups: [['sync'], ['throw', 'JSON.parse']],
|
|
427
|
+
check: hasSyncForThrowingOps,
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
name: 'effect-no-console-log-in-effect-code',
|
|
431
|
+
message: 'Use Effect logging APIs instead of console logging in Effect code.',
|
|
432
|
+
tokens: ['console'],
|
|
433
|
+
ast: (context, source) => {
|
|
434
|
+
const isEffectModule = hasEffectSignal(source);
|
|
435
|
+
return {
|
|
436
|
+
CallExpression(node) {
|
|
437
|
+
const { objectName, propertyName } = memberParts(node.callee);
|
|
438
|
+
if (isEffectModule &&
|
|
439
|
+
objectName === 'console' &&
|
|
440
|
+
propertyName &&
|
|
441
|
+
['debug', 'error', 'info', 'log', 'warn'].includes(propertyName)) {
|
|
442
|
+
reportAst(context, 'Use Effect logging APIs instead of console logging in Effect code.', node);
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: 'effect-no-process-env-in-effect-code',
|
|
450
|
+
message: 'Use Effect Config instead of process.env in Effect code.',
|
|
451
|
+
tokens: ['process'],
|
|
452
|
+
ast: (context, source) => {
|
|
453
|
+
const isEffectModule = hasEffectSignal(source);
|
|
454
|
+
const isConfigLayer = isConfiguredPath(context, 'configLayers');
|
|
455
|
+
return {
|
|
456
|
+
MemberExpression(node) {
|
|
457
|
+
const { objectName, propertyName } = memberParts(node);
|
|
458
|
+
const processEnv = objectName === 'process' && propertyName === 'env';
|
|
459
|
+
if (isEffectModule && !isConfigLayer && processEnv) {
|
|
460
|
+
reportAst(context, 'Use Effect Config instead of process.env in Effect code.', node);
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
name: 'effect-no-date-now-in-effect-code',
|
|
468
|
+
message: 'Use Effect Clock instead of Date.now in Effect code.',
|
|
469
|
+
tokens: ['Date'],
|
|
470
|
+
ast: (context, source) => {
|
|
471
|
+
const isEffectModule = hasEffectSignal(source);
|
|
472
|
+
const isAdapterLayer = isConfiguredPath(context, 'adapterLayers');
|
|
473
|
+
return {
|
|
474
|
+
CallExpression(node) {
|
|
475
|
+
const { objectName, propertyName } = memberParts(node.callee);
|
|
476
|
+
if (isEffectModule &&
|
|
477
|
+
!isAdapterLayer &&
|
|
478
|
+
objectName === 'Date' &&
|
|
479
|
+
propertyName === 'now') {
|
|
480
|
+
reportAst(context, 'Use Effect Clock instead of Date.now in Effect code.', node);
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
name: 'effect-no-math-random-in-effect-code',
|
|
488
|
+
message: 'Use Effect Random instead of Math.random in Effect code.',
|
|
489
|
+
tokens: ['Math'],
|
|
490
|
+
ast: (context, source) => {
|
|
491
|
+
const isEffectModule = hasEffectSignal(source);
|
|
492
|
+
const isAdapterLayer = isConfiguredPath(context, 'adapterLayers');
|
|
493
|
+
return {
|
|
494
|
+
CallExpression(node) {
|
|
495
|
+
const { objectName, propertyName } = memberParts(node.callee);
|
|
496
|
+
if (isEffectModule &&
|
|
497
|
+
!isAdapterLayer &&
|
|
498
|
+
objectName === 'Math' &&
|
|
499
|
+
propertyName === 'random') {
|
|
500
|
+
reportAst(context, 'Use Effect Random instead of Math.random in Effect code.', node);
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
name: 'effect-no-json-parse-cast',
|
|
508
|
+
message: 'Decode external JSON with Schema instead of casting parsed unknown data.',
|
|
509
|
+
tokenGroups: [[' as '], ['JSON.parse', '.json']],
|
|
510
|
+
patterns: [
|
|
511
|
+
/\bJSON\.parse\s*\([^)]*\)\s+as\s+[A-Za-z_$][\w$]*/,
|
|
512
|
+
/\.json\s*\(\s*\)\s+as\s+[A-Za-z_$][\w$]*/,
|
|
513
|
+
],
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
name: 'effect-schema-prefer-decodeUnknown-effect',
|
|
517
|
+
message: 'Use Schema.decodeUnknown to decode unknown input into the Effect error channel.',
|
|
518
|
+
tokens: ['decode'],
|
|
519
|
+
check: hasSchemaPromiseDecode,
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
name: 'effect-schema-require-parse-error-handling',
|
|
523
|
+
message: 'Schema parsing must expose parse errors through typed Effect handling.',
|
|
524
|
+
tokens: ['decode'],
|
|
525
|
+
check: hasUnhandledSchemaEffectDecode,
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
name: 'effect-schema-use-decodeUnknown-for-external-data',
|
|
529
|
+
message: 'External data must enter through Schema.decodeUnknown.',
|
|
530
|
+
tokens: ['.json'],
|
|
531
|
+
check: hasExternalJsonWithoutDecodeUnknown,
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
name: 'effect-schema-no-unsafe-sync-decode-in-effect-code',
|
|
535
|
+
message: 'Do not use throwing synchronous Schema decoders in Effect modules.',
|
|
536
|
+
tokens: ['decodeSync', 'decodeUnknownSync'],
|
|
537
|
+
check: hasSchemaSyncDecodeInEffectWorkflow,
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
name: 'effect-schema-require-parseJson-for-json-strings',
|
|
541
|
+
message: 'Use Schema.parseJson when decoding JSON strings with Schema.',
|
|
542
|
+
tokens: ['JSON.parse'],
|
|
543
|
+
check: hasJsonParsedBeforeSchemaStringDecode,
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
name: 'effect-schema-correct-number-type-for-parsed-json',
|
|
547
|
+
message: 'Use the correct Schema number type for already-parsed JSON numbers.',
|
|
548
|
+
tokens: ['JSON.parse'],
|
|
549
|
+
check: hasParsedJsonNumberFromString,
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: 'effect-schema-prefer-taggedClass-over-manual-tag',
|
|
553
|
+
message: 'Prefer Schema.TaggedClass over hand-written _tag fields.',
|
|
554
|
+
tokens: ['_tag'],
|
|
555
|
+
patterns: [/Schema\.Struct\s*\(\s*{[\s\S]*?_tag\s*:\s*Schema\.Literal/],
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
name: 'effect-schema-avoid-old-type-names',
|
|
559
|
+
message: 'Use current Effect Schema API names instead of obsolete lowercase helpers.',
|
|
560
|
+
tokens: ['Schema.'],
|
|
561
|
+
patterns: [/\bSchema\.(?:string|number|boolean|array|object)\s*\(/],
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
name: 'effect-schema-no-cast-after-decode',
|
|
565
|
+
message: 'Do not cast after Schema decoding; let the schema provide the type.',
|
|
566
|
+
tokenGroups: [['Schema.decode'], [' as ']],
|
|
567
|
+
check: hasCastAfterSchemaDecode,
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
name: 'effect-require-acquire-release',
|
|
571
|
+
message: 'Resource acquisition must use acquireRelease, scoped, or equivalent finalization.',
|
|
572
|
+
tokens: ['open', 'connect', 'subscribe', 'listen'],
|
|
573
|
+
check: hasUnreleasedAcquire,
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
name: 'effect-require-scoped-for-acquireRelease',
|
|
577
|
+
message: 'Use Effect.scoped around acquireRelease when exposing acquired resources.',
|
|
578
|
+
tokens: ['acquireRelease'],
|
|
579
|
+
check: hasUnscopedAcquireRelease,
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
name: 'effect-require-scoped-for-resources',
|
|
583
|
+
message: 'Resourceful workflows must be scoped.',
|
|
584
|
+
tokens: ['Socket.', 'Connection.'],
|
|
585
|
+
check: hasUnscopedResourceWorkflow,
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
name: 'effect-no-fork-daemon-without-cleanup',
|
|
589
|
+
message: 'Daemon fibers must have cleanup, interruption, or supervision.',
|
|
590
|
+
tokens: ['forkDaemon'],
|
|
591
|
+
check: hasForkDaemonWithoutCleanup,
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: 'effect-prefer-fork-scoped-for-listeners',
|
|
595
|
+
message: 'Long-running listeners should use forkScoped so they follow Scope lifetime.',
|
|
596
|
+
tokens: ['fork'],
|
|
597
|
+
patterns: [
|
|
598
|
+
/Effect\.fork\s*\([\s\S]*?\b(?:listen[A-Z]\w*|subscribe[A-Z]\w*|watch[A-Z]\w*|listen|subscribe|watch)\b/,
|
|
599
|
+
],
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
name: 'effect-require-restore-for-fork-in-uninterruptible',
|
|
603
|
+
message: 'Use restore when forking inside uninterruptible regions.',
|
|
604
|
+
tokenGroups: [['uninterruptible'], ['fork']],
|
|
605
|
+
check: hasForkInUninterruptibleWithoutRestore,
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
name: 'effect-require-bounded-concurrency',
|
|
609
|
+
message: 'Concurrent Effect traversal must declare an explicit concurrency bound.',
|
|
610
|
+
tokens: ['concurrency'],
|
|
611
|
+
check: hasUnboundedEffectConcurrency,
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
name: 'effect-require-bounded-flatMap-concurrency',
|
|
615
|
+
message: 'Concurrent Effect.flatMap usage must declare a bounded concurrency value.',
|
|
616
|
+
tokenGroups: [['flatMap'], ['concurrency']],
|
|
617
|
+
check: hasUnboundedFlatMapConcurrency,
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
name: 'effect-no-unbounded-queue',
|
|
621
|
+
message: 'Use bounded, sliding, or dropping queues instead of unbounded queues.',
|
|
622
|
+
tokens: ['unbounded'],
|
|
623
|
+
patterns: [/\bQueue\.unbounded\s*\(/],
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
name: 'effect-no-unbounded-stream-buffer',
|
|
627
|
+
message: 'Stream buffers must be explicitly bounded.',
|
|
628
|
+
tokens: ['unbounded', 'Infinity'],
|
|
629
|
+
patterns: [
|
|
630
|
+
/\bStream\.(?:buffer|fromQueue|async|asyncPush)\s*\([^)]*\b(?:Infinity|unbounded)\b/,
|
|
631
|
+
],
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
name: 'effect-test-no-runpromise',
|
|
635
|
+
message: 'Use @effect/vitest it.effect instead of manually running Effects in tests.',
|
|
636
|
+
tokens: ['run'],
|
|
637
|
+
check: (source, context) => isEffectTestPath(context) && hasRuntimeCall(source),
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
name: 'effect-prefer-it-effect-for-unit-tests',
|
|
641
|
+
message: 'Use it.effect for tests that exercise Effect programs.',
|
|
642
|
+
tokens: ['it('],
|
|
643
|
+
check: (source, context) => isEffectTestPath(context) &&
|
|
644
|
+
!hasRuntimeCall(source) &&
|
|
645
|
+
/\bit\s*\([\s\S]*?Effect\./.test(stripCommentsAndStrings(source)),
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
name: 'effect-testClock-requires-fork',
|
|
649
|
+
message: 'Fork time-dependent work before adjusting TestClock.',
|
|
650
|
+
tokens: ['TestClock.adjust'],
|
|
651
|
+
check: (source) => /TestClock\.adjust\s*\(/.test(stripCommentsAndStrings(source)) &&
|
|
652
|
+
!hasForkBeforeTestClockAdjust(source),
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
name: 'effect-testClock-requires-testContext',
|
|
656
|
+
message: 'Use Effect test context when using TestClock.',
|
|
657
|
+
tokens: ['TestClock'],
|
|
658
|
+
check: (source, context) => isEffectTestPath(context) && hasTestClockWithoutEffectContext(source),
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
name: 'effect-no-real-sleep-in-tests',
|
|
662
|
+
message: 'Use TestClock instead of real sleeps in Effect tests.',
|
|
663
|
+
tokens: ['sleep'],
|
|
664
|
+
check: (source, context) => isEffectTestPath(context) && hasRealSleepWithoutTestClock(source),
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
name: 'effect-use-exit-for-failure-tests',
|
|
668
|
+
message: 'Use Effect.exit inside it.effect when asserting Effect failures.',
|
|
669
|
+
tokens: ['rejects', 'toThrow'],
|
|
670
|
+
check: (source, context) => isEffectTestPath(context) &&
|
|
671
|
+
!hasRuntimeCall(source) &&
|
|
672
|
+
/\b(?:rejects|toThrow)\b/.test(stripCommentsAndStrings(source)) &&
|
|
673
|
+
/\bEffect\./.test(stripCommentsAndStrings(source)),
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
name: 'effect-no-focused-effect-tests',
|
|
677
|
+
message: 'Focused Effect tests must not be committed.',
|
|
678
|
+
tokens: ['.only'],
|
|
679
|
+
check: (source, context) => isEffectTestPath(context) &&
|
|
680
|
+
/\b(?:it|describe)\.effect\.only\s*\(/.test(stripCommentsAndStrings(source)),
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
name: 'effect-no-skipped-effect-tests',
|
|
684
|
+
message: 'Skipped Effect tests must not be committed.',
|
|
685
|
+
tokens: ['.skip'],
|
|
686
|
+
check: (source, context) => isEffectTestPath(context) &&
|
|
687
|
+
/\b(?:it|describe)\.effect\.skip\s*\(/.test(stripCommentsAndStrings(source)),
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
name: 'effect-no-obsolete-imports',
|
|
691
|
+
message: 'Import Effect APIs from the main effect package; deprecated split packages are blocked.',
|
|
692
|
+
tokens: ['@effect/io', '@effect/data'],
|
|
693
|
+
patterns: [/from\s+['"]@effect\/(?:io|data)['"]/],
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
name: 'effect-no-known-fake-api',
|
|
697
|
+
message: 'This is not a known Effect API for the configured version.',
|
|
698
|
+
tokens: ['fromPromise', 'tryCatch', 'bracket', 'fromEither'],
|
|
699
|
+
check: (source) => /\bEffect\.(?:fromPromise|tryCatch|bracket|fromEither)\s*\(/.test(stripCommentsAndStrings(source)),
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
name: 'effect-prefer-gen-over-do',
|
|
703
|
+
message: 'Prefer Effect.gen over Effect.Do for agent-readable sequential workflows.',
|
|
704
|
+
tokens: ['Effect.Do'],
|
|
705
|
+
patterns: [/\bEffect\.Do\b/],
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
name: 'effect-prefer-direct-yield-star',
|
|
709
|
+
message: 'Use direct yield* effect style instead of adapter-style Effect.gen helpers.',
|
|
710
|
+
tokenGroups: [['gen'], ['$']],
|
|
711
|
+
patterns: [/Effect\.gen\s*\(\s*function\*\s*\(\s*\$\s*\)/],
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
name: 'effect-prefer-config-redacted',
|
|
715
|
+
message: 'Use Config.redacted for sensitive config values so secrets stay redacted in logs and errors.',
|
|
716
|
+
tokens: ['secret', 'Secret'],
|
|
717
|
+
patterns: [/Config\.(?:secret|Secret)\s*\(/],
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
name: 'effect-no-deprecated-schema-package',
|
|
721
|
+
message: 'Use Schema from effect/Schema instead of @effect/schema.',
|
|
722
|
+
tokens: ['@effect/schema'],
|
|
723
|
+
patterns: [/from\s+['"]@effect\/schema['"]/],
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
name: 'effect-no-deprecated-context-tag-function',
|
|
727
|
+
message: 'Use the current Context.Tag class/service pattern instead of deprecated tag helpers.',
|
|
728
|
+
tokens: ['Context.Tag'],
|
|
729
|
+
patterns: [
|
|
730
|
+
/\b(?:const|let|var)\s+[A-Z][\w$]*\s*=\s*Context\.Tag(?:<[^>]+>)?\s*\(\s*['"][^'"]+['"]\s*\)/,
|
|
731
|
+
/(?:^|[;\n]\s*)Context\.Tag(?:<[^>]+>)?\s*\(\s*['"][^'"]+['"]\s*\)/,
|
|
732
|
+
],
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
name: 'effect-no-global-error-channel',
|
|
736
|
+
message: 'Use domain-specific tagged errors instead of the global Error type channel.',
|
|
737
|
+
tokenGroups: [['Effect.Effect'], ['Error']],
|
|
738
|
+
patterns: [/Effect\.Effect\s*<[^,>]+,\s*Error\s*[,>]/],
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
name: 'effect-use-duration-constructors',
|
|
742
|
+
message: 'Use Duration constructors or string durations instead of naked millisecond numbers.',
|
|
743
|
+
tokens: ['sleep', 'timeout', 'delay'],
|
|
744
|
+
patterns: [
|
|
745
|
+
/Effect\.(?:sleep|timeout|delay)\s*\(\s*\d+\s*\)/,
|
|
746
|
+
/Effect\.(?:timeout|delay)\s*\([^,]+,\s*\d+\s*\)/,
|
|
747
|
+
],
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
name: 'effect-no-mixed-effect-import-styles',
|
|
751
|
+
message: 'Use one Effect import style per file.',
|
|
752
|
+
tokens: ['import'],
|
|
753
|
+
check: hasMixedEffectImportStyles,
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
name: 'effect-prefer-effect-is',
|
|
757
|
+
message: 'Use Effect.isEffect for Effect type checks.',
|
|
758
|
+
tokens: ['instanceof', '_op'],
|
|
759
|
+
patterns: [/\b[A-Za-z_$][\w$]*\s+instanceof\s+Effect\b/, /\._op\s*===\s*['"]Effect['"]/],
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
name: 'effect-no-try-catch-in-effect-gen',
|
|
763
|
+
message: 'Use Effect error combinators instead of try/catch inside Effect.gen.',
|
|
764
|
+
tokenGroups: [['gen'], ['try']],
|
|
765
|
+
ast: (context, source) => ({
|
|
766
|
+
TryStatement(node) {
|
|
767
|
+
const { start } = node;
|
|
768
|
+
if (typeof start !== 'number') {
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const index = hasTryCatchInEffectGen(source);
|
|
772
|
+
if (typeof index === 'number' && index === start) {
|
|
773
|
+
reportAst(context, 'Use Effect error combinators instead of try/catch inside Effect.gen.', node);
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
}),
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
name: 'effect-no-new-promise',
|
|
780
|
+
message: 'Use Effect.async, Effect.promise, or Effect.tryPromise instead of new Promise.',
|
|
781
|
+
tokenGroups: [['Promise'], ['Effect', 'effect']],
|
|
782
|
+
ast: (context, source) => {
|
|
783
|
+
const isEffectModule = hasEffectSignal(source);
|
|
784
|
+
return {
|
|
785
|
+
NewExpression(node) {
|
|
786
|
+
if (isEffectModule && identifierName(node.callee) === 'Promise') {
|
|
787
|
+
reportAst(context, 'Use Effect.async, Effect.promise, or Effect.tryPromise instead of new Promise.', node);
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
name: 'effect-no-global-timers',
|
|
795
|
+
message: 'Use Effect.sleep, Schedule, or Clock instead of global timers in Effect modules.',
|
|
796
|
+
tokens: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'],
|
|
797
|
+
ast: (context, source) => {
|
|
798
|
+
const isEffectModule = hasEffectSignal(source);
|
|
799
|
+
return {
|
|
800
|
+
CallExpression(node) {
|
|
801
|
+
const calleeName = identifierName(node.callee);
|
|
802
|
+
if (isEffectModule &&
|
|
803
|
+
calleeName &&
|
|
804
|
+
['clearInterval', 'clearTimeout', 'setInterval', 'setTimeout'].includes(calleeName)) {
|
|
805
|
+
reportAst(context, 'Use Effect.sleep, Schedule, or Clock instead of global timers in Effect modules.', node);
|
|
806
|
+
}
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
name: 'effect-no-native-error-classes',
|
|
813
|
+
message: 'Use tagged/data/schema errors instead of classes extending native Error.',
|
|
814
|
+
tokens: ['Error'],
|
|
815
|
+
check: hasNativeErrorClassInEffectModule,
|
|
816
|
+
ast: (context, source) => {
|
|
817
|
+
const isEffectModule = hasEffectSignal(source);
|
|
818
|
+
return {
|
|
819
|
+
ClassDeclaration(node) {
|
|
820
|
+
if (isEffectModule && identifierName(node.superClass) === 'Error') {
|
|
821
|
+
reportAst(context, 'Use tagged/data/schema errors instead of classes extending native Error.', node);
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
name: 'effect-no-unsafe-effect-type-assertion',
|
|
829
|
+
message: 'Do not assert Effect error or requirement channels with type casts.',
|
|
830
|
+
tokenGroups: [[' as '], ['Effect.Effect']],
|
|
831
|
+
check: hasUnsafeEffectTypeAssertion,
|
|
832
|
+
ast: (context) => ({
|
|
833
|
+
TSAsExpression(node) {
|
|
834
|
+
if (typeReferenceName(node.typeAnnotation) === 'Effect.Effect') {
|
|
835
|
+
reportAst(context, 'Do not assert Effect error or requirement channels with type casts.', node);
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
}),
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
name: 'effect-require-service-self-match',
|
|
842
|
+
message: 'Effect service/tag self type must match the declaring class name.',
|
|
843
|
+
tokenGroups: [['class'], ['extends'], ['Context', 'Service', 'Tag']],
|
|
844
|
+
check: hasServiceSelfMismatch,
|
|
845
|
+
ast: (context, source) => ({
|
|
846
|
+
ClassDeclaration(node) {
|
|
847
|
+
const className = identifierName(node.id);
|
|
848
|
+
const selfName = effectServiceSelfName(node.superClass, source);
|
|
849
|
+
if (className && selfName && className !== selfName) {
|
|
850
|
+
reportAst(context, 'Effect service/tag self type must match the declaring class name.', node);
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
}),
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
name: 'effect-no-effect-fn-iife',
|
|
857
|
+
message: 'Do not call Effect.fn as an IIFE; use Effect.gen for local one-shot workflows.',
|
|
858
|
+
tokenGroups: [['fn'], ['Effect', 'effect']],
|
|
859
|
+
check: hasEffectFnIife,
|
|
860
|
+
ast: (context, source) => {
|
|
861
|
+
const isEffectFn = effectCallPredicate(source, ['fn', 'fnUntraced', 'fnUntracedEager']);
|
|
862
|
+
return {
|
|
863
|
+
CallExpression(node) {
|
|
864
|
+
const outerCallee = node.callee;
|
|
865
|
+
const middleCallee = outerCallee?.callee;
|
|
866
|
+
const innerCallee = middleCallee?.callee;
|
|
867
|
+
if (nodeType(outerCallee) === 'CallExpression' &&
|
|
868
|
+
nodeType(middleCallee) === 'CallExpression' &&
|
|
869
|
+
isEffectFn(innerCallee)) {
|
|
870
|
+
reportAst(context, 'Do not call Effect.fn as an IIFE; use Effect.gen for local one-shot workflows.', node);
|
|
871
|
+
}
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
},
|
|
875
|
+
},
|
|
876
|
+
];
|
|
877
|
+
const effectDefaultRules = makeRules(effectDefaultSpecs, {
|
|
878
|
+
defaultTokens: effectDefaultRuleTokens,
|
|
879
|
+
schema: strictPathOptionsSchema,
|
|
880
|
+
});
|
|
881
|
+
export default effectDefaultRules;
|
|
882
|
+
//# sourceMappingURL=effect-default.js.map
|