@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +62 -2
  3. package/dist/index.d.ts +34 -11
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +73 -2
  6. package/dist/index.js.map +1 -1
  7. package/dist/rules/acronym-case.d.ts.map +1 -1
  8. package/dist/rules/acronym-case.js +0 -2
  9. package/dist/rules/acronym-case.js.map +1 -1
  10. package/dist/rules/effect-default-helpers.d.ts +33 -0
  11. package/dist/rules/effect-default-helpers.d.ts.map +1 -0
  12. package/dist/rules/effect-default-helpers.js +536 -0
  13. package/dist/rules/effect-default-helpers.js.map +1 -0
  14. package/dist/rules/effect-default-resource-helpers.d.ts +5 -0
  15. package/dist/rules/effect-default-resource-helpers.d.ts.map +1 -0
  16. package/dist/rules/effect-default-resource-helpers.js +65 -0
  17. package/dist/rules/effect-default-resource-helpers.js.map +1 -0
  18. package/dist/rules/effect-default-test-helpers.d.ts +5 -0
  19. package/dist/rules/effect-default-test-helpers.d.ts.map +1 -0
  20. package/dist/rules/effect-default-test-helpers.js +46 -0
  21. package/dist/rules/effect-default-test-helpers.js.map +1 -0
  22. package/dist/rules/effect-default.d.ts +3 -0
  23. package/dist/rules/effect-default.d.ts.map +1 -0
  24. package/dist/rules/effect-default.js +882 -0
  25. package/dist/rules/effect-default.js.map +1 -0
  26. package/dist/rules/effect-path-options.d.ts +59 -0
  27. package/dist/rules/effect-path-options.d.ts.map +1 -0
  28. package/dist/rules/effect-path-options.js +114 -0
  29. package/dist/rules/effect-path-options.js.map +1 -0
  30. package/dist/rules/effect-rule-core.d.ts +60 -0
  31. package/dist/rules/effect-rule-core.d.ts.map +1 -0
  32. package/dist/rules/effect-rule-core.js +509 -0
  33. package/dist/rules/effect-rule-core.js.map +1 -0
  34. package/dist/rules/effect-rule-names.d.ts +4 -0
  35. package/dist/rules/effect-rule-names.d.ts.map +1 -0
  36. package/dist/rules/effect-rule-names.js +147 -0
  37. package/dist/rules/effect-rule-names.js.map +1 -0
  38. package/dist/rules/effect-source-helpers.d.ts +10 -0
  39. package/dist/rules/effect-source-helpers.d.ts.map +1 -0
  40. package/dist/rules/effect-source-helpers.js +359 -0
  41. package/dist/rules/effect-source-helpers.js.map +1 -0
  42. package/dist/rules/effect-source-scan.d.ts +8 -0
  43. package/dist/rules/effect-source-scan.d.ts.map +1 -0
  44. package/dist/rules/effect-source-scan.js +364 -0
  45. package/dist/rules/effect-source-scan.js.map +1 -0
  46. package/dist/rules/effect-strict-helpers.d.ts +34 -0
  47. package/dist/rules/effect-strict-helpers.d.ts.map +1 -0
  48. package/dist/rules/effect-strict-helpers.js +500 -0
  49. package/dist/rules/effect-strict-helpers.js.map +1 -0
  50. package/dist/rules/effect-strict.d.ts +3 -0
  51. package/dist/rules/effect-strict.d.ts.map +1 -0
  52. package/dist/rules/effect-strict.js +727 -0
  53. package/dist/rules/effect-strict.js.map +1 -0
  54. package/dist/rules/max-line-length.d.ts.map +1 -1
  55. package/dist/rules/max-line-length.js +25 -7
  56. package/dist/rules/max-line-length.js.map +1 -1
  57. package/dist/rules/plugin.d.ts +2 -121
  58. package/dist/rules/plugin.d.ts.map +1 -1
  59. package/dist/rules/plugin.js +94 -101
  60. package/dist/rules/plugin.js.map +1 -1
  61. package/dist/rules/require-function-doc.d.ts.map +1 -1
  62. package/dist/rules/require-function-doc.js +32 -21
  63. package/dist/rules/require-function-doc.js.map +1 -1
  64. package/dist/rules/source-cache.d.ts +11 -0
  65. package/dist/rules/source-cache.d.ts.map +1 -0
  66. package/dist/rules/source-cache.js +50 -0
  67. package/dist/rules/source-cache.js.map +1 -0
  68. package/package.json +13 -4
