@dfinity/eslint-config-oisy-wallet 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -142,6 +142,64 @@ export default [
142
142
 
143
143
  Note: To override local rules, make sure you have the `eslint-local-rules.cjs` file at the root as described above.
144
144
 
145
+ ## 📐 Custom Rules
146
+
147
+ This configuration ships with several custom local rules:
148
+
149
+ | Rule | Description |
150
+ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
151
+ | `local-rules/use-nullish-checks` | Enforce the use of `isNullish()` / `nonNullish()` instead of direct truthiness checks for nullish assertions. |
152
+ | `local-rules/use-nullish-type-wrapper` | Enforce proper usage of nullish type wrappers. |
153
+ | `local-rules/prefer-object-params` | Prefer a single object parameter over multiple positional parameters. |
154
+ | `local-rules/no-svelte-store-in-api` | Disallow importing Svelte stores in API modules. |
155
+ | `local-rules/no-relative-imports` | Disallow relative imports; prefer alias paths. |
156
+ | `local-rules/explicit-non-void-return-type` | Require explicit return types for functions that return a value. |
157
+
158
+ ### `use-nullish-checks` Options
159
+
160
+ The rule accepts an optional configuration object:
161
+
162
+ | Option | Type | Default | Description |
163
+ | ------------------------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
164
+ | `includeBooleans` | `boolean` | `false` | When `true`, also enforce nullish checks on variables typed as `boolean` (or `boolean \| null \| undefined`). Boolean expressions — comparisons, known boolean methods, literals, and negations — are always allowed regardless of this setting. |
165
+ | `allowFindUndefinedCheck` | `boolean` | `false` | When `true`, allow direct nullish comparisons on the result of `.find()` calls (e.g. `array.find(x => x) === undefined`). Useful because `.find()` legitimately returns `undefined` when no match is found. |
166
+
167
+ Example:
168
+
169
+ ```javascript
170
+ // Enable the rule with boolean variable checking
171
+ "local-rules/use-nullish-checks": ["error", { includeBooleans: true }]
172
+
173
+ // Allow .find() results to be compared with undefined/null directly
174
+ "local-rules/use-nullish-checks": ["error", { allowFindUndefinedCheck: true }]
175
+ ```
176
+
177
+ With `includeBooleans: true`:
178
+
179
+ ```typescript
180
+ const b: boolean = true;
181
+
182
+ // ❌ Error — boolean variable should use nonNullish()
183
+ if (b) {
184
+ }
185
+
186
+ // ✅ OK — boolean expression, always allowed
187
+ if (a === b) {
188
+ }
189
+ ```
190
+
191
+ With `allowFindUndefinedCheck: true`:
192
+
193
+ ```typescript
194
+ // ✅ OK — .find() result compared with undefined is allowed
195
+ if (array.find((item) => item.id === id) === undefined) {
196
+ }
197
+
198
+ // ✅ OK — .find() result compared with null is allowed
199
+ if (array.find((item) => item.id === id) !== null) {
200
+ }
201
+ ```
202
+
145
203
  ## 🛠️ TypeScript Support
146
204
 
147
205
  If your project uses TypeScript, make sure you have a `tsconfig.json` file in your project root.
@@ -83,7 +83,6 @@ export const eslintRules = [
83
83
  "newlines-between": "never",
84
84
  },
85
85
  ],
86
- "import/no-duplicates": ["error", { "prefer-inline": true }],
87
86
  "import/no-relative-parent-imports": "error",
88
87
 
89
88
  "object-shorthand": "error",
