@blumintinc/eslint-plugin-blumint 1.20.126 → 1.20.127
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/lib/index.js +1 -1
- package/lib/rules/enforce-boolean-naming-prefixes.js +42 -19
- package/lib/rules/enforce-firestore-doc-ref-generic.js +22 -4
- package/lib/rules/enforce-firestore-set-merge.js +30 -2
- package/lib/rules/fast-deep-equal-over-microdiff.js +38 -24
- package/lib/rules/no-direct-function-state.d.ts +2 -1
- package/lib/rules/no-direct-function-state.js +93 -17
- package/package.json +3 -1
- package/release-manifest.json +46 -0
package/lib/index.js
CHANGED
|
@@ -261,26 +261,49 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
261
261
|
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, callee), 'Boolean');
|
|
262
262
|
return !variable || variable.defs.length === 0;
|
|
263
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* An optional link wraps the member access or call it belongs to in a
|
|
266
|
+
* `ChainExpression`, so `user?.isLoggedIn` and `canDelete?.('x')` reach a
|
|
267
|
+
* value check as a wrapper node rather than as the member/call the check
|
|
268
|
+
* looks for.
|
|
269
|
+
*
|
|
270
|
+
* Unwrapping is the right answer for THIS rule even though `a?.b` is
|
|
271
|
+
* `boolean | undefined` where `a.b` is `boolean`: the rule's remedy is a
|
|
272
|
+
* rename of the binding, which never changes how the initializer
|
|
273
|
+
* short-circuits, and the rule already requires the prefix on
|
|
274
|
+
* possibly-undefined booleans elsewhere — `deletable?: boolean` on a
|
|
275
|
+
* parameter, class property or method all report, and so does
|
|
276
|
+
* `const loggedIn = user && user.isLoggedIn`, whose type is exactly the
|
|
277
|
+
* `boolean | undefined` an optional chain produces. A value that may be
|
|
278
|
+
* absent is where an unprefixed name misleads most, because a falsy result
|
|
279
|
+
* no longer distinguishes "false" from "receiver was missing".
|
|
280
|
+
*/
|
|
281
|
+
function unwrapChainExpression(expression) {
|
|
282
|
+
return expression.type === utils_1.AST_NODE_TYPES.ChainExpression
|
|
283
|
+
? expression.expression
|
|
284
|
+
: expression;
|
|
285
|
+
}
|
|
264
286
|
/**
|
|
265
287
|
* Check if a node is initialized with a boolean value
|
|
266
288
|
*/
|
|
267
289
|
function hasInitialBooleanValue(node) {
|
|
268
290
|
if (node.type === utils_1.AST_NODE_TYPES.VariableDeclarator && node.init) {
|
|
291
|
+
const init = unwrapChainExpression(node.init);
|
|
269
292
|
// Check for direct boolean literal initialization
|
|
270
|
-
if (
|
|
271
|
-
typeof
|
|
293
|
+
if (init.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
294
|
+
typeof init.value === 'boolean') {
|
|
272
295
|
return true;
|
|
273
296
|
}
|
|
274
297
|
// Check for logical expressions that typically return boolean
|
|
275
|
-
if (
|
|
276
|
-
BOOLEANISH_BINARY_OPERATORS.has(
|
|
298
|
+
if (init.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
|
|
299
|
+
BOOLEANISH_BINARY_OPERATORS.has(init.operator)) {
|
|
277
300
|
return true;
|
|
278
301
|
}
|
|
279
302
|
// Check for logical expressions (&&)
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
const left = evaluateBooleanishExpression(
|
|
283
|
-
const right = evaluateBooleanishExpression(
|
|
303
|
+
if (init.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
|
|
304
|
+
init.operator === '&&') {
|
|
305
|
+
const left = evaluateBooleanishExpression(init.left);
|
|
306
|
+
const right = evaluateBooleanishExpression(init.right);
|
|
284
307
|
// If both sides are boolean, the result is boolean.
|
|
285
308
|
if (left === 'boolean' && right === 'boolean') {
|
|
286
309
|
return true;
|
|
@@ -297,10 +320,10 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
297
320
|
// Special case for logical OR (||) - only consider it boolean if:
|
|
298
321
|
// 1. It's used with boolean literals or
|
|
299
322
|
// 2. It's not used with array/object literals as fallbacks
|
|
300
|
-
if (
|
|
301
|
-
|
|
323
|
+
if (init.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
|
|
324
|
+
init.operator === '||') {
|
|
302
325
|
// Check if right side is a non-boolean literal (array, object, string, number)
|
|
303
|
-
const rightSide =
|
|
326
|
+
const rightSide = init.right;
|
|
304
327
|
if (rightSide.type === utils_1.AST_NODE_TYPES.ArrayExpression ||
|
|
305
328
|
rightSide.type === utils_1.AST_NODE_TYPES.ObjectExpression ||
|
|
306
329
|
(rightSide.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
@@ -314,7 +337,7 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
314
337
|
}
|
|
315
338
|
// For other cases, we need to be more careful
|
|
316
339
|
// If we can determine the left side is a boolean, then it's a boolean variable
|
|
317
|
-
const leftSide =
|
|
340
|
+
const leftSide = unwrapChainExpression(init.left);
|
|
318
341
|
if ((leftSide.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
319
342
|
typeof leftSide.value === 'boolean') ||
|
|
320
343
|
(leftSide.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
|
@@ -336,20 +359,20 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
336
359
|
return false;
|
|
337
360
|
}
|
|
338
361
|
// Check for unary expressions with ! operator
|
|
339
|
-
if (
|
|
340
|
-
|
|
362
|
+
if (init.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
|
363
|
+
init.operator === '!') {
|
|
341
364
|
return true;
|
|
342
365
|
}
|
|
343
366
|
// Check for function calls that might return boolean
|
|
344
|
-
if (
|
|
345
|
-
|
|
367
|
+
if (init.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
368
|
+
init.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
346
369
|
// A coercion through the global `Boolean` is as definitive as `!!x`,
|
|
347
370
|
// and its callee carries no approved prefix for the name heuristic
|
|
348
371
|
// below to recognize.
|
|
349
|
-
if (isGlobalBooleanCall(
|
|
372
|
+
if (isGlobalBooleanCall(init)) {
|
|
350
373
|
return true;
|
|
351
374
|
}
|
|
352
|
-
const calleeName =
|
|
375
|
+
const calleeName = init.callee.name;
|
|
353
376
|
const lowerCallee = calleeName.toLowerCase();
|
|
354
377
|
// For assert*-style utilities, only treat as boolean if we can confirm boolean return type
|
|
355
378
|
if (lowerCallee.startsWith('assert')) {
|
|
@@ -1110,7 +1133,7 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
1110
1133
|
const variableDeclarator = node.parent;
|
|
1111
1134
|
if (variableDeclarator?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
1112
1135
|
variableDeclarator.init) {
|
|
1113
|
-
const init = variableDeclarator.init;
|
|
1136
|
+
const init = unwrapChainExpression(variableDeclarator.init);
|
|
1114
1137
|
// Check for direct boolean initialization
|
|
1115
1138
|
if (init.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
1116
1139
|
typeof init.value === 'boolean') {
|
|
@@ -39,6 +39,22 @@ const referenceTypeNameOf = (typeName) => {
|
|
|
39
39
|
}
|
|
40
40
|
return undefined;
|
|
41
41
|
};
|
|
42
|
+
/**
|
|
43
|
+
* The expression an optional link wraps, so a receiver spelled with `?.` is
|
|
44
|
+
* read as the expression it actually evaluates.
|
|
45
|
+
*
|
|
46
|
+
* `a?.b` interposes a `ChainExpression` between the member/call and its real
|
|
47
|
+
* parent. That link perturbs nullability, not the document schema:
|
|
48
|
+
* `db?.collection<T>('x')` has type `CollectionReference<T> | undefined`, whose
|
|
49
|
+
* schema is still `T`, never `DocumentData`. Leaving the wrapper in place makes
|
|
50
|
+
* a typed collection look unrecognizable, and the `.doc()` that inherits its
|
|
51
|
+
* schema draws a missing-generic report whose only remedy — `doc<T>(...)` —
|
|
52
|
+
* does not compile, since `CollectionReference<T>.doc` declares no type
|
|
53
|
+
* parameters.
|
|
54
|
+
*/
|
|
55
|
+
function unwrapOptionalChain(node) {
|
|
56
|
+
return node.type === utils_1.AST_NODE_TYPES.ChainExpression ? node.expression : node;
|
|
57
|
+
}
|
|
42
58
|
/**
|
|
43
59
|
* The type declaration a statement makes, looking through `export`.
|
|
44
60
|
*
|
|
@@ -344,9 +360,10 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
|
|
|
344
360
|
return false;
|
|
345
361
|
}
|
|
346
362
|
const isTypedCollectionReferenceCache = new Map();
|
|
347
|
-
function isTypedCollectionReference(
|
|
348
|
-
if (!
|
|
363
|
+
function isTypedCollectionReference(receiver) {
|
|
364
|
+
if (!receiver)
|
|
349
365
|
return false;
|
|
366
|
+
const node = unwrapOptionalChain(receiver);
|
|
350
367
|
if (isTypedCollectionReferenceCache.has(node)) {
|
|
351
368
|
return isTypedCollectionReferenceCache.get(node);
|
|
352
369
|
}
|
|
@@ -511,10 +528,11 @@ exports.enforceFirestoreDocRefGeneric = (0, createRule_1.createRule)({
|
|
|
511
528
|
}
|
|
512
529
|
return isTypedCollectionInitializer(declarator.init);
|
|
513
530
|
}
|
|
514
|
-
function isTypedCollectionInitializer(
|
|
515
|
-
if (!
|
|
531
|
+
function isTypedCollectionInitializer(initializer) {
|
|
532
|
+
if (!initializer) {
|
|
516
533
|
return false;
|
|
517
534
|
}
|
|
535
|
+
const init = unwrapOptionalChain(initializer);
|
|
518
536
|
// An explicit assertion states the schema just as an annotation does.
|
|
519
537
|
if (init.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
|
|
520
538
|
return hasCollectionReferenceType(init.typeAnnotation);
|
|
@@ -193,9 +193,37 @@ function isPrimitiveLiteral(node) {
|
|
|
193
193
|
typeof value === 'boolean' ||
|
|
194
194
|
typeof value === 'bigint');
|
|
195
195
|
}
|
|
196
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Strips the wrappers that leave an expression's shape intact, assertions plus
|
|
198
|
+
* the `ChainExpression` an optional link parks on the outermost node of a chain.
|
|
199
|
+
*
|
|
200
|
+
* The two are kept apart rather than merged into `unwrapAssertions` because a
|
|
201
|
+
* chain is not erased at runtime: `admin?.firestore()` evaluates to the handle
|
|
202
|
+
* or to `undefined`, so a caller reasoning about the *value* an expression
|
|
203
|
+
* produces — `isPrimitiveLiteral` — must keep seeing the chain. A caller
|
|
204
|
+
* reasoning about the *shape* it is written in, which is what the evidence scan
|
|
205
|
+
* asks, must look through it: the optional link decides whether the handle is
|
|
206
|
+
* produced, never which instance it is.
|
|
207
|
+
*/
|
|
208
|
+
function unwrapTransparent(node) {
|
|
209
|
+
const stripped = unwrapAssertions(node);
|
|
210
|
+
return stripped.type === utils_1.AST_NODE_TYPES.ChainExpression
|
|
211
|
+
? unwrapTransparent(stripped.expression)
|
|
212
|
+
: stripped;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Whether a declarator is initialized from a `<x>.firestore()` call.
|
|
216
|
+
*
|
|
217
|
+
* Both optional spellings — `admin?.firestore()` and `admin.firestore?.()` —
|
|
218
|
+
* parse as `ChainExpression > CallExpression`, so testing the initializer's own
|
|
219
|
+
* type read `ChainExpression` and answered no. Since this scan is the last
|
|
220
|
+
* detector left for a bare-identifier receiver, that miss dropped the report
|
|
221
|
+
* silently, and it hit the more careful spellings hardest: `admin.apps[0]?.
|
|
222
|
+
* firestore()` and `admin.app()?.firestore()` are the idiomatic admin-SDK
|
|
223
|
+
* singleton bootstrap, not exotic code.
|
|
224
|
+
*/
|
|
197
225
|
function initializesFirestore(declarator) {
|
|
198
|
-
const
|
|
226
|
+
const init = declarator.init ? unwrapTransparent(declarator.init) : null;
|
|
199
227
|
return (init?.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
200
228
|
init.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
201
229
|
init.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
@@ -66,6 +66,17 @@ function bindsFastDeepEqual(variable) {
|
|
|
66
66
|
fastDeepEqualModules_1.FAST_DEEP_EQUAL_MODULES.has(String(declaration.source.value)));
|
|
67
67
|
}));
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* The expression an optional chain wraps. ESTree interposes a
|
|
71
|
+
* `ChainExpression` between an optional member/call and its real parent, so
|
|
72
|
+
* `changes?.length` reaches an operand test as a ChainExpression while
|
|
73
|
+
* `changes.length` reaches it as a MemberExpression. Every arm that inspects an
|
|
74
|
+
* operand has to unwrap first, or one spelling of a single idiom escapes the
|
|
75
|
+
* rule while the other is reported.
|
|
76
|
+
*/
|
|
77
|
+
function unwrapChain(node) {
|
|
78
|
+
return node.type === utils_1.AST_NODE_TYPES.ChainExpression ? node.expression : node;
|
|
79
|
+
}
|
|
69
80
|
/**
|
|
70
81
|
* Whether two edits touch the same characters. ESLint sorts the fixes of one
|
|
71
82
|
* report and asserts each starts at or after the end of the previous one, so
|
|
@@ -107,7 +118,6 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
|
|
|
107
118
|
* violations still emit `isEqual(...)` calls, leaving them unbound.
|
|
108
119
|
*/
|
|
109
120
|
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
110
|
-
const isChainExpression = (node) => node.type === utils_1.AST_NODE_TYPES.ChainExpression;
|
|
111
121
|
function isMicrodiffCallee(callee) {
|
|
112
122
|
if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
113
123
|
callee.name === microdiffImportName) {
|
|
@@ -265,11 +275,16 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
|
|
|
265
275
|
'!=',
|
|
266
276
|
];
|
|
267
277
|
if (operators.includes(node.operator)) {
|
|
278
|
+
// `diff(a, b)?.length`, `changes?.length` and `diff?.(a, b).length`
|
|
279
|
+
// each reach the operand as a ChainExpression, so the unwrap is what
|
|
280
|
+
// keeps the comparison spellings of one idiom from diverging.
|
|
281
|
+
const left = unwrapChain(node.left);
|
|
282
|
+
const right = unwrapChain(node.right);
|
|
268
283
|
// side A: MemberExpression .length, side B: 0
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const { diffCall } = getMicrodiffCallFromLengthAccess(
|
|
284
|
+
if (right.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
285
|
+
right.value === 0 &&
|
|
286
|
+
left.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
287
|
+
const { diffCall } = getMicrodiffCallFromLengthAccess(left);
|
|
273
288
|
if (diffCall) {
|
|
274
289
|
return {
|
|
275
290
|
isEquality: node.operator === '===' || node.operator === '==',
|
|
@@ -278,10 +293,10 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
|
|
|
278
293
|
}
|
|
279
294
|
}
|
|
280
295
|
// side A: 0, side B: MemberExpression .length
|
|
281
|
-
if (
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const { diffCall } = getMicrodiffCallFromLengthAccess(
|
|
296
|
+
if (left.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
297
|
+
left.value === 0 &&
|
|
298
|
+
right.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
299
|
+
const { diffCall } = getMicrodiffCallFromLengthAccess(right);
|
|
285
300
|
if (diffCall) {
|
|
286
301
|
return {
|
|
287
302
|
isEquality: node.operator === '===' || node.operator === '==',
|
|
@@ -294,10 +309,7 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
|
|
|
294
309
|
// Check for unary expressions like !diff(a, b).length or !changes.length (including optional chaining)
|
|
295
310
|
if (node.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
|
296
311
|
node.operator === '!') {
|
|
297
|
-
const
|
|
298
|
-
const target = isChainExpression(argumentNode)
|
|
299
|
-
? argumentNode.expression
|
|
300
|
-
: argumentNode;
|
|
312
|
+
const target = unwrapChain(node.argument);
|
|
301
313
|
if (target.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
302
314
|
const { diffCall } = getMicrodiffCallFromLengthAccess(target);
|
|
303
315
|
if (diffCall) {
|
|
@@ -315,13 +327,18 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
|
|
|
315
327
|
* Try to find the identifier used as `<id>.length` for the given equality node
|
|
316
328
|
*/
|
|
317
329
|
function getLengthIdentifierFromNode(node) {
|
|
330
|
+
// The operands are unwrapped for the same reason the detection arm
|
|
331
|
+
// unwraps them: `changes?.length` hides the identifier behind a
|
|
332
|
+
// ChainExpression. Missing it here does not silence the report — it
|
|
333
|
+
// rewrites the comparison and leaves the now-dead
|
|
334
|
+
// `const changes = diff(a, b);` behind.
|
|
335
|
+
const isLengthMember = (n) => n.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
336
|
+
!n.computed &&
|
|
337
|
+
n.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
338
|
+
n.property.name === 'length';
|
|
318
339
|
if (node.type === utils_1.AST_NODE_TYPES.BinaryExpression) {
|
|
319
|
-
const left = node.left;
|
|
320
|
-
const right = node.right;
|
|
321
|
-
const isLengthMember = (n) => n.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
322
|
-
!n.computed &&
|
|
323
|
-
n.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
324
|
-
n.property.name === 'length';
|
|
340
|
+
const left = unwrapChain(node.left);
|
|
341
|
+
const right = unwrapChain(node.right);
|
|
325
342
|
if (isLengthMember(left) &&
|
|
326
343
|
left.object.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
327
344
|
return left.object;
|
|
@@ -332,11 +349,8 @@ exports.fastDeepEqualOverMicrodiff = (0, createRule_1.createRule)({
|
|
|
332
349
|
}
|
|
333
350
|
}
|
|
334
351
|
if (node.type === utils_1.AST_NODE_TYPES.UnaryExpression) {
|
|
335
|
-
const arg = node.argument;
|
|
336
|
-
if (arg
|
|
337
|
-
!arg.computed &&
|
|
338
|
-
arg.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
339
|
-
arg.property.name === 'length' &&
|
|
352
|
+
const arg = unwrapChain(node.argument);
|
|
353
|
+
if (isLengthMember(arg) &&
|
|
340
354
|
arg.object.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
341
355
|
return arg.object;
|
|
342
356
|
}
|
|
@@ -4,5 +4,6 @@ type Options = [
|
|
|
4
4
|
functionPatterns?: string[];
|
|
5
5
|
}
|
|
6
6
|
];
|
|
7
|
-
|
|
7
|
+
type MessageIds = 'noDirectFunctionState' | 'noDirectFunctionStateAssertion';
|
|
8
|
+
export declare const noDirectFunctionState: TSESLint.RuleModule<MessageIds, Options, TSESLint.RuleListener>;
|
|
8
9
|
export {};
|
|
@@ -104,13 +104,68 @@ function useStateHasFunctionTypeParam(callNode, resolveFrom) {
|
|
|
104
104
|
}
|
|
105
105
|
return isFunctionTypeAnnotation(typeParams.params[0], resolveFrom);
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The expression a runtime-transparent wrapper stands in for.
|
|
109
|
+
*
|
|
110
|
+
* `a?.b` parses as a `ChainExpression` around the member read, and `x as T`,
|
|
111
|
+
* `<T>x`, `x satisfies T`, `x!` and `fn<T>` each wrap their operand in a node
|
|
112
|
+
* that is erased before execution. Every one of them evaluates to exactly what
|
|
113
|
+
* its operand evaluates to, so a question about what a setter argument *is* has
|
|
114
|
+
* to be asked of the operand — asking the wrapper answers about the wrapper and
|
|
115
|
+
* silently loses the argument, in whichever direction the caller's default
|
|
116
|
+
* happens to point.
|
|
117
|
+
*
|
|
118
|
+
* Recursive because the wrappers stack: `props?.onClose as any` is a
|
|
119
|
+
* `TSAsExpression` over a `ChainExpression` over the member read.
|
|
120
|
+
*/
|
|
121
|
+
function unwrapTransparent(node) {
|
|
122
|
+
switch (node.type) {
|
|
123
|
+
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
124
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
125
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
126
|
+
case utils_1.AST_NODE_TYPES.TSTypeAssertion:
|
|
127
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
128
|
+
case utils_1.AST_NODE_TYPES.TSInstantiationExpression:
|
|
129
|
+
return unwrapTransparent(node.expression);
|
|
130
|
+
default:
|
|
131
|
+
return node;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether wrapping this argument in a thunk would leave an arrow whose body is
|
|
136
|
+
* a type assertion.
|
|
137
|
+
*
|
|
138
|
+
* `no-type-assertion-returns` is `error` in the same recommended config and
|
|
139
|
+
* reports exactly that shape, so emitting `setX(() => props.onClose as any)`
|
|
140
|
+
* would trade one error for another and leave `eslint --fix` non-converging.
|
|
141
|
+
* Moving the assertion outside the thunk instead — `(() => x) as T` — is not an
|
|
142
|
+
* option either: it asserts a different value, and for a `T` that is neither
|
|
143
|
+
* assignable to nor from `() => T` it does not even compile. So the report
|
|
144
|
+
* stands without a fix and names the hoist that does converge (verified end to
|
|
145
|
+
* end under the whole recommended config).
|
|
146
|
+
*
|
|
147
|
+
* Only a top-level assertion matters. An assertion nested inside the argument
|
|
148
|
+
* (`(props as any).onClose`) leaves the thunk returning a member read, which
|
|
149
|
+
* that rule exempts.
|
|
150
|
+
*/
|
|
151
|
+
function thunkWouldReturnAssertion(arg) {
|
|
152
|
+
return (arg.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
153
|
+
arg.type === utils_1.AST_NODE_TYPES.TSTypeAssertion);
|
|
154
|
+
}
|
|
107
155
|
/**
|
|
108
156
|
* Returns true when the AST node is a safe value to pass to a setter — i.e.,
|
|
109
157
|
* NOT a bare identifier or member expression that could be a function reference.
|
|
110
158
|
* Arrow/function expressions are always safe (they are intentional).
|
|
111
159
|
* Literals, null, undefined, call expressions, arrays, objects are all safe.
|
|
160
|
+
*
|
|
161
|
+
* The argument is unwrapped first so the carve-outs below are decided by what
|
|
162
|
+
* actually reaches the setter. Without it a wrapped argument falls to the
|
|
163
|
+
* `default` arm, which is the *unsafe* verdict — so `factory?.build()` would
|
|
164
|
+
* lose the deliberate CallExpression exemption and be rewritten into
|
|
165
|
+
* `() => factory?.build()`, deferring the call into a React updater.
|
|
112
166
|
*/
|
|
113
|
-
function isDefinitelySafeArg(
|
|
167
|
+
function isDefinitelySafeArg(argNode) {
|
|
168
|
+
const node = unwrapTransparent(argNode);
|
|
114
169
|
switch (node.type) {
|
|
115
170
|
case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
|
|
116
171
|
case utils_1.AST_NODE_TYPES.FunctionExpression:
|
|
@@ -137,11 +192,6 @@ function isDefinitelySafeArg(node) {
|
|
|
137
192
|
case utils_1.AST_NODE_TYPES.ArrayExpression:
|
|
138
193
|
case utils_1.AST_NODE_TYPES.ObjectExpression:
|
|
139
194
|
return true;
|
|
140
|
-
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
141
|
-
case utils_1.AST_NODE_TYPES.TSTypeAssertion:
|
|
142
|
-
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
143
|
-
// Unwrap type assertions and recurse
|
|
144
|
-
return isDefinitelySafeArg(node.expression);
|
|
145
195
|
default:
|
|
146
196
|
// MemberExpression, Identifier (non-undefined), etc. are NOT definitely safe
|
|
147
197
|
return false;
|
|
@@ -169,8 +219,14 @@ function matchesFunctionPattern(name, patterns) {
|
|
|
169
219
|
* Extracts the identifier name from an argument node for pattern matching.
|
|
170
220
|
* For MemberExpression like `obj.handler`, returns `handler`.
|
|
171
221
|
* For Identifier like `myCallback`, returns `myCallback`.
|
|
222
|
+
*
|
|
223
|
+
* The argument is unwrapped first: `props?.onClose` and `props.onClose as any`
|
|
224
|
+
* name the same property as `props.onClose`. Under an untyped `useState` the
|
|
225
|
+
* name pattern is the only live signal, so returning `null` for a wrapped
|
|
226
|
+
* argument silences the rule entirely rather than merely weakening it.
|
|
172
227
|
*/
|
|
173
|
-
function getArgName(
|
|
228
|
+
function getArgName(argNode) {
|
|
229
|
+
const node = unwrapTransparent(argNode);
|
|
174
230
|
if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
175
231
|
return node.name;
|
|
176
232
|
}
|
|
@@ -236,6 +292,10 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
|
|
|
236
292
|
noDirectFunctionState: 'What\'s wrong: "{{argText}}" is passed directly to "{{setterName}}", but React invokes a function argument as a functional updater (prev => next) instead of storing it. ' +
|
|
237
293
|
'Why it matters: The function will be called with the previous state value and its return value stored — a silent bug with no error. ' +
|
|
238
294
|
'How to fix: Wrap it in a thunk so React stores the function as a value: {{setterName}}(() => {{argText}})',
|
|
295
|
+
noDirectFunctionStateAssertion: 'What\'s wrong: "{{argText}}" is passed directly to "{{setterName}}", but React invokes a function argument as a functional updater (prev => next) instead of storing it. ' +
|
|
296
|
+
'Why it matters: The function will be called with the previous state value and its return value stored — a silent bug with no error. ' +
|
|
297
|
+
'How to fix: Give the asserted value a name, then store that name through a thunk: const value = {{argText}}; {{setterName}}(() => value). ' +
|
|
298
|
+
'The assertion is hoisted out because a thunk that returned it would be an arrow returning a cast, which no-type-assertion-returns reports.',
|
|
239
299
|
},
|
|
240
300
|
},
|
|
241
301
|
defaultOptions: [{ functionPatterns: DEFAULT_FUNCTION_PATTERNS }],
|
|
@@ -252,12 +312,18 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
|
|
|
252
312
|
VariableDeclarator(node) {
|
|
253
313
|
// Look for `const [state, setter] = useState<T>(...)` or
|
|
254
314
|
// `const [state, setter] = React.useState<T>(...)`.
|
|
255
|
-
if (node.id.type !== utils_1.AST_NODE_TYPES.ArrayPattern ||
|
|
256
|
-
!node.init ||
|
|
257
|
-
node.init.type !== utils_1.AST_NODE_TYPES.CallExpression) {
|
|
315
|
+
if (node.id.type !== utils_1.AST_NODE_TYPES.ArrayPattern || !node.init) {
|
|
258
316
|
return;
|
|
259
317
|
}
|
|
260
|
-
|
|
318
|
+
// `React?.useState(...)` and `useState?.(...)` wrap the call in a
|
|
319
|
+
// ChainExpression. Reading `init` without unwrapping registers no
|
|
320
|
+
// setter, which blinds every setter call in the file rather than just
|
|
321
|
+
// this declaration.
|
|
322
|
+
const init = unwrapTransparent(node.init);
|
|
323
|
+
if (init.type !== utils_1.AST_NODE_TYPES.CallExpression) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const callNode = init;
|
|
261
327
|
const callee = callNode.callee;
|
|
262
328
|
const isUseStateCall = (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
263
329
|
callee.name === 'useState') ||
|
|
@@ -304,7 +370,8 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
|
|
|
304
370
|
// skip without further checks
|
|
305
371
|
if (isDefinitelySafeArg(arg))
|
|
306
372
|
return;
|
|
307
|
-
// At this point arg is an Identifier (non-undefined) or MemberExpression
|
|
373
|
+
// At this point arg is an Identifier (non-undefined) or MemberExpression,
|
|
374
|
+
// possibly behind transparent wrappers (`?.`, `as`, `!`).
|
|
308
375
|
// Decide whether it is a function reference.
|
|
309
376
|
const isFunctionTypedState = setterFunctionTyped.get(setterName) === true;
|
|
310
377
|
if (isFunctionTypedState) {
|
|
@@ -320,11 +387,14 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
|
|
|
320
387
|
reportAndFix(node, arg, setterName, context);
|
|
321
388
|
return;
|
|
322
389
|
}
|
|
323
|
-
// Check if the identifier is bound to a function in scope
|
|
324
|
-
|
|
325
|
-
|
|
390
|
+
// Check if the identifier is bound to a function in scope. This reads
|
|
391
|
+
// the same argument the two signals above do, so it has to see through
|
|
392
|
+
// the same wrappers: `myCallback!` still references `myCallback`.
|
|
393
|
+
const unwrappedArg = unwrapTransparent(arg);
|
|
394
|
+
if (unwrappedArg.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
395
|
+
unwrappedArg.name !== 'undefined') {
|
|
326
396
|
const scope = context.getScope();
|
|
327
|
-
if (isIdentifierBoundToFunction(
|
|
397
|
+
if (isIdentifierBoundToFunction(unwrappedArg.name, scope)) {
|
|
328
398
|
reportAndFix(node, arg, setterName, context);
|
|
329
399
|
return;
|
|
330
400
|
}
|
|
@@ -336,14 +406,20 @@ exports.noDirectFunctionState = (0, createRule_1.createRule)({
|
|
|
336
406
|
function reportAndFix(callNode, arg, setterName, context) {
|
|
337
407
|
const sourceCode = context.getSourceCode();
|
|
338
408
|
const argText = sourceCode.getText(arg);
|
|
409
|
+
const returnsAssertion = thunkWouldReturnAssertion(arg);
|
|
339
410
|
context.report({
|
|
340
411
|
node: callNode,
|
|
341
|
-
messageId:
|
|
412
|
+
messageId: returnsAssertion
|
|
413
|
+
? 'noDirectFunctionStateAssertion'
|
|
414
|
+
: 'noDirectFunctionState',
|
|
342
415
|
data: {
|
|
343
416
|
argText,
|
|
344
417
|
setterName,
|
|
345
418
|
},
|
|
346
419
|
fix(fixer) {
|
|
420
|
+
if (returnsAssertion) {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
347
423
|
return fixer.replaceText(arg, `() => ${argText}`);
|
|
348
424
|
},
|
|
349
425
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blumintinc/eslint-plugin-blumint",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.127",
|
|
4
4
|
"description": "Custom eslint rules for use within BluMint",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Brodie McGuire",
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"@semantic-release/release-notes-generator": "14.1.0",
|
|
73
73
|
"@types/eslint": "8.37.0",
|
|
74
74
|
"@types/jest": "29.5.14",
|
|
75
|
+
"@types/js-yaml": "4.0.9",
|
|
75
76
|
"@types/node": "22.20.0",
|
|
76
77
|
"@types/semver": "7.5.8",
|
|
77
78
|
"@typescript-eslint/eslint-plugin": "5.34.0",
|
|
@@ -96,6 +97,7 @@
|
|
|
96
97
|
"husky": "9.1.7",
|
|
97
98
|
"jest": "29.7.0",
|
|
98
99
|
"jest-junit": "14.0.0",
|
|
100
|
+
"js-yaml": "4.3.1",
|
|
99
101
|
"jsonc-eslint-parser": "2.3.0",
|
|
100
102
|
"markdown-eslint-parser": "1.2.1",
|
|
101
103
|
"npm-run-all": "4.1.5",
|
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,50 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.127",
|
|
4
|
+
"date": "2026-08-07T02:31:31.437Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-boolean-naming-prefixes",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1828
|
|
11
|
+
],
|
|
12
|
+
"summary": "read an optional-chained initializer as boolean (closes #1828)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "enforce-firestore-doc-ref-generic",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1826
|
|
19
|
+
],
|
|
20
|
+
"summary": "resolve a typed collection through an optional link (closes #1826)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "enforce-firestore-set-merge",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
1827
|
|
27
|
+
],
|
|
28
|
+
"summary": "see a Firestore handle through an optional link (closes #1827)"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "fast-deep-equal-over-microdiff",
|
|
32
|
+
"changeType": "fix",
|
|
33
|
+
"issues": [
|
|
34
|
+
1825
|
|
35
|
+
],
|
|
36
|
+
"summary": "unwrap ChainExpression in the binary comparison arm (closes #1825)"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "no-direct-function-state",
|
|
40
|
+
"changeType": "fix",
|
|
41
|
+
"issues": [
|
|
42
|
+
1824
|
|
43
|
+
],
|
|
44
|
+
"summary": "read the setter argument through transparent wrappers (closes #1824)"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
2
48
|
{
|
|
3
49
|
"version": "1.20.126",
|
|
4
50
|
"date": "2026-08-06T22:42:22.372Z",
|