@@ -0,0 +1,727 @@
1
+ import { hasRunForkWithoutObserver } from './effect-default-helpers.js';
2
+ import { isConfiguredPath, isEffectTestPath, isUnitTestPath, strictPathOptionsSchema, } from './effect-path-options.js';
3
+ import { effectApiAliases, effectFunctionAliases, effectImportAliases, hasEffectSignal, hasRuntimeCall, makeRules, } from './effect-rule-core.js';
4
+ import { exportedDeclarationTexts, findBalancedCallEnd, findMatchingBrace, findStatementEnd, stripComments, stripCommentsAndStrings, } from './effect-source-helpers.js';
5
+ import { hasBoundaryDataWithoutSchema, hasCommandHandlerWithoutSchema, hasDuplicateLayerInstance, hasAsyncPushWithoutBuffer, hasEnsuringCleanupWithoutOnExit, hasExternalEffectWithoutSpan, hasExternalEffectWithoutTimeout, hasHttpClientResponseWithoutSchema, hasHttpServerRequestWithoutSchema, hasIdempotentExternalEffectWithoutRetry, hasLayerFactory, hasLiveTestService, hasNPlusOneWithoutBatchedResolver, hasOutputBoundaryWithoutSchema, hasPersistenceReadWithoutSchema, hasRealTestService, hasSharedResourceForEachWithoutSemaphore, hasSharedMutableStateWithoutRef, hasTimeCodeWithoutTestClock, hasUnbatchedResolver, hasUnprovidedServiceInEffectTest, hasUnscopedResourceLayer, hasUnscopedResourceLoop, hasUnsafeResourceStream, hasUnterminatedLongRunningStream, } from './effect-strict-helpers.js';
6
+ function reportAst(context, message, node) {
7
+ context.report({ message, node });
8
+ }
9
+ function nodeType(node) {
10
+ return typeof node === 'object' && node !== null ? node.type : undefined;
11
+ }
12
+ function identifierName(node) {
13
+ return nodeType(node) === 'Identifier' ? node.name : undefined;
14
+ }
15
+ function literalValue(node) {
16
+ return nodeType(node) === 'Literal' ? node.value : undefined;
17
+ }
18
+ function isVoidZero(node) {
19
+ if (nodeType(node) !== 'UnaryExpression') {
20
+ return false;
21
+ }
22
+ const unary = node;
23
+ return unary.operator === 'void' && literalValue(unary.argument) === 0;
24
+ }
25
+ function memberParts(node) {
26
+ if (nodeType(node) !== 'MemberExpression') {
27
+ return {};
28
+ }
29
+ const member = node;
30
+ return {
31
+ objectName: identifierName(member.object),
32
+ propertyName: identifierName(member.property),
33
+ };
34
+ }
35
+ function isMember(node, objectName, propertyName) {
36
+ const parts = memberParts(node);
37
+ return parts.objectName === objectName && parts.propertyName === propertyName;
38
+ }
39
+ function isSchemaMember(node, source, propertyName) {
40
+ const parts = memberParts(node);
41
+ return Boolean(parts.objectName &&
42
+ parts.propertyName === propertyName &&
43
+ effectApiAliases(source, 'Schema').includes(parts.objectName));
44
+ }
45
+ function effectCallPredicate(source, names) {
46
+ const memberNames = new Set(names);
47
+ const importAliases = new Set(effectImportAliases(source));
48
+ const functionAliases = new Set(names.flatMap((name) => effectFunctionAliases(source, 'Effect', name)));
49
+ return (callee) => {
50
+ const { objectName, propertyName } = memberParts(callee);
51
+ if (objectName && propertyName) {
52
+ return importAliases.has(objectName) && memberNames.has(propertyName);
53
+ }
54
+ const calleeName = identifierName(callee);
55
+ return Boolean(calleeName && functionAliases.has(calleeName));
56
+ };
57
+ }
58
+ function serviceKeyFromClass(node, source) {
59
+ const { id, superClass } = node;
60
+ const className = identifierName(id);
61
+ if (nodeType(superClass) !== 'CallExpression') {
62
+ return { className };
63
+ }
64
+ const outer = superClass;
65
+ const inner = outer.callee;
66
+ if (!inner || nodeType(inner) !== 'CallExpression') {
67
+ return { className };
68
+ }
69
+ const innerCall = inner;
70
+ const { objectName, propertyName } = memberParts(innerCall.callee);
71
+ if (objectName === 'Context' && (propertyName === 'Tag' || propertyName === 'GenericTag')) {
72
+ return { className, key: literalValue(innerCall.arguments?.[0]) };
73
+ }
74
+ if (objectName &&
75
+ effectImportAliases(source).includes(objectName) &&
76
+ propertyName === 'Service') {
77
+ return { className, key: literalValue(outer.arguments?.[0]) };
78
+ }
79
+ return { className };
80
+ }
81
+ function hasRetryScheduleWithoutJitter(source) {
82
+ for (const match of source.matchAll(/\bEffect\.retry\s*\(/g)) {
83
+ const openParenIndex = source.indexOf('(', match.index);
84
+ const callBody = source.slice(openParenIndex + 1, findBalancedCallEnd(source, openParenIndex));
85
+ if (/\bSchedule\./.test(callBody) && !/\bjitter(?:ed)?\b/.test(callBody)) {
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }
91
+ function publicApiDeclarationSignature(declaration) {
92
+ if (/^\s*(?:export\s+)?(?:async\s+)?function\b/.test(declaration)) {
93
+ const bodyStart = declaration.indexOf('{');
94
+ return bodyStart === -1 ? declaration : declaration.slice(0, bodyStart);
95
+ }
96
+ if (/^\s*(?:export\s+)?(?:const|let|var)\b/.test(declaration)) {
97
+ const bodyStart = declaration.indexOf('{');
98
+ return bodyStart === -1 ? declaration : declaration.slice(0, bodyStart);
99
+ }
100
+ return declaration;
101
+ }
102
+ function hasClassPromiseReturningPublicMember(declaration) {
103
+ if (!/^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\b/.test(declaration)) {
104
+ return false;
105
+ }
106
+ const signatureSource = stripCommentsAndStrings(declaration);
107
+ const publicMemberPrefix = '(?!private\\b|protected\\b)(?:(?:public|static|abstract|override|declare|readonly)\\s+)*';
108
+ const memberName = '[A-Za-z_$][\\w$]*';
109
+ const memberStart = `(?:^|[{\\n;]\\s*)${publicMemberPrefix}${memberName}`;
110
+ const accessorStart = `(?:^|[{\\n;]\\s*)${publicMemberPrefix}`;
111
+ return (new RegExp(`${memberStart}\\s*\\([^)]*\\)\\s*:\\s*Promise\\s*<`).test(signatureSource) ||
112
+ new RegExp(`(?:^|[{\\n;]\\s*)${publicMemberPrefix}async\\s+${memberName}\\s*\\([^)]*\\)`).test(signatureSource) ||
113
+ new RegExp(`${memberStart}\\s*=\\s*async\\b`).test(signatureSource) ||
114
+ new RegExp(`${memberStart}\\s*=\\s*\\([^)]*\\)\\s*:\\s*Promise\\s*<`).test(signatureSource) ||
115
+ new RegExp(`${memberStart}\\s*:\\s*[^;\\n=]*Promise\\s*<`).test(signatureSource) ||
116
+ new RegExp(`${accessorStart}get\\s+${memberName}\\s*\\([^)]*\\)\\s*:\\s*Promise\\s*<`).test(signatureSource) ||
117
+ new RegExp(`${accessorStart}accessor\\s+${memberName}\\s*:\\s*[^;\\n=]*Promise\\s*<`).test(signatureSource));
118
+ }
119
+ function hasPromiseReturningPublicApi(source) {
120
+ return exportedDeclarationTexts(source).some((declaration) => {
121
+ if (/^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\b/.test(declaration)) {
122
+ return hasClassPromiseReturningPublicMember(declaration);
123
+ }
124
+ const signature = stripCommentsAndStrings(publicApiDeclarationSignature(declaration));
125
+ return (/\bPromise\s*</.test(signature) ||
126
+ /^\s*(?:export\s+)?async\s+function\b/.test(signature) ||
127
+ /=\s*async\b/.test(signature));
128
+ });
129
+ }
130
+ function hasExportedRunPromiseApi(source) {
131
+ return exportedDeclarationTexts(source).some((declaration) => /\bEffect\.runPromise\s*\(/.test(stripCommentsAndStrings(declaration)));
132
+ }
133
+ function hasRunSyncInServerRequestHandler(source) {
134
+ const code = stripCommentsAndStrings(source);
135
+ for (const match of code.matchAll(/\b(?:handler|route|loader|action)\s*=/g)) {
136
+ const segment = code.slice(match.index, findStatementEnd(code, match.index) + 1);
137
+ if (/\bEffect\.runSync\s*\(/.test(segment)) {
138
+ return true;
139
+ }
140
+ }
141
+ for (const match of code.matchAll(/\bfunction\s+(?:handler|route|loader|action)\s*\(/g)) {
142
+ const bodyStart = code.indexOf('{', match.index);
143
+ if (bodyStart === -1) {
144
+ continue;
145
+ }
146
+ const bodyEnd = findMatchingBrace(code, bodyStart);
147
+ if (bodyEnd !== -1 && /\bEffect\.runSync\s*\(/.test(code.slice(bodyStart, bodyEnd + 1))) {
148
+ return true;
149
+ }
150
+ }
151
+ return false;
152
+ }
153
+ function hasCryptoRandomUuid(source) {
154
+ const match = /\bcrypto\.randomUUID\s*\(/.exec(stripCommentsAndStrings(source));
155
+ return match?.index ?? false;
156
+ }
157
+ function hasSchemaInstanceof(source) {
158
+ const code = stripCommentsAndStrings(source);
159
+ const match = /\binstanceof\s+[A-Z][\w$]*(?:Schema|Request)\b/.exec(code);
160
+ return match?.index ?? false;
161
+ }
162
+ function hasSchemaStructWithTag(source) {
163
+ const match = /\bSchema\.Struct\s*\(\s*{[\s\S]*?_tag\s*:\s*Schema\.Literal\s*\(/.exec(stripCommentsAndStrings(source));
164
+ return match?.index ?? false;
165
+ }
166
+ function hasSchemaUnionOfLiterals(source) {
167
+ const match = /\bSchema\.Union\s*\(\s*Schema\.Literal\s*\([^)]*\)\s*,\s*Schema\.Literal\s*\(/.exec(stripCommentsAndStrings(source));
168
+ return match?.index ?? false;
169
+ }
170
+ function hasNonDeterministicServiceKey(source) {
171
+ const code = stripComments(source);
172
+ const legacyPattern = /\bclass\s+([A-Z][\w$]*)\s+extends\s+(?:Context\.Tag|Effect\.Service|Effect\.Tag)\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g;
173
+ for (const match of code.matchAll(legacyPattern)) {
174
+ const [, className, key] = match;
175
+ if (className !== key && !key.endsWith(`/${className}`)) {
176
+ return match.index;
177
+ }
178
+ }
179
+ const servicePattern = /\bclass\s+([A-Z][\w$]*)\s+extends\s+Effect\.Service\s*<\s*[A-Z][\w$]*\s*>\s*\(\s*\)\s*\(\s*['"`]([^'"`]+)['"`]/g;
180
+ for (const match of code.matchAll(servicePattern)) {
181
+ const [, className, key] = match;
182
+ if (className !== key && !key.endsWith(`/${className}`)) {
183
+ return match.index;
184
+ }
185
+ }
186
+ return false;
187
+ }
188
+ function hasMultipleProvideChain(source) {
189
+ const code = stripCommentsAndStrings(source);
190
+ const match = /\.pipe\s*\([\s\S]*?Effect\.provide\s*\([\s\S]*?Effect\.provide\s*\(/.exec(code);
191
+ return match?.index ?? false;
192
+ }
193
+ function hasLayerEffectWithScope(source) {
194
+ const code = stripCommentsAndStrings(source);
195
+ const match = /\bLayer\.effect\s*\([\s\S]*?\b(?:Scope\.Scope|Scope)\b/.exec(code);
196
+ return match?.index ?? false;
197
+ }
198
+ function hasNodeBuiltinImport(source) {
199
+ const match = /\bfrom\s+['"]node:(?:fs|fs\/promises|path|child_process|crypto|stream|http|https)['"]/.exec(stripComments(source));
200
+ return match?.index ?? false;
201
+ }
202
+ function hasGlobalFetch(source, context) {
203
+ const code = stripCommentsAndStrings(source);
204
+ if (isConfiguredPath(context, 'adapterLayers')) {
205
+ return false;
206
+ }
207
+ for (const match of code.matchAll(/\bfetch\s*\(/g)) {
208
+ const wrappedFetch = Boolean(effectWrapperStatement(code, match.index));
209
+ if (wrappedFetch) {
210
+ return match.index;
211
+ }
212
+ }
213
+ return false;
214
+ }
215
+ function hasEffectSucceedWithVoid(source) {
216
+ const match = /\bEffect\.succeed\s*\(\s*(?:undefined|void\s+0)?\s*\)/.exec(stripCommentsAndStrings(source));
217
+ return match?.index ?? false;
218
+ }
219
+ function hasMapToVoid(source) {
220
+ const match = /\bEffect\.map\s*\(\s*\(\s*\)\s*=>\s*(?:undefined|void\s+0|\{\s*\})\s*\)/.exec(stripCommentsAndStrings(source));
221
+ return match?.index ?? false;
222
+ }
223
+ function hasMapFlatten(source) {
224
+ const match = /\bEffect\.map\s*\([\s\S]*?\)\s*,\s*Effect\.flatten\b|\bEffect\.map\s*\([\s\S]*?\)\.pipe\s*\(\s*Effect\.flatten\b/.exec(stripCommentsAndStrings(source));
225
+ return match?.index ?? false;
226
+ }
227
+ function effectWrapperStatement(source, targetIndex) {
228
+ const statementStart = Math.max(source.lastIndexOf(';', targetIndex) + 1, source.lastIndexOf('\n', targetIndex) + 1);
229
+ const statementEnd = findStatementEnd(source, statementStart);
230
+ const statement = source.slice(statementStart, statementEnd + 1);
231
+ return /\bEffect\.(?:promise|tryPromise)\s*\(/.test(statement) ? statement : undefined;
232
+ }
233
+ function hasDirectPlatformAccess(source, context) {
234
+ if (isConfiguredPath(context, 'adapterLayers')) {
235
+ return false;
236
+ }
237
+ const code = stripCommentsAndStrings(source);
238
+ for (const match of code.matchAll(/\b(?:fetch|readFileSync|writeFileSync|createReadStream)\s*\(/g)) {
239
+ if (!match[0].startsWith('fetch')) {
240
+ return true;
241
+ }
242
+ if (!effectWrapperStatement(code, match.index)) {
243
+ return true;
244
+ }
245
+ }
246
+ return false;
247
+ }
248
+ const effectStrictSpecs = [
249
+ {
250
+ name: 'effect-no-run-outside-entrypoints',
251
+ message: 'Run Effect programs only from configured entrypoints.',
252
+ tokens: ['Effect', 'effect', 'runMain'],
253
+ check: (source, context) => hasRuntimeCall(source) &&
254
+ !hasExportedRunPromiseApi(source) &&
255
+ !hasRunForkWithoutObserver(source) &&
256
+ !isEffectTestPath(context) &&
257
+ !isConfiguredPath(context, 'entrypoints'),
258
+ },
259
+ {
260
+ name: 'effect-require-platform-runmain-at-entrypoints',
261
+ message: 'Entrypoints should use the platform runMain boundary instead of raw Effect runners.',
262
+ tokens: ['Effect.run', 'effect/Effect', 'runPromise', 'runSync', 'runFork'],
263
+ check: (source, context) => isConfiguredPath(context, 'entrypoints') &&
264
+ /Effect\.run(?:Promise|Sync|Fork)/.test(stripCommentsAndStrings(source)),
265
+ },
266
+ {
267
+ name: 'effect-no-runSync-in-server-request-handlers',
268
+ message: 'Server handlers must not synchronously run Effects.',
269
+ tokens: ['Effect.runSync'],
270
+ check: hasRunSyncInServerRequestHandler,
271
+ },
272
+ {
273
+ name: 'effect-no-promise-returning-public-api',
274
+ message: 'Public APIs should expose Effect return types instead of Promise.',
275
+ tokens: ['Promise', 'async'],
276
+ check: hasPromiseReturningPublicApi,
277
+ },
278
+ {
279
+ name: 'effect-no-direct-process-env-outside-config-layer',
280
+ message: 'Read process.env only inside the configured Effect Config layer.',
281
+ tokens: ['process'],
282
+ check: (source, context) => /\bprocess\s*\.\s*env\b/.test(stripCommentsAndStrings(source)) &&
283
+ !hasEffectSignal(source) &&
284
+ !isConfiguredPath(context, 'configLayers'),
285
+ },
286
+ {
287
+ name: 'effect-no-direct-clock-random-outside-adapters',
288
+ message: 'Use Clock and Random services outside configured adapter modules.',
289
+ tokens: ['Date', 'Math'],
290
+ check: (source, context) => /\b(?:Date\s*\.\s*now|Math\s*\.\s*random)\s*\(/.test(stripCommentsAndStrings(source)) &&
291
+ !hasEffectSignal(source) &&
292
+ !isConfiguredPath(context, 'adapterLayers'),
293
+ },
294
+ {
295
+ name: 'effect-no-direct-http-fs-outside-platform-services',
296
+ message: 'Use Effect platform services for HTTP and filesystem access outside adapters.',
297
+ tokens: ['fetch', 'readFileSync', 'writeFileSync', 'createReadStream'],
298
+ check: hasDirectPlatformAccess,
299
+ },
300
+ {
301
+ name: 'effect-require-service-class-pattern',
302
+ message: 'Services must use the configured class-based Effect service pattern.',
303
+ tokens: ['Context.GenericTag'],
304
+ patterns: [/Context\.GenericTag(?:<[^>]+>)?\s*\(\s*['"`]/],
305
+ },
306
+ {
307
+ name: 'effect-require-tag-identifier',
308
+ message: 'Service tags require stable identifiers.',
309
+ tokens: ['Context.Tag', 'Context.GenericTag'],
310
+ patterns: [
311
+ /Context\.Tag(?:<[^>]+>)?\s*\(\s*\)/,
312
+ /Context\.GenericTag(?:<[^>]+>)?\s*\(\s*\)/,
313
+ /class\s+[A-Z][\w$]*\s+extends\s+Context\.Tag\s*\(\s*\)/,
314
+ ],
315
+ },
316
+ {
317
+ name: 'effect-no-leaked-service-dependencies',
318
+ message: 'Domain exports must not leak raw service implementation dependencies.',
319
+ tokens: ['Layer', 'Live'],
320
+ check: (source, context) => isConfiguredPath(context, 'domain') && /Layer\.|Live\b/.test(stripCommentsAndStrings(source)),
321
+ },
322
+ {
323
+ name: 'effect-no-duplicate-layer-instances',
324
+ message: 'Do not construct the same Layer multiple times in one module.',
325
+ tokens: ['Layer'],
326
+ check: hasDuplicateLayerInstance,
327
+ },
328
+ {
329
+ name: 'effect-require-centralized-provision',
330
+ message: 'Provide layers only from configured composition roots.',
331
+ tokens: ['Effect.provide', 'Layer.provide'],
332
+ check: (source, context) => /Effect\.provide|Layer\.provide/.test(stripCommentsAndStrings(source)) &&
333
+ !isConfiguredPath(context, 'compositionRoots'),
334
+ },
335
+ {
336
+ name: 'effect-no-provide-in-domain-modules',
337
+ message: 'Domain modules should declare requirements, not provide concrete layers.',
338
+ tokens: ['Effect.provide', 'Layer.provide'],
339
+ check: (source, context) => isConfiguredPath(context, 'domain') &&
340
+ /Effect\.provide|Layer\.provide/.test(stripCommentsAndStrings(source)),
341
+ },
342
+ {
343
+ name: 'effect-require-layer-memoization-constant',
344
+ message: 'Shared layers should be memoized constants instead of functions that recreate resources.',
345
+ tokens: ['Layer'],
346
+ check: hasLayerFactory,
347
+ },
348
+ {
349
+ name: 'effect-require-suspend-for-circular-deps',
350
+ message: 'Circular layer dependencies must be suspended.',
351
+ tokens: ['Layer'],
352
+ patterns: [
353
+ /Layer\.(?:effect|scoped)\s*\([\s\S]*?Layer\.(?:effect|scoped)\s*\((?![\s\S]*Effect\.suspend)/,
354
+ ],
355
+ },
356
+ {
357
+ name: 'effect-avoid-layer-explosion',
358
+ message: 'Avoid tiny one-off Layer declarations that fragment dependency wiring.',
359
+ tokens: ['Layer'],
360
+ patterns: [
361
+ /const\s+[A-Za-z_$][\w$]*Layer\s*=\s*Layer\.[\s\S]*const\s+[A-Za-z_$][\w$]*Layer\s*=\s*Layer\./,
362
+ ],
363
+ },
364
+ {
365
+ name: 'effect-prefer-succeed-for-static-layers',
366
+ message: 'Use Layer.succeed for static pure services.',
367
+ tokens: ['Layer.effect', 'Effect.succeed'],
368
+ patterns: [/Layer\.effect\s*\([^,]+,\s*Effect\.succeed\s*\(/],
369
+ },
370
+ {
371
+ name: 'effect-require-scoped-for-resource-layers',
372
+ message: 'Layers that allocate resources must be scoped.',
373
+ tokens: ['Layer'],
374
+ check: hasUnscopedResourceLayer,
375
+ },
376
+ {
377
+ name: 'effect-no-service-construction-outside-layer',
378
+ message: 'Construct services inside Layers, not directly in domain or application logic.',
379
+ tokens: ['Service', 'Repo', 'Client'],
380
+ check: (source, context) => !isConfiguredPath(context, 'adapterLayers') &&
381
+ !isConfiguredPath(context, 'configLayers') &&
382
+ !/Layer\./.test(stripCommentsAndStrings(source)) &&
383
+ /new\s+[A-Z][\w$]*(?:Service|Repo|Client)\s*\(/.test(stripCommentsAndStrings(source)),
384
+ },
385
+ {
386
+ name: 'effect-schema-require-validation-at-input-boundaries',
387
+ message: 'Input boundaries must decode request, command, or message data with Schema.',
388
+ tokens: ['.body', '.params', '.query', '.payload'],
389
+ check: hasBoundaryDataWithoutSchema,
390
+ },
391
+ {
392
+ name: 'effect-schema-require-validation-at-output-boundaries',
393
+ message: 'Output boundaries should encode or validate public response data.',
394
+ tokens: ['Response.json', 'return json'],
395
+ check: hasOutputBoundaryWithoutSchema,
396
+ },
397
+ {
398
+ name: 'effect-schema-require-http-client-response-schema',
399
+ message: 'HTTP client responses must be decoded with Schema.',
400
+ tokens: ['HttpClient.', 'response.json'],
401
+ check: hasHttpClientResponseWithoutSchema,
402
+ },
403
+ {
404
+ name: 'effect-schema-require-http-server-request-schema',
405
+ message: 'HTTP server requests must be decoded with Schema.',
406
+ tokens: ['HttpRouter.', 'HttpServerRequest'],
407
+ check: hasHttpServerRequestWithoutSchema,
408
+ },
409
+ {
410
+ name: 'effect-schema-require-config-schema',
411
+ message: 'Configuration modules must decode configuration with Schema.',
412
+ tokens: ['Config'],
413
+ check: (source, context) => isConfiguredPath(context, 'configLayers') &&
414
+ /Config\./.test(stripCommentsAndStrings(source)) &&
415
+ !/Schema\./.test(stripCommentsAndStrings(source)),
416
+ },
417
+ {
418
+ name: 'effect-schema-require-persistence-schema',
419
+ message: 'Persistence boundaries must validate loaded records with Schema.',
420
+ tokens: ['db.', 'database.', 'collection.', 'repository.'],
421
+ check: hasPersistenceReadWithoutSchema,
422
+ },
423
+ {
424
+ name: 'effect-schema-require-public-command-schema',
425
+ message: 'Public command handlers must declare and use input schemas.',
426
+ tokens: ['handler'],
427
+ check: hasCommandHandlerWithoutSchema,
428
+ },
429
+ {
430
+ name: 'effect-schema-no-unknown-crossing-boundary',
431
+ message: 'unknown values must be decoded before crossing configured boundaries.',
432
+ tokens: ['unknown'],
433
+ check: (source) => exportedDeclarationTexts(source).some((declaration) => /\bunknown\b/.test(stripCommentsAndStrings(publicApiDeclarationSignature(declaration)))),
434
+ },
435
+ {
436
+ name: 'effect-require-timeout-on-external-effects',
437
+ message: 'External effects must declare a timeout.',
438
+ tokens: ['HttpClient.', 'fetch', 'FileSystem.', 'SqlClient.'],
439
+ check: (source) => hasExternalEffectWithoutTimeout(source),
440
+ },
441
+ {
442
+ name: 'effect-require-retry-policy-for-idempotent-external-effects',
443
+ message: 'Idempotent external effects must declare retry policy deliberately.',
444
+ tokens: ['HttpClient.', 'fetch', 'find', 'lookup', 'read'],
445
+ check: (source) => hasIdempotentExternalEffectWithoutRetry(source),
446
+ },
447
+ {
448
+ name: 'effect-require-schedule-jitter-for-retries',
449
+ message: 'Retry schedules must include jitter.',
450
+ tokenGroups: [['Effect.retry'], ['Schedule.']],
451
+ check: hasRetryScheduleWithoutJitter,
452
+ },
453
+ {
454
+ name: 'effect-require-span-external',
455
+ message: 'External effects must declare an observability span.',
456
+ tokens: ['HttpClient.', 'fetch', 'FileSystem.', 'SqlClient.'],
457
+ check: (source) => hasExternalEffectWithoutSpan(source),
458
+ },
459
+ {
460
+ name: 'effect-require-semaphore-for-shared-resources',
461
+ message: 'Shared scarce resources must be guarded by Semaphore.',
462
+ tokens: ['Effect.forEach'],
463
+ check: hasSharedResourceForEachWithoutSemaphore,
464
+ },
465
+ {
466
+ name: 'effect-require-ref-for-shared-mutable-state',
467
+ message: 'Shared mutable state must use Ref, SynchronizedRef, or scoped services.',
468
+ tokens: ['let '],
469
+ check: hasSharedMutableStateWithoutRef,
470
+ },
471
+ {
472
+ name: 'effect-require-scoped-in-loops',
473
+ message: 'Loops that acquire resources must scope each acquisition.',
474
+ tokens: ['open', 'connect', 'subscribe', 'listen'],
475
+ check: hasUnscopedResourceLoop,
476
+ },
477
+ {
478
+ name: 'effect-require-onExit-for-cleanup',
479
+ message: 'Cleanup logic should use onExit so success, failure, and interruption are handled.',
480
+ tokens: ['Effect.ensuring', 'cleanup'],
481
+ check: hasEnsuringCleanupWithoutOnExit,
482
+ },
483
+ {
484
+ name: 'effect-require-stream-resource-safety',
485
+ message: 'Streams over resources must be scoped or bracketed.',
486
+ tokens: ['Stream'],
487
+ check: hasUnsafeResourceStream,
488
+ },
489
+ {
490
+ name: 'effect-require-stream-termination',
491
+ message: 'Long-running streams must declare termination or shutdown behavior.',
492
+ tokens: ['Stream'],
493
+ check: hasUnterminatedLongRunningStream,
494
+ },
495
+ {
496
+ name: 'effect-require-explicit-asyncPush-buffer',
497
+ message: 'Stream.asyncPush must declare an explicit buffer/backpressure policy.',
498
+ tokens: ['Stream.asyncPush'],
499
+ check: hasAsyncPushWithoutBuffer,
500
+ },
501
+ {
502
+ name: 'effect-require-batching-for-resolver',
503
+ message: 'RequestResolver implementations should batch requests.',
504
+ tokens: ['RequestResolver'],
505
+ check: hasUnbatchedResolver,
506
+ },
507
+ {
508
+ name: 'effect-use-batched-resolver-for-n-plus-one',
509
+ message: 'Potential N+1 data access should use a batched RequestResolver.',
510
+ tokens: ['Effect.forEach'],
511
+ check: hasNPlusOneWithoutBatchedResolver,
512
+ },
513
+ {
514
+ name: 'effect-prefer-pubsub-for-broadcast',
515
+ message: 'Use PubSub for broadcast semantics instead of manually fanning out queues.',
516
+ tokens: ['Queue', 'broadcast', 'subscribers'],
517
+ patterns: [/Queue\.[\s\S]*?\bsubscribers\b|broadcast\s*\([\s\S]*?Queue\./],
518
+ },
519
+ {
520
+ name: 'effect-require-provided-services-in-tests',
521
+ message: 'Effect tests must provide required services explicitly.',
522
+ tokens: ['Service', 'Repo', 'Client'],
523
+ check: (source, context) => isEffectTestPath(context) && hasUnprovidedServiceInEffectTest(source),
524
+ },
525
+ {
526
+ name: 'effect-prefer-in-memory-implementations',
527
+ message: 'Unit tests should use in-memory service implementations.',
528
+ tokens: ['real'],
529
+ check: (source, context) => isUnitTestPath(context) && hasRealTestService(source),
530
+ },
531
+ {
532
+ name: 'effect-no-live-services-in-unit-tests',
533
+ message: 'Live services belong in integration tests, not unit tests.',
534
+ tokens: ['Live', 'Layer.live'],
535
+ check: (source, context) => isUnitTestPath(context) && hasLiveTestService(source),
536
+ },
537
+ {
538
+ name: 'effect-require-testclock-for-time-code',
539
+ message: 'Tests for time-dependent Effect code should use TestClock.',
540
+ tokens: ['Effect.timeout', 'Effect.delay', 'Clock.'],
541
+ check: (source, context) => isEffectTestPath(context) && hasTimeCodeWithoutTestClock(source),
542
+ },
543
+ {
544
+ name: 'effect-no-test-runtime-leakage',
545
+ message: 'Do not share mutable runtime, layer, or service state across Effect tests.',
546
+ tokens: ['Runtime', 'Layer', 'Service'],
547
+ check: (source, context) => isEffectTestPath(context) &&
548
+ /const\s+[A-Za-z_$][\w$]*(?:Runtime|Layer|Service)\s*=/.test(source),
549
+ },
550
+ {
551
+ name: 'effect-no-ad-hoc-effect-wrapper-abstractions',
552
+ message: 'Do not hide Effect semantics behind local wrapper DSLs.',
553
+ tokens: ['runEffect', 'makeEffect', 'effectify', 'toEffect'],
554
+ patterns: [/function\s+(?:runEffect|makeEffect|effectify|toEffect)\s*\(/],
555
+ },
556
+ {
557
+ name: 'effect-require-effect-suppression-reason-and-ticket',
558
+ message: 'Effect rule suppressions must name a rule, a reason, and a tracking ticket.',
559
+ tokens: ['disable', 'effect-'],
560
+ check: (source) => source.split('\n').some((line) => {
561
+ if (!/(?:eslint|oxlint)-disable[^\n]*effect-/.test(line)) {
562
+ return false;
563
+ }
564
+ return !/(?:reason|because)[^\n]*(?:[A-Z]+-\d+|#\d+)/.test(line);
565
+ }),
566
+ },
567
+ {
568
+ name: 'effect-no-crypto-randomUUID',
569
+ message: 'Use Effect Random or an injected UUID service instead of crypto.randomUUID.',
570
+ tokens: ['crypto.randomUUID'],
571
+ check: hasCryptoRandomUuid,
572
+ ast: (context) => ({
573
+ CallExpression(node) {
574
+ if (isMember(node.callee, 'crypto', 'randomUUID')) {
575
+ reportAst(context, 'Use Effect Random or an injected UUID service instead of crypto.randomUUID.', node);
576
+ }
577
+ },
578
+ }),
579
+ },
580
+ {
581
+ name: 'effect-require-schema-is-over-instanceof',
582
+ message: 'Use Schema.is for schema-modeled domain checks instead of instanceof.',
583
+ tokens: ['instanceof'],
584
+ check: hasSchemaInstanceof,
585
+ ast: (context) => ({
586
+ BinaryExpression(node) {
587
+ const binary = node;
588
+ const rightName = identifierName(binary.right);
589
+ if (binary.operator === 'instanceof' &&
590
+ rightName &&
591
+ /(?:Schema|Request)$/.test(rightName)) {
592
+ reportAst(context, 'Use Schema.is for schema-modeled domain checks instead of instanceof.', node);
593
+ }
594
+ },
595
+ }),
596
+ },
597
+ {
598
+ name: 'effect-prefer-schema-tagged-struct',
599
+ message: 'Use Schema.TaggedStruct or Schema.TaggedClass instead of Struct with _tag.',
600
+ tokens: ['Schema', '_tag'],
601
+ check: hasSchemaStructWithTag,
602
+ ast: (context, source) => ({
603
+ CallExpression(node) {
604
+ const call = node;
605
+ const firstArg = call.arguments?.[0];
606
+ if (!isSchemaMember(call.callee, source, 'Struct') ||
607
+ nodeType(firstArg) !== 'ObjectExpression') {
608
+ return;
609
+ }
610
+ const objectArg = firstArg;
611
+ if (objectArg.properties?.some((property) => {
612
+ const prop = property;
613
+ return (identifierName(prop.key) === '_tag' &&
614
+ isSchemaMember(prop.value.callee, source, 'Literal'));
615
+ })) {
616
+ reportAst(context, 'Use Schema.TaggedStruct or Schema.TaggedClass instead of Struct with _tag.', node);
617
+ }
618
+ },
619
+ }),
620
+ },
621
+ {
622
+ name: 'effect-prefer-single-schema-literal-union',
623
+ message: 'Combine literal alternatives into one Schema.Literal call.',
624
+ tokens: ['Schema.Union', 'Schema.Literal', 'effect'],
625
+ check: hasSchemaUnionOfLiterals,
626
+ ast: (context, source) => ({
627
+ CallExpression(node) {
628
+ const call = node;
629
+ const literalArgCount = call.arguments?.filter((argument) => isSchemaMember(argument.callee, source, 'Literal')).length ?? 0;
630
+ if (isSchemaMember(call.callee, source, 'Union') && literalArgCount > 1) {
631
+ reportAst(context, 'Combine literal alternatives into one Schema.Literal call.', node);
632
+ }
633
+ },
634
+ }),
635
+ },
636
+ {
637
+ name: 'effect-require-deterministic-service-keys',
638
+ message: 'Service/tag identifiers must deterministically match the service class.',
639
+ tokens: ['Context.Tag', 'Effect.Service', 'Effect.Tag'],
640
+ check: hasNonDeterministicServiceKey,
641
+ ast: (context, source) => ({
642
+ ClassDeclaration(node) {
643
+ const { className, key } = serviceKeyFromClass(node, source);
644
+ if (className && key && className !== key && !key.endsWith(`/${className}`)) {
645
+ reportAst(context, 'Service/tag identifiers must deterministically match the service class.', node);
646
+ }
647
+ },
648
+ }),
649
+ },
650
+ {
651
+ name: 'effect-no-multiple-provide-chain',
652
+ message: 'Avoid chaining Effect.provide calls; compose layers deliberately at the root.',
653
+ tokens: ['Effect.provide'],
654
+ check: hasMultipleProvideChain,
655
+ },
656
+ {
657
+ name: 'effect-require-layer-scoped-when-scope-required',
658
+ message: 'Use Layer.scoped when a Layer effect requires Scope.',
659
+ tokens: ['Layer.effect', 'Scope'],
660
+ check: hasLayerEffectWithScope,
661
+ },
662
+ {
663
+ name: 'effect-no-node-builtins-when-effect-platform-exists',
664
+ message: 'Use Effect platform services instead of direct Node built-in imports.',
665
+ tokens: ['node:'],
666
+ check: hasNodeBuiltinImport,
667
+ ast: (context) => ({
668
+ ImportDeclaration(node) {
669
+ const sourceValue = literalValue(node.source);
670
+ if (!isConfiguredPath(context, 'adapterLayers') &&
671
+ typeof sourceValue === 'string' &&
672
+ /^node:(?:fs|fs\/promises|path|child_process|crypto|stream|http|https)$/.test(sourceValue)) {
673
+ reportAst(context, 'Use Effect platform services instead of direct Node built-in imports.', node);
674
+ }
675
+ },
676
+ }),
677
+ },
678
+ {
679
+ name: 'effect-no-global-fetch',
680
+ message: 'Use the Effect HTTP client or an adapter service instead of global fetch.',
681
+ tokens: ['fetch'],
682
+ check: hasGlobalFetch,
683
+ ast: (context, source) => ({
684
+ CallExpression(node) {
685
+ const calleeName = identifierName(node.callee);
686
+ const wrappedFetch = effectWrapperStatement(stripCommentsAndStrings(source), node.start ?? 0);
687
+ if (calleeName === 'fetch' && !isConfiguredPath(context, 'adapterLayers') && wrappedFetch) {
688
+ reportAst(context, 'Use the Effect HTTP client or an adapter service instead of global fetch.', node);
689
+ }
690
+ },
691
+ }),
692
+ },
693
+ {
694
+ name: 'effect-prefer-effect-void',
695
+ message: 'Use Effect.void instead of Effect.succeed(undefined).',
696
+ tokens: ['succeed'],
697
+ check: hasEffectSucceedWithVoid,
698
+ ast: (context, source) => {
699
+ const isEffectSucceed = effectCallPredicate(source, ['succeed']);
700
+ return {
701
+ CallExpression(node) {
702
+ const call = node;
703
+ const firstArg = call.arguments?.[0];
704
+ if (isEffectSucceed(call.callee) &&
705
+ (!firstArg || identifierName(firstArg) === 'undefined' || isVoidZero(firstArg))) {
706
+ reportAst(context, 'Use Effect.void instead of Effect.succeed(undefined).', node);
707
+ }
708
+ },
709
+ };
710
+ },
711
+ },
712
+ {
713
+ name: 'effect-prefer-asVoid',
714
+ message: 'Use Effect.asVoid instead of mapping to undefined or an empty block.',
715
+ tokens: ['Effect', 'effect'],
716
+ check: hasMapToVoid,
717
+ },
718
+ {
719
+ name: 'effect-prefer-flatMap-over-map-flatten',
720
+ message: 'Use Effect.flatMap instead of Effect.map followed by Effect.flatten.',
721
+ tokens: ['Effect', 'effect'],
722
+ check: hasMapFlatten,
723
+ },
724
+ ];
725
+ const effectStrictRules = makeRules(effectStrictSpecs, { schema: strictPathOptionsSchema });
726
+ export default effectStrictRules;
727
+ //# sourceMappingURL=effect-strict.js.map