@@ -91,6 +90,7 @@ export const eslintRules = [
91
90
  "no-console": ["error", { allow: ["error", "warn"] }],
92
91
  "no-continue": "warn",
93
92
  "no-delete-var": "error",
93
+ "no-duplicate-imports": "error",
94
94
  "no-else-return": ["warn", { allowElseIf: false }],
95
95
  "no-restricted-imports": [
96
96
  "error",
@@ -1,6 +1,6 @@
1
1
  module.exports = {
2
2
  "no-svelte-store-in-api": require("./rules/no-svelte-store-in-api.cjs"),
3
- "use-option-type-wrapper": require("./rules/use-option-type-wrapper.cjs"),
3
+ "use-option-type-wrapper": require("./rules/use-nullish-type-wrapper.cjs"),
4
4
  "prefer-object-params": require("./rules/prefer-object-params.cjs"),
5
5
  "no-relative-imports": require("./rules/no-relative-imports.cjs"),
6
6
  "explicit-non-void-return-type": require("./rules/explicit-non-void-return-type.cjs"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfinity/eslint-config-oisy-wallet",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Shared ESLint configurations from the Oisy Wallet team",
6
6
  "repository": {
@@ -44,7 +44,8 @@
44
44
  "format:check": "prettier --check .",
45
45
  "lint": "eslint .",
46
46
  "lint:fix": "eslint --fix .",
47
- "test": "vitest --run"
47
+ "test": "vitest --run",
48
+ "update:peer": "npm update $(node -e \"const p=require('./package.json');console.log(Object.keys(p.peerDependencies).filter(d=>/eslint|prettier/.test(d)).join(' '))\")"
48
49
  },
49
50
  "keywords": [
50
51
  "eslint",
@@ -70,7 +71,6 @@
70
71
  "@typescript-eslint/eslint-plugin": "^8.56.0",
71
72
  "@vitest/eslint-plugin": "^1.6.9",
72
73
  "eslint": "^9.39.2",
73
- "eslint-config-love": "^151.0.0",
74
74
  "eslint-config-prettier": "^10.1.8",
75
75
  "eslint-plugin-import": "^2.32.0",
76
76
  "eslint-plugin-local-rules": "^3.0.2",
@@ -82,7 +82,8 @@
82
82
  "globals": "^17.3.0",
83
83
  "prettier": "^3.8.1",
84
84
  "prettier-plugin-organize-imports": "^4.3.0",
85
- "typescript": "^5"
85
+ "typescript": "^5",
86
+ "typescript-eslint": "^8.56.0"
86
87
  },
87
88
  "devDependencies": {
88
89
  "@types/node": "^25.3.0",
@@ -1,3 +1,21 @@
1
+ const ts = require("typescript");
2
+
3
+ const NULLISH_UTILS = new Set(["isNullish", "nonNullish"]);
4
+
5
+ // TODO: shall we expand it with `==` and `!=` ?
6
+ const EQ_OPS = new Set(["==="]);
7
+ const NOT_EQ_OPS = new Set(["!=="]);
8
+ const COMPARISON_OPS = new Set([...EQ_OPS, ...NOT_EQ_OPS]);
9
+ const BOOLEAN_BINARY_OPS = new Set([...COMPARISON_OPS, ">", "<", ">=", "<="]);
10
+ const KNOWN_BOOLEAN_METHODS = new Set([
11
+ "includes",
12
+ "startsWith",
13
+ "endsWith",
14
+ "test",
15
+ "some",
16
+ "every",
17
+ ]);
18
+
1
19
  module.exports = {
2
20
  meta: {
3
21
  type: "suggestion",
@@ -13,39 +31,340 @@ module.exports = {
13
31
  "Use nonNullish() instead of direct variable check for nullish checks.",
14
32
  },
15
33
  fixable: "code",
16
- schema: [],
34
+ schema: [
35
+ {
36
+ type: "object",
37
+ properties: {
38
+ includeBooleans: {
39
+ type: "boolean",
40
+ description:
41
+ "When true, also enforce nullish checks on variables typed as boolean or nullish boolean. " +
42
+ "Boolean expressions (comparisons, known boolean methods, literals, negations) are always allowed. " +
43
+ "Defaults to false.",
44
+ },
45
+ allowFindUndefinedCheck: {
46
+ type: "boolean",
47
+ description:
48
+ "When true, allow direct nullish comparisons on the result of .find() calls " +
49
+ "(e.g. array.find(x => x) === undefined). " +
50
+ "Defaults to false.",
51
+ },
52
+ },
53
+ additionalProperties: false,
54
+ },
55
+ ],
17
56
  },
18
57
 
19
58
  create: (context) => {
20
- const binaryCheck = (node) => {
21
- if (node.type === "BinaryExpression") {
22
- return (
23
- (node.operator === "===" || node.operator === "!==") &&
24
- ((node.right.type === "Identifier" &&
25
- node.right.name === "undefined") ||
26
- (node.right.type === "Literal" && node.right.value === null))
27
- );
59
+ const [options] = context.options;
60
+
61
+ const shouldLintBooleans = options?.includeBooleans ?? false;
62
+
63
+ const allowFindUndefinedCheck = options?.allowFindUndefinedCheck ?? false;
64
+
65
+ const { sourceCode } = context;
66
+
67
+ const parserServices = sourceCode?.parserServices ?? context.parserServices;
68
+
69
+ const checker = parserServices?.program?.getTypeChecker();
70
+
71
+ const isNullishUtilityCall = (node) =>
72
+ node.type === "CallExpression" &&
73
+ node.callee.type === "Identifier" &&
74
+ NULLISH_UTILS.has(node.callee.name);
75
+
76
+ const hasKnownBooleanMethodCall = (node) =>
77
+ node.type === "CallExpression" &&
78
+ node.callee.type === "MemberExpression" &&
79
+ node.callee.property.type === "Identifier" &&
80
+ KNOWN_BOOLEAN_METHODS.has(node.callee.property.name);
81
+
82
+ const isBooleanBinaryExpression = (node) =>
83
+ node.type === "BinaryExpression" && BOOLEAN_BINARY_OPS.has(node.operator);
84
+
85
+ const isBooleanTypeAnnotation = (typeAnn) => {
86
+ if (!typeAnn) {
87
+ return false;
88
+ }
89
+
90
+ if (typeAnn.type === "TSBooleanKeyword") {
91
+ return true;
92
+ }
93
+
94
+ if (typeAnn.type !== "TSUnionType") {
95
+ return false;
96
+ }
97
+
98
+ return typeAnn.types.every(
99
+ (typeNode) =>
100
+ typeNode.type === "TSBooleanKeyword" ||
101
+ typeNode.type === "TSNullKeyword" ||
102
+ typeNode.type === "TSUndefinedKeyword" ||
103
+ (typeNode.type === "TSLiteralType" &&
104
+ typeof typeNode.literal?.value === "boolean"),
105
+ );
106
+ };
107
+
108
+ const findVariableTypeAnnotation = ({ scope, variableName }) => {
109
+ if (!scope) {
110
+ return;
111
+ }
112
+
113
+ const variable = scope.set.get(variableName);
114
+ const [def] = variable?.defs ?? [];
115
+
116
+ const typeAnn =
117
+ def?.node?.id?.typeAnnotation?.typeAnnotation ??
118
+ def?.node?.typeAnnotation?.typeAnnotation;
119
+
120
+ return (
121
+ typeAnn ??
122
+ findVariableTypeAnnotation({ scope: scope.upper, variableName })
123
+ );
124
+ };
125
+
126
+ const isBooleanTypeFromScope = (node) => {
127
+ if (node.type === "Literal" && typeof node.value === "boolean") {
128
+ return true;
129
+ }
130
+
131
+ if (isBooleanBinaryExpression(node)) {
132
+ return true;
133
+ }
134
+
135
+ if (node.type === "UnaryExpression" && node.operator === "!") {
136
+ return true;
137
+ }
138
+
139
+ if (isNullishUtilityCall(node)) {
140
+ return true;
141
+ }
142
+
143
+ if (hasKnownBooleanMethodCall(node)) {
144
+ return true;
145
+ }
146
+
147
+ if (node.type !== "Identifier") {
148
+ return false;
149
+ }
150
+
151
+ const initialScope = sourceCode?.getScope
152
+ ? sourceCode.getScope(node)
153
+ : context.getScope();
154
+
155
+ const typeAnn = findVariableTypeAnnotation({
156
+ scope: initialScope,
157
+ variableName: node.name,
158
+ });
159
+
160
+ return isBooleanTypeAnnotation(typeAnn);
161
+ };
162
+
163
+ const isBooleanTypeFromTs = (node) => {
164
+ if (!checker || !parserServices) {
165
+ return false;
166
+ }
167
+
168
+ const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node);
169
+ const type = checker.getTypeAtLocation(tsNode);
170
+
171
+ const isStrictBoolean = (typeToCheck) =>
172
+ (typeToCheck.getFlags() &
173
+ (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)) !==
174
+ 0;
175
+
176
+ if (isStrictBoolean(type)) {
177
+ return true;
178
+ }
179
+
180
+ return (
181
+ type.isUnion() &&
182
+ type.types.every(
183
+ (typeToCheck) =>
184
+ (typeToCheck.getFlags() &
185
+ (ts.TypeFlags.Boolean |
186
+ ts.TypeFlags.BooleanLiteral |
187
+ ts.TypeFlags.Null |
188
+ ts.TypeFlags.Undefined)) !==
189
+ 0,
190
+ )
191
+ );
192
+ };
193
+
194
+ const isBooleanExpression = (node) =>
195
+ isBooleanBinaryExpression(node) ||
196
+ hasKnownBooleanMethodCall(node) ||
197
+ isNullishUtilityCall(node) ||
198
+ (node.type === "Literal" && typeof node.value === "boolean") ||
199
+ (node.type === "UnaryExpression" && node.operator === "!");
200
+
201
+ const shouldTreatAsBooleanCondition = (node) => {
202
+ if (shouldLintBooleans) {
203
+ return isBooleanExpression(node);
204
+ }
205
+
206
+ try {
207
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
208
+ return isBooleanTypeFromTs(node) || isBooleanTypeFromScope(node);
209
+ } catch {
210
+ return isBooleanTypeFromScope(node);
211
+ }
212
+ };
213
+
214
+ const isNullishLiteral = (expression) =>
215
+ (expression.type === "Identifier" && expression.name === "undefined") ||
216
+ (expression.type === "Literal" && expression.value === null);
217
+
218
+ const isFindCall = (node) =>
219
+ node.type === "CallExpression" &&
220
+ node.callee.type === "MemberExpression" &&
221
+ node.callee.property.type === "Identifier" &&
222
+ node.callee.property.name === "find";
223
+
224
+ const getNullishComparisonTarget = (node) => {
225
+ if (node.type !== "BinaryExpression") {
226
+ return;
227
+ }
228
+
229
+ if (!COMPARISON_OPS.has(node.operator)) {
230
+ return;
231
+ }
232
+
233
+ if (isNullishLiteral(node.right)) {
234
+ return node.left;
235
+ }
236
+
237
+ if (isNullishLiteral(node.left)) {
238
+ return node.right;
28
239
  }
29
240
  };
30
241
 
31
- const binaryReportCheck = (node) => {
242
+ const getNullishReplacement = (operator) =>
243
+ EQ_OPS.has(operator) ? "isNullish" : "nonNullish";
244
+
245
+ const report = ({ node, messageId, replacementFn, fixNode }) => {
32
246
  context.report({
33
247
  node,
34
- messageId: node.operator === "===" ? "isNullish" : "nonNullish",
248
+ messageId,
35
249
  fix: (fixer) =>
36
250
  fixer.replaceText(
37
251
  node,
38
- `${node.operator === "===" ? "isNullish" : "nonNullish"}(${context.sourceCode.getText(node.left)})`,
252
+ `${replacementFn}(${sourceCode ? sourceCode.getText(fixNode) : context.getSourceCode().getText(fixNode)})`,
39
253
  ),
40
254
  });
41
255
  };
42
256
 
257
+ const checkCondition = (node) => {
258
+ if (!node) {
259
+ return;
260
+ }
261
+
262
+ if (isNullishUtilityCall(node)) {
263
+ return;
264
+ }
265
+
266
+ if (getNullishComparisonTarget(node)) {
267
+ return;
268
+ }
269
+
270
+ if (node.type === "UnaryExpression" && node.operator === "!") {
271
+ return;
272
+ }
273
+
274
+ if (node.type === "LogicalExpression") {
275
+ checkCondition(node.left);
276
+
277
+ checkCondition(node.right);
278
+
279
+ return;
280
+ }
281
+
282
+ if (shouldTreatAsBooleanCondition(node)) {
283
+ return;
284
+ }
285
+
286
+ report({
287
+ node,
288
+ messageId: "nonNullish",
289
+ replacementFn: "nonNullish",
290
+ fixNode: node,
291
+ });
292
+ };
293
+
294
+ // ESLint AST puts the condition of if/while/for/ternary in `node.test`, so we analyse that specifically
295
+ const checkTestCondition = (node) => {
296
+ if (node.test) {
297
+ checkCondition(node.test);
298
+ }
299
+ };
300
+
43
301
  return {
44
302
  BinaryExpression: (node) => {
45
- if (binaryCheck(node)) {
46
- binaryReportCheck(node);
303
+ const target = getNullishComparisonTarget(node);
304
+
305
+ if (!target) {
306
+ return;
307
+ }
308
+
309
+ if (allowFindUndefinedCheck && isFindCall(target)) {
310
+ return;
311
+ }
312
+
313
+ const replacementFn = getNullishReplacement(node.operator);
314
+
315
+ report({
316
+ node,
317
+ messageId: replacementFn,
318
+ replacementFn,
319
+ fixNode: target,
320
+ });
321
+ },
322
+
323
+ UnaryExpression: (node) => {
324
+ if (
325
+ node.operator === "!" &&
326
+ node.argument.type === "UnaryExpression" &&
327
+ node.argument.operator === "!"
328
+ ) {
329
+ if (shouldTreatAsBooleanCondition(node.argument.argument)) {
330
+ return;
331
+ }
332
+
333
+ report({
334
+ node,
335
+ messageId: "nonNullish",
336
+ replacementFn: "nonNullish",
337
+ fixNode: node.argument.argument,
338
+ });
339
+
340
+ return;
341
+ }
342
+
343
+ if (node.operator === "!") {
344
+ if (shouldTreatAsBooleanCondition(node.argument)) {
345
+ return;
346
+ }
347
+
348
+ const { parent } = node;
349
+
350
+ if (parent?.type === "UnaryExpression" && parent.operator === "!") {
351
+ return;
352
+ }
353
+
354
+ report({
355
+ node,
356
+ messageId: "isNullish",
357
+ replacementFn: "isNullish",
358
+ fixNode: node.argument,
359
+ });
47
360
  }
48
361
  },
362
+
363
+ IfStatement: checkTestCondition,
364
+ WhileStatement: checkTestCondition,
365
+ DoWhileStatement: checkTestCondition,
366
+ ForStatement: checkTestCondition,
367
+ ConditionalExpression: checkTestCondition,
49
368
  };
50
369
  },
51
370
  };
@@ -2,19 +2,19 @@ module.exports = {
2
2
  meta: {
3
3
  type: "suggestion",
4
4
  docs: {
5
- description: "Enforce use of Option<T> instead of T | null | undefined",
5
+ description: "Enforce use of Nullish<T> instead of T | null | undefined",
6
6
  category: "Best Practices",
7
7
  recommended: false,
8
8
  },
9
9
  messages: {
10
- useOption:
11
- "Use Option<{{ type }}> instead of {{ type }} | null | undefined.",
10
+ useNullish:
11
+ "Use Nullish<{{ type }}> instead of {{ type }} | null | undefined.",
12
12
  },
13
13
  fixable: "code",
14
14
  schema: [],
15
15
  },
16
16
  create: (context) => {
17
- const checkForOptionType = (node) => {
17
+ const checkForNullishType = (node) => {
18
18
  if (
19
19
  node.typeAnnotation.type === "TSUnionType" &&
20
20
  node.typeAnnotation.types.length === 3 &&
@@ -34,12 +34,12 @@ module.exports = {
34
34
  try {
35
35
  context.report({
36
36
  node,
37
- messageId: "useOption",
37
+ messageId: "useNullish",
38
38
  data: {
39
39
  type: typeText,
40
40
  },
41
41
  fix: (fixer) =>
42
- fixer.replaceText(node.typeAnnotation, `Option<${typeText}>`),
42
+ fixer.replaceText(node.typeAnnotation, `Nullish<${typeText}>`),
43
43
  });
44
44
  } catch (e) {
45
45
  console.error(e);
@@ -52,10 +52,10 @@ module.exports = {
52
52
 
53
53
  return {
54
54
  TSTypeAnnotation: (node) => {
55
- checkForOptionType(node);
55
+ checkForNullishType(node);
56
56
  },
57
57
  TSTypeAliasDeclaration: (node) => {
58
- checkForOptionType(node);
58
+ checkForNullishType(node);
59
59
  },
60
60
  };
61
61
  },