@timmo001/oxlint-rules 0.3.6 → 0.5.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.
@@ -1,8 +1,86 @@
1
1
  // src/generic/index.ts
2
2
  import { eslintCompatPlugin } from "@oxlint/plugins";
3
3
 
4
- // src/generic/rules/prefer-event-parameter-type.ts
4
+ // src/generic/rules/no-explicit-inferable-type-predicate.ts
5
5
  import { defineRule } from "@oxlint/plugins";
6
+ function isNamed(node, name) {
7
+ return node.type === "Identifier" && node.name === name;
8
+ }
9
+ function isNullish(node) {
10
+ return node.type === "Literal" && node.value === null || isNamed(node, "undefined");
11
+ }
12
+ function isNullishExclusion(node, name) {
13
+ return node.type === "BinaryExpression" && (node.operator === "!==" || node.operator === "!=") && (isNamed(node.left, name) && isNullish(node.right) || isNullish(node.left) && isNamed(node.right, name));
14
+ }
15
+ function isTypeofCheck(node, name) {
16
+ if (node.type !== "BinaryExpression" || node.operator !== "===" && node.operator !== "==") {
17
+ return false;
18
+ }
19
+ const isTypeof = (side) => side.type === "UnaryExpression" && side.operator === "typeof" && isNamed(side.argument, name);
20
+ const isLiteral = (side) => side.type === "Literal";
21
+ return isTypeof(node.left) && isLiteral(node.right) || isLiteral(node.left) && isTypeof(node.right);
22
+ }
23
+ function isInferableCheck(node, name) {
24
+ if (node.type === "LogicalExpression") {
25
+ return node.operator === "&&" && isNullishExclusion(node.left, name) && isNullishExclusion(node.right, name);
26
+ }
27
+ return isNullishExclusion(node, name) || isTypeofCheck(node, name) || node.type === "BinaryExpression" && node.operator === "instanceof" && isNamed(node.left, name);
28
+ }
29
+ function returnedExpression(owner) {
30
+ const body = owner.body;
31
+ if (!body)
32
+ return null;
33
+ if (body.type !== "BlockStatement")
34
+ return body;
35
+ const [statement] = body.body;
36
+ return body.body.length === 1 && statement?.type === "ReturnStatement" ? statement.argument : null;
37
+ }
38
+ function isPublicSignature(owner) {
39
+ const parent = owner.parent;
40
+ if (parent.type === "MethodDefinition" || parent.type === "ExportNamedDeclaration" || parent.type === "ExportDefaultDeclaration") {
41
+ return true;
42
+ }
43
+ return parent.type === "VariableDeclarator" && parent.parent.parent?.type === "ExportNamedDeclaration";
44
+ }
45
+ var noExplicitInferableTypePredicateRule = defineRule({
46
+ meta: {
47
+ type: "suggestion",
48
+ docs: {
49
+ description: "Disallow explicit type predicates on simple null, typeof, or instanceof checks that TypeScript 5.5+ infers."
50
+ },
51
+ messages: {
52
+ removeTypePredicate: "TypeScript infers the type predicate for `{{parameter}}` from this check, while an explicit predicate is never checked against the body. Remove the annotation and rely on the inferred predicate."
53
+ }
54
+ },
55
+ create(context) {
56
+ const check = (owner) => {
57
+ const annotation = owner.returnType?.typeAnnotation;
58
+ if (annotation?.type !== "TSTypePredicate" || annotation.asserts || annotation.parameterName.type !== "Identifier" || isPublicSignature(owner)) {
59
+ return;
60
+ }
61
+ const parameter = annotation.parameterName.name;
62
+ const expression = returnedExpression(owner);
63
+ if (!owner.params.some((param) => isNamed(param, parameter)) || !expression || !isInferableCheck(expression, parameter)) {
64
+ return;
65
+ }
66
+ context.report({
67
+ node: annotation,
68
+ messageId: "removeTypePredicate",
69
+ data: { parameter }
70
+ });
71
+ };
72
+ return {
73
+ ArrowFunctionExpression: check,
74
+ FunctionDeclaration: check,
75
+ FunctionExpression: check
76
+ };
77
+ }
78
+ });
79
+
80
+ // src/generic/rules/no-unchecked-query-selector-type.ts
81
+ import { defineRule as defineRule2 } from "@oxlint/plugins";
82
+
83
+ // src/shared/ast.ts
6
84
  function nearestEnclosingFunction(node) {
7
85
  let current = node.parent;
8
86
  while (current) {
@@ -13,30 +91,204 @@ function nearestEnclosingFunction(node) {
13
91
  }
14
92
  return null;
15
93
  }
16
- function assertedEventParameter(node) {
17
- const expression = node.expression;
18
- if (expression.type !== "MemberExpression" || expression.computed || expression.object.type !== "Identifier" || expression.property.type !== "Identifier" || expression.property.name !== "currentTarget" && expression.property.name !== "target") {
94
+ function staticMemberName(node) {
95
+ if (node.type !== "MemberExpression" || node.computed)
19
96
  return null;
97
+ return node.property.type === "Identifier" ? node.property.name : null;
98
+ }
99
+
100
+ // src/shared/json.ts
101
+ function jsonObject(value) {
102
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
103
+ }
104
+
105
+ // src/shared/lit.ts
106
+ var renderRoots = new Set(["renderRoot", "shadowRoot"]);
107
+ function litElementBaseClasses(settings) {
108
+ const configured = jsonObject(settings.lit)?.elementBaseClasses;
109
+ return new Set([
110
+ "LitElement",
111
+ ...Array.isArray(configured) ? configured.map(String) : []
112
+ ]);
113
+ }
114
+ function isWrapperExpression(node) {
115
+ return node?.type === "ChainExpression" || node?.type === "ParenthesizedExpression" || node?.type === "TSAsExpression" || node?.type === "TSNonNullExpression" || node?.type === "TSSatisfiesExpression" || node?.type === "TSTypeAssertion";
116
+ }
117
+ function unwrapExpression(node) {
118
+ let current = node;
119
+ while (isWrapperExpression(current))
120
+ current = current.expression;
121
+ return current;
122
+ }
123
+ function extendsLitBase(node, bases) {
124
+ if (node.type === "Identifier")
125
+ return bases.has(node.name);
126
+ return node.type === "CallExpression" && node.arguments.some((argument) => extendsLitBase(argument, bases));
127
+ }
128
+ function isLitClass(node, bases) {
129
+ const hasCustomElementDecorator = node.decorators.some((decorator) => decorator.expression.type === "CallExpression" && decorator.expression.callee.type === "Identifier" && decorator.expression.callee.name === "customElement");
130
+ return hasCustomElementDecorator || node.superClass !== null && extendsLitBase(node.superClass, bases);
131
+ }
132
+ function classOfBody(body) {
133
+ const owner = body.parent;
134
+ return owner?.type === "ClassDeclaration" || owner?.type === "ClassExpression" ? owner : null;
135
+ }
136
+ function thisClass(node) {
137
+ let current = node;
138
+ while (current.parent) {
139
+ const parent = current.parent;
140
+ if (parent.type === "FunctionDeclaration" || parent.type === "FunctionExpression") {
141
+ const owner = parent.parent;
142
+ return (owner?.type === "MethodDefinition" || owner?.type === "TSAbstractMethodDefinition") && !owner.static && owner.value === parent ? classOfBody(owner.parent) : null;
143
+ }
144
+ if ((parent.type === "PropertyDefinition" || parent.type === "AccessorProperty") && parent.value === current) {
145
+ return parent.static ? null : classOfBody(parent.parent);
146
+ }
147
+ if (parent.type === "StaticBlock" || parent.type === "ClassBody" || parent.type === "Program") {
148
+ return null;
149
+ }
150
+ current = parent;
151
+ }
152
+ return null;
153
+ }
154
+ function isStaticSelector(node) {
155
+ return node?.type === "Literal" || node?.type === "TemplateLiteral" && node.expressions.length === 0;
156
+ }
157
+ function renderRootQuery(node, bases) {
158
+ const method = staticMemberName(node.callee);
159
+ if (method !== "querySelector" && method !== "querySelectorAll" || node.callee.type !== "MemberExpression" || !isStaticSelector(node.arguments[0])) {
160
+ return null;
161
+ }
162
+ const receiver = unwrapExpression(node.callee.object);
163
+ const root = staticMemberName(receiver);
164
+ if (!root || !renderRoots.has(root) || receiver.type !== "MemberExpression" || receiver.object.type !== "ThisExpression") {
165
+ return null;
166
+ }
167
+ const owner = thisClass(receiver.object);
168
+ return owner && isLitClass(owner, bases) ? { method, root } : null;
169
+ }
170
+ function isAssignedMethod(name) {
171
+ return name === "assignedElements" || name === "assignedNodes";
172
+ }
173
+ function feedsAssignedCall(node) {
174
+ let current = node;
175
+ while (isWrapperExpression(current.parent))
176
+ current = current.parent;
177
+ const member = current.parent;
178
+ return member?.type === "MemberExpression" && member.object === current && isAssignedMethod(staticMemberName(member));
179
+ }
180
+ function litQueryDecoratorCall(node, bases) {
181
+ const method = staticMemberName(node.callee);
182
+ if (!isAssignedMethod(method)) {
183
+ return feedsAssignedCall(node) ? null : renderRootQuery(node, bases);
184
+ }
185
+ if (node.callee.type !== "MemberExpression")
186
+ return null;
187
+ const inner = unwrapExpression(node.callee.object);
188
+ if (inner.type !== "CallExpression")
189
+ return null;
190
+ const query = renderRootQuery(inner, bases);
191
+ return query?.method === "querySelector" ? { method, root: query.root } : null;
192
+ }
193
+ function isLitRenderRootQuery(node, bases) {
194
+ return renderRootQuery(node, bases) !== null;
195
+ }
196
+
197
+ // src/generic/rules/no-unchecked-query-selector-type.ts
198
+ var queryMethods = new Set(["closest", "querySelector", "querySelectorAll"]);
199
+ var noUncheckedQuerySelectorTypeRule = defineRule2({
200
+ meta: {
201
+ type: "problem",
202
+ docs: {
203
+ description: "Disallow type arguments on `querySelector`, `querySelectorAll`, and `closest`, which cast the result without checking it."
204
+ },
205
+ messages: {
206
+ checkQueryResult: "`{{method}}<T>` casts the result without checking it. Check it with `instanceof`, use a checked lookup helper, or query a tag name so TypeScript infers the element type."
207
+ }
208
+ },
209
+ create(context) {
210
+ const bases = litElementBaseClasses(context.settings);
211
+ return {
212
+ CallExpression(node) {
213
+ const method = staticMemberName(node.callee);
214
+ if (!node.typeArguments || !method || !queryMethods.has(method) || isLitRenderRootQuery(node, bases)) {
215
+ return;
216
+ }
217
+ context.report({
218
+ node: node.typeArguments,
219
+ messageId: "checkQueryResult",
220
+ data: { method }
221
+ });
222
+ }
223
+ };
20
224
  }
21
- const parameterName = expression.object.name;
22
- const property = expression.property.name;
225
+ });
226
+
227
+ // src/generic/rules/no-untyped-custom-event.ts
228
+ import { defineRule as defineRule3 } from "@oxlint/plugins";
229
+ var noUntypedCustomEventRule = defineRule3({
230
+ meta: {
231
+ type: "problem",
232
+ docs: {
233
+ description: "Disallow `CustomEvent` type references without a detail type, which leave `detail` as `any`."
234
+ },
235
+ messages: {
236
+ typeCustomEventDetail: "`CustomEvent` without a type argument leaves `detail` as `any`. Reuse the project's typed event helper if one fits, or add one, instead of a bare `CustomEvent`."
237
+ }
238
+ },
239
+ create(context) {
240
+ return {
241
+ TSTypeReference(node) {
242
+ if (node.typeName.type !== "Identifier" || node.typeName.name !== "CustomEvent" || node.typeArguments) {
243
+ return;
244
+ }
245
+ context.report({ node, messageId: "typeCustomEventDetail" });
246
+ }
247
+ };
248
+ }
249
+ });
250
+
251
+ // src/generic/rules/prefer-event-parameter-type.ts
252
+ import { defineRule as defineRule4 } from "@oxlint/plugins";
253
+ function staticMemberPath(node) {
254
+ const path = [];
255
+ let current = node.type === "ChainExpression" ? node.expression : node;
256
+ while (current.type === "MemberExpression") {
257
+ if (current.computed || current.property.type !== "Identifier") {
258
+ return null;
259
+ }
260
+ path.unshift(current.property.name);
261
+ current = current.object;
262
+ }
263
+ return current.type === "Identifier" && path.length > 0 ? { root: current.name, path } : null;
264
+ }
265
+ function isEventMemberPath(path) {
266
+ const [first] = path;
267
+ if (first === "detail")
268
+ return true;
269
+ return path.length === 1 && (first === "currentTarget" || first === "target");
270
+ }
271
+ function assertedEventParameter(node) {
272
+ const member = staticMemberPath(node.expression);
273
+ if (!member || !isEventMemberPath(member.path))
274
+ return null;
23
275
  const owner = nearestEnclosingFunction(node);
24
- if (!owner?.params.some((parameter) => parameter.type === "Identifier" && parameter.name === parameterName)) {
276
+ if (!owner?.params.some((parameter) => parameter.type === "Identifier" && parameter.name === member.root)) {
25
277
  return null;
26
278
  }
27
279
  return {
28
- parameter: parameterName,
29
- property
280
+ parameter: member.root,
281
+ property: member.path.join(".")
30
282
  };
31
283
  }
32
- var preferEventParameterTypeRule = defineRule({
284
+ var preferEventParameterTypeRule = defineRule4({
33
285
  meta: {
34
286
  type: "suggestion",
35
287
  docs: {
36
- description: "Prefer typing event target properties in the handler parameter instead of asserting them at use sites."
288
+ description: "Prefer typing event target and detail properties in the handler parameter instead of asserting them at use sites."
37
289
  },
38
290
  messages: {
39
- typeEventParameter: "Type `{{parameter}}.{{property}}` in the function signature instead of asserting it at the use site."
291
+ typeEventParameter: "Type `{{parameter}}.{{property}}` in the function signature instead of asserting it at the use site. Reuse the project's typed event helper if one fits, or add one."
40
292
  }
41
293
  },
42
294
  create(context) {
@@ -57,11 +309,102 @@ var preferEventParameterTypeRule = defineRule({
57
309
  }
58
310
  });
59
311
 
312
+ // src/generic/rules/prefer-lit-query-decorators.ts
313
+ import { defineRule as defineRule5 } from "@oxlint/plugins";
314
+ var messageIds = {
315
+ assignedElements: "preferQueryAssignedElements",
316
+ assignedNodes: "preferQueryAssignedNodes",
317
+ querySelector: "preferQuery",
318
+ querySelectorAll: "preferQueryAll"
319
+ };
320
+ var preferLitQueryDecoratorsRule = defineRule5({
321
+ meta: {
322
+ type: "suggestion",
323
+ docs: {
324
+ description: "Require Lit query decorators instead of static render-root queries in Lit elements."
325
+ },
326
+ schema: [
327
+ {
328
+ type: "object",
329
+ properties: {
330
+ querySelector: { type: "boolean" },
331
+ querySelectorAll: { type: "boolean" },
332
+ assignedElements: { type: "boolean" },
333
+ assignedNodes: { type: "boolean" }
334
+ },
335
+ additionalProperties: false
336
+ }
337
+ ],
338
+ defaultOptions: [
339
+ {
340
+ querySelector: true,
341
+ querySelectorAll: true,
342
+ assignedElements: true,
343
+ assignedNodes: true
344
+ }
345
+ ],
346
+ messages: {
347
+ preferQuery: "Use the @query decorator instead of this.{{root}}.querySelector().",
348
+ preferQueryAll: "Use the @queryAll decorator instead of this.{{root}}.querySelectorAll().",
349
+ preferQueryAssignedElements: "Use the @queryAssignedElements decorator instead of this.{{root}}.querySelector().assignedElements().",
350
+ preferQueryAssignedNodes: "Use the @queryAssignedNodes decorator instead of this.{{root}}.querySelector().assignedNodes()."
351
+ }
352
+ },
353
+ create(context) {
354
+ const bases = litElementBaseClasses(context.settings);
355
+ const options = jsonObject(context.options[0]);
356
+ const disabled = new Set(options ? Object.keys(options).filter((method) => options[method] === false) : []);
357
+ return {
358
+ CallExpression(node) {
359
+ const query = litQueryDecoratorCall(node, bases);
360
+ if (!query || disabled.has(query.method))
361
+ return;
362
+ context.report({
363
+ node,
364
+ messageId: messageIds[query.method],
365
+ data: { root: query.root }
366
+ });
367
+ }
368
+ };
369
+ }
370
+ });
371
+
372
+ // src/generic/rules/prefer-types-over-assertions.ts
373
+ import { defineRule as defineRule6 } from "@oxlint/plugins";
374
+ var preferTypesOverAssertionsRule = defineRule6({
375
+ meta: {
376
+ type: "problem",
377
+ docs: {
378
+ description: "Prefer typing a value at its source over a TypeScript type assertion."
379
+ },
380
+ messages: {
381
+ avoidAssertion: "Type this value at its source, such as a function parameter or DOM lookup, instead of asserting it here. A SAFETY comment is not a fix."
382
+ }
383
+ },
384
+ create(context) {
385
+ const checkAssertion = (node) => {
386
+ if (node.typeAnnotation.type === "TSTypeReference" && node.typeAnnotation.typeName.type === "Identifier" && node.typeAnnotation.typeName.name === "const" || assertedEventParameter(node)) {
387
+ return;
388
+ }
389
+ context.report({ node, messageId: "avoidAssertion" });
390
+ };
391
+ return {
392
+ TSAsExpression: checkAssertion,
393
+ TSTypeAssertion: checkAssertion
394
+ };
395
+ }
396
+ });
397
+
60
398
  // src/generic/index.ts
61
399
  var timmoPlugin = eslintCompatPlugin({
62
400
  meta: { name: "timmo" },
63
401
  rules: {
64
- "prefer-event-parameter-type": preferEventParameterTypeRule
402
+ "no-explicit-inferable-type-predicate": noExplicitInferableTypePredicateRule,
403
+ "no-unchecked-query-selector-type": noUncheckedQuerySelectorTypeRule,
404
+ "no-untyped-custom-event": noUntypedCustomEventRule,
405
+ "prefer-event-parameter-type": preferEventParameterTypeRule,
406
+ "prefer-lit-query-decorators": preferLitQueryDecoratorsRule,
407
+ "prefer-types-over-assertions": preferTypesOverAssertionsRule
65
408
  }
66
409
  });
67
410
  var generic_default = timmoPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/oxlint-rules",
3
- "version": "0.3.6",
3
+ "version": "0.5.0",
4
4
  "description": "Shared Oxlint plugins and configs with optional Effect rules.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -25,6 +25,8 @@
25
25
  "src/generic",
26
26
  "!src/generic/**/*.test.ts",
27
27
  "!src/effect/**/*.test.ts",
28
+ "src/shared",
29
+ "!src/shared/**/*.test.ts",
28
30
  "vendor/anti-slop/src",
29
31
  "vendor/anti-slop/LICENSE",
30
32
  "!vendor/anti-slop/src/**/*.test.ts",
@@ -46,14 +48,14 @@
46
48
  "typecheck": "tsc --noEmit"
47
49
  },
48
50
  "peerDependencies": {
49
- "@oxlint/plugins": "1.85.0",
50
- "oxlint": "1.85.0"
51
+ "@oxlint/plugins": "1.86.0",
52
+ "oxlint": "1.86.0"
51
53
  },
52
54
  "devDependencies": {
53
- "@oxlint/plugins": "1.85.0",
55
+ "@oxlint/plugins": "1.86.0",
54
56
  "@types/bun": "^1.3.14",
55
- "oxlint": "1.85.0",
56
- "oxlint-tsgolint": "7.0.2002",
57
+ "oxlint": "1.86.0",
58
+ "oxlint-tsgolint": "7.0.2003",
57
59
  "prettier": "^3.9.5",
58
60
  "skills-ref": "0.1.5",
59
61
  "typescript": "^7.0.0"
@@ -1,11 +1,13 @@
1
1
  import { eslintCompatPlugin } from "@oxlint/plugins";
2
2
 
3
3
  import { noTryCatchInEffectGeneratorsRule } from "./rules/no-try-catch-in-effect-generators.ts";
4
+ import { preferPlatformServicesRule } from "./rules/prefer-platform-services.ts";
4
5
 
5
6
  const timmoEffectPlugin = eslintCompatPlugin({
6
7
  meta: { name: "timmo-effect" },
7
8
  rules: {
8
9
  "no-try-catch-in-effect-generators": noTryCatchInEffectGeneratorsRule,
10
+ "prefer-platform-services": preferPlatformServicesRule,
9
11
  },
10
12
  });
11
13
 
@@ -1,104 +1,10 @@
1
1
  import { defineRule } from "@oxlint/plugins";
2
2
 
3
- import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
3
+ import { nearestEnclosingFunction } from "../../shared/ast.ts";
4
+ import { isEffectMethod } from "../../shared/effect.ts";
4
5
 
5
- type FunctionNode = ESTree.Function | ESTree.ArrowFunctionExpression;
6
-
7
- function resolveVariable(
8
- sourceCode: SourceCode,
9
- identifier: ESTree.IdentifierReference,
10
- ): Variable | null {
11
- let scope: Scope | null = sourceCode.getScope(identifier);
12
-
13
- while (scope) {
14
- const variable = scope.set.get(identifier.name);
15
-
16
- if (variable) return variable;
17
- scope = scope.upper;
18
- }
19
-
20
- return null;
21
- }
22
-
23
- function nearestEnclosingFunction(node: ESTree.Node): FunctionNode | null {
24
- let current: ESTree.Node | null = node.parent;
25
-
26
- while (current) {
27
- if (
28
- current.type === "FunctionDeclaration" ||
29
- current.type === "FunctionExpression" ||
30
- current.type === "ArrowFunctionExpression"
31
- ) {
32
- return current;
33
- }
34
-
35
- current = current.parent;
36
- }
37
-
38
- return null;
39
- }
40
-
41
- function staticMemberName(node: ESTree.Expression): string | null {
42
- if (node.type !== "MemberExpression" || node.computed) return null;
43
-
44
- return node.property.type === "Identifier" ? node.property.name : null;
45
- }
46
-
47
- function isEffectImport(
48
- sourceCode: SourceCode,
49
- identifier: ESTree.IdentifierReference,
50
- namespace: boolean,
51
- ): boolean {
52
- const variable = resolveVariable(sourceCode, identifier);
53
-
54
- return (
55
- variable?.defs.some((definition) => {
56
- if (
57
- definition.type !== "ImportBinding" ||
58
- definition.parent?.type !== "ImportDeclaration" ||
59
- definition.parent.source.value !== "effect"
60
- ) {
61
- return false;
62
- }
63
-
64
- if (namespace) return definition.node.type === "ImportNamespaceSpecifier";
65
-
66
- if (definition.node.type !== "ImportSpecifier") return false;
67
- const imported = definition.node.imported;
68
-
69
- return (
70
- (imported.type === "Identifier" ? imported.name : imported.value) ===
71
- "Effect"
72
- );
73
- }) ?? false
74
- );
75
- }
76
-
77
- function isEffectMethod(
78
- sourceCode: SourceCode,
79
- node: ESTree.Expression,
80
- method: "fn" | "gen",
81
- ): boolean {
82
- if (staticMemberName(node) !== method || node.type !== "MemberExpression") {
83
- return false;
84
- }
85
-
86
- const object = node.object;
87
-
88
- if (object.type === "Identifier") {
89
- return isEffectImport(sourceCode, object, false);
90
- }
91
-
92
- if (
93
- staticMemberName(object) !== "Effect" ||
94
- object.type !== "MemberExpression" ||
95
- object.object.type !== "Identifier"
96
- ) {
97
- return false;
98
- }
99
-
100
- return isEffectImport(sourceCode, object.object, true);
101
- }
6
+ import type { FunctionNode } from "../../shared/ast.ts";
7
+ import type { ESTree, SourceCode } from "@oxlint/plugins";
102
8
 
103
9
  function isDirectArgument(
104
10
  owner: FunctionNode,
@@ -0,0 +1,105 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+
3
+ import { staticMemberName } from "../../shared/ast.ts";
4
+ import { importsEffect } from "../../shared/effect.ts";
5
+ import { resolveVariable } from "../../shared/scope.ts";
6
+
7
+ type PlatformService = "childProcess" | "fileSystem";
8
+
9
+ const moduleServices = new Map<string, PlatformService>([
10
+ ["child_process", "childProcess"],
11
+ ["fs", "fileSystem"],
12
+ ["fs/promises", "fileSystem"],
13
+ ]);
14
+
15
+ const bunServices = new Map<string, PlatformService>([
16
+ ["$", "childProcess"],
17
+ ["file", "fileSystem"],
18
+ ["spawn", "childProcess"],
19
+ ["spawnSync", "childProcess"],
20
+ ["write", "fileSystem"],
21
+ ]);
22
+
23
+ /** Prefer Effect's platform services over direct process and file system APIs in Effect modules. */
24
+ export const preferPlatformServicesRule = defineRule({
25
+ meta: {
26
+ type: "suggestion",
27
+ docs: {
28
+ description:
29
+ "Prefer Effect's ChildProcessSpawner and FileSystem services over Node or Bun process and file system APIs in modules that use Effect.",
30
+ },
31
+ messages: {
32
+ childProcess:
33
+ "Use Effect's `ChildProcessSpawner` service (`effect/process`) instead of `{{api}}`, so scope cleanup, interruption, and test layers apply.",
34
+ fileSystem:
35
+ "Use Effect's `FileSystem` service instead of `{{api}}`, so failures stay typed and tests can provide a layer.",
36
+ },
37
+ },
38
+ create(context) {
39
+ let enabled = false;
40
+
41
+ return {
42
+ Program(node) {
43
+ enabled = importsEffect(node);
44
+ },
45
+ ImportDeclaration(node) {
46
+ if (!enabled || node.importKind === "type") return;
47
+ const source = node.source.value;
48
+ const service = moduleServices.get(source.replace(/^node:/u, ""));
49
+
50
+ if (service) {
51
+ context.report({ node, messageId: service, data: { api: source } });
52
+
53
+ return;
54
+ }
55
+
56
+ if (source !== "bun") return;
57
+
58
+ for (const specifier of node.specifiers) {
59
+ if (
60
+ specifier.type !== "ImportSpecifier" ||
61
+ specifier.importKind === "type"
62
+ ) {
63
+ continue;
64
+ }
65
+
66
+ const imported =
67
+ specifier.imported.type === "Identifier"
68
+ ? specifier.imported.name
69
+ : specifier.imported.value;
70
+
71
+ const bunService = bunServices.get(imported);
72
+
73
+ if (bunService) {
74
+ context.report({
75
+ node: specifier,
76
+ messageId: bunService,
77
+ data: { api: `Bun.${imported}` },
78
+ });
79
+ }
80
+ }
81
+ },
82
+ MemberExpression(node) {
83
+ const name = staticMemberName(node);
84
+ const service = name ? bunServices.get(name) : undefined;
85
+
86
+ if (
87
+ !enabled ||
88
+ !service ||
89
+ node.object.type !== "Identifier" ||
90
+ node.object.name !== "Bun" ||
91
+ (resolveVariable(context.sourceCode, node.object)?.defs.length ?? 0) >
92
+ 0
93
+ ) {
94
+ return;
95
+ }
96
+
97
+ context.report({
98
+ node,
99
+ messageId: service,
100
+ data: { api: `Bun.${name}` },
101
+ });
102
+ },
103
+ };
104
+ },
105
+ });