@blumintinc/eslint-plugin-blumint 1.20.89 → 1.20.91
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
CHANGED
|
@@ -78,6 +78,46 @@ function nearestManifestDeclaresModule(startDir) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* `jest.mock` hoists its module factory above the file's imports;
|
|
83
|
+
* `doMock`/`setMock` register a factory of the same shape at call time. The
|
|
84
|
+
* hoist rejects a factory that reads any out-of-scope binding whose name does
|
|
85
|
+
* not begin with `mock`, which is what puts the injected `assertSafe` import
|
|
86
|
+
* out of reach inside one.
|
|
87
|
+
*/
|
|
88
|
+
const MOCK_REGISTRARS = new Set(['mock', 'doMock', 'setMock']);
|
|
89
|
+
/** Whether the call registers a module factory with `jest`. */
|
|
90
|
+
function isMockRegistrarCall(node) {
|
|
91
|
+
const { callee } = node;
|
|
92
|
+
if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression || callee.computed) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const { object, property } = callee;
|
|
96
|
+
return (object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
97
|
+
object.name === 'jest' &&
|
|
98
|
+
property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
99
|
+
MOCK_REGISTRARS.has(property.name));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether the node sits inside the factory a jest registrar hoists — the second
|
|
103
|
+
* argument of the call. The module specifier that precedes it is evaluated in
|
|
104
|
+
* place and keeps its access to the file's imports, so only the factory subtree
|
|
105
|
+
* is out of reach.
|
|
106
|
+
*/
|
|
107
|
+
function isInsideMockFactory(node) {
|
|
108
|
+
let child = node;
|
|
109
|
+
let parent = node.parent;
|
|
110
|
+
while (parent) {
|
|
111
|
+
if (parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
112
|
+
parent.arguments[1] === child &&
|
|
113
|
+
isMockRegistrarCall(parent)) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
child = parent;
|
|
117
|
+
parent = parent.parent;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
81
121
|
/** Names that read as a positional sequence rather than a keyed record. */
|
|
82
122
|
const ARRAY_LIKE_NAME = /^(array|arr|items|elements|list|collection|data)s?$/i;
|
|
83
123
|
/**
|
|
@@ -365,6 +405,18 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
365
405
|
if (isReportSuppressed(node)) {
|
|
366
406
|
return null;
|
|
367
407
|
}
|
|
408
|
+
// A jest registrar's factory is hoisted above every import in the
|
|
409
|
+
// file, so the injected `import { assertSafe }` binds too late for
|
|
410
|
+
// the emitted call: the hoist allows a factory to read only globals
|
|
411
|
+
// and `mock`-prefixed bindings, and rejects the module at transform
|
|
412
|
+
// time otherwise, taking the whole suite down with it. Declining —
|
|
413
|
+
// and leaving the import unclaimed for a violation that does fix —
|
|
414
|
+
// keeps the report standing so the author reaches for a remedy the
|
|
415
|
+
// factory can hold, such as `jest.requireActual` inside it or a
|
|
416
|
+
// `mock`-prefixed import alias.
|
|
417
|
+
if (isInsideMockFactory(node)) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
368
420
|
// Resolve `assertSafe` through the scope chain at the fixed node. A
|
|
369
421
|
// binding that is not the helper import breaks the edit two ways: the
|
|
370
422
|
// injected import collides with a module-scope declaration (TS2440,
|
|
@@ -59,6 +59,52 @@ function bindsReactFragment(variable) {
|
|
|
59
59
|
declaration.source.value === REACT_MODULE);
|
|
60
60
|
}));
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* `jest.mock` hoists its module factory above the file's imports;
|
|
64
|
+
* `doMock`/`setMock` register a factory of the same shape at call time. The
|
|
65
|
+
* hoist rejects a factory that reads any out-of-scope binding whose name does
|
|
66
|
+
* not begin with `mock`, which is what puts the injected `Fragment` import out
|
|
67
|
+
* of reach inside one: the module fails at transform time
|
|
68
|
+
* (`Invalid variable access: Fragment`) and takes the whole suite down with it.
|
|
69
|
+
* The shorthand `<>` the rule rewrites away is the spelling that works there.
|
|
70
|
+
*
|
|
71
|
+
* The report still fires, because remedies the factory can hold exist —
|
|
72
|
+
* `const { Fragment } = jest.requireActual('react')` inside it, or a
|
|
73
|
+
* `mock`-prefixed import alias — and only the fix is withheld.
|
|
74
|
+
*/
|
|
75
|
+
const MOCK_REGISTRARS = new Set(['mock', 'doMock', 'setMock']);
|
|
76
|
+
/** Whether the call registers a module factory with `jest`. */
|
|
77
|
+
function isMockRegistrarCall(node) {
|
|
78
|
+
const { callee } = node;
|
|
79
|
+
if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression || callee.computed) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const { object, property } = callee;
|
|
83
|
+
return (object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
84
|
+
object.name === 'jest' &&
|
|
85
|
+
property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
86
|
+
MOCK_REGISTRARS.has(property.name));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether the node sits inside the factory a jest registrar hoists — the second
|
|
90
|
+
* argument of the call. The module specifier that precedes it is evaluated in
|
|
91
|
+
* place and keeps its access to the file's imports, so only the factory subtree
|
|
92
|
+
* is out of reach.
|
|
93
|
+
*/
|
|
94
|
+
function isInsideMockFactory(node) {
|
|
95
|
+
let child = node;
|
|
96
|
+
let parent = node.parent;
|
|
97
|
+
while (parent) {
|
|
98
|
+
if (parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
99
|
+
parent.arguments[1] === child &&
|
|
100
|
+
isMockRegistrarCall(parent)) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
child = parent;
|
|
104
|
+
parent = parent.parent;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
62
108
|
exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
63
109
|
name: 'prefer-fragment-component',
|
|
64
110
|
meta: {
|
|
@@ -227,6 +273,13 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
227
273
|
if (isReportSuppressed(node)) {
|
|
228
274
|
return null;
|
|
229
275
|
}
|
|
276
|
+
// A hoisted jest factory cannot reach the injected import, so
|
|
277
|
+
// the rewrite is withheld inside one. Checking the inner
|
|
278
|
+
// fragment covers the outer React.Fragment as well, since a
|
|
279
|
+
// parent of a node in the factory is in the factory too.
|
|
280
|
+
if (isInsideMockFactory(node)) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
230
283
|
// Both tags are rewritten to the same bare name, so resolving at
|
|
231
284
|
// the inner fragment — the deeper of the two scopes — covers the
|
|
232
285
|
// outer one too. Every bail precedes the latch so a withheld edit
|
|
@@ -294,6 +347,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
294
347
|
if (isReportSuppressed(node)) {
|
|
295
348
|
return null;
|
|
296
349
|
}
|
|
350
|
+
// A hoisted jest factory cannot reach the injected import, so the
|
|
351
|
+
// rewrite is withheld inside one.
|
|
352
|
+
if (isInsideMockFactory(node)) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
297
355
|
// Every bail precedes the latch so a withheld edit cannot make a
|
|
298
356
|
// later fix believe the import is already handled.
|
|
299
357
|
if (!resolvesToReactFragment(node)) {
|
|
@@ -350,6 +408,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
350
408
|
if (isReportSuppressed(node.name)) {
|
|
351
409
|
return null;
|
|
352
410
|
}
|
|
411
|
+
// A hoisted jest factory cannot reach the injected import, so
|
|
412
|
+
// the rewrite is withheld inside one.
|
|
413
|
+
if (isInsideMockFactory(node)) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
353
416
|
// Every bail precedes the latch so a withheld edit cannot make a
|
|
354
417
|
// later fix believe the import is already handled.
|
|
355
418
|
if (!resolvesToReactFragment(node)) {
|
|
@@ -391,6 +454,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
391
454
|
if (isReportSuppressed(node.name)) {
|
|
392
455
|
return null;
|
|
393
456
|
}
|
|
457
|
+
// A hoisted jest factory cannot reach the injected import, so the
|
|
458
|
+
// rewrite is withheld inside one.
|
|
459
|
+
if (isInsideMockFactory(node)) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
394
462
|
// Every bail precedes the latch so a withheld edit cannot make a
|
|
395
463
|
// later fix believe the import is already handled.
|
|
396
464
|
if (!resolvesToReactFragment(node)) {
|
|
@@ -5,6 +5,7 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
7
|
const importInsertion_1 = require("../utils/importInsertion");
|
|
8
|
+
const importRemoval_1 = require("../utils/importRemoval");
|
|
8
9
|
const DEEP_COMPARE_MODULE = '@blumintinc/use-deep-compare';
|
|
9
10
|
const DEEP_COMPARE_HOOK = 'useDeepCompareMemo';
|
|
10
11
|
// Consider these as memoizing hooks producing stable references
|
|
@@ -218,49 +219,6 @@ function ensureDeepCompareImportFixes(context, fixer) {
|
|
|
218
219
|
(0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor), importText),
|
|
219
220
|
];
|
|
220
221
|
}
|
|
221
|
-
function findVariableInScope(scope, name) {
|
|
222
|
-
if (!scope)
|
|
223
|
-
return null;
|
|
224
|
-
const found = scope.variables?.find((v) => v.name === name);
|
|
225
|
-
if (found)
|
|
226
|
-
return found;
|
|
227
|
-
if (Array.isArray(scope.childScopes)) {
|
|
228
|
-
for (const child of scope.childScopes) {
|
|
229
|
-
const v = findVariableInScope(child, name);
|
|
230
|
-
if (v)
|
|
231
|
-
return v;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
function isIdentifierReferenced(sourceCode, name) {
|
|
237
|
-
const scope = sourceCode.scopeManager?.globalScope;
|
|
238
|
-
if (!scope)
|
|
239
|
-
return true;
|
|
240
|
-
const variable = findVariableInScope(scope, name);
|
|
241
|
-
if (!variable)
|
|
242
|
-
return false;
|
|
243
|
-
return variable.references.some((ref) => ref.identifier && ref.identifier.name === name);
|
|
244
|
-
}
|
|
245
|
-
function removeImportSpecifierFixes(sourceCode, fixer, importDecl, specifier) {
|
|
246
|
-
const fixes = [];
|
|
247
|
-
if (importDecl.specifiers.length === 1) {
|
|
248
|
-
fixes.push(fixer.remove(importDecl));
|
|
249
|
-
return fixes;
|
|
250
|
-
}
|
|
251
|
-
const tokenAfter = sourceCode.getTokenAfter(specifier);
|
|
252
|
-
const tokenBefore = sourceCode.getTokenBefore(specifier);
|
|
253
|
-
if (tokenAfter && tokenAfter.value === ',') {
|
|
254
|
-
fixes.push(fixer.removeRange([specifier.range[0], tokenAfter.range[1]]));
|
|
255
|
-
}
|
|
256
|
-
else if (tokenBefore && tokenBefore.value === ',') {
|
|
257
|
-
fixes.push(fixer.removeRange([tokenBefore.range[0], specifier.range[1]]));
|
|
258
|
-
}
|
|
259
|
-
else {
|
|
260
|
-
fixes.push(fixer.remove(specifier));
|
|
261
|
-
}
|
|
262
|
-
return fixes;
|
|
263
|
-
}
|
|
264
222
|
function isImportedIdentifier(context, name) {
|
|
265
223
|
const sourceCode = context.sourceCode;
|
|
266
224
|
const program = sourceCode.ast;
|
|
@@ -436,6 +394,28 @@ exports.preferUseDeepCompareMemo = (0, createRule_1.createRule)({
|
|
|
436
394
|
!bindsHookImport(existing, findDeepCompareMemoImport(context.sourceCode.ast))) {
|
|
437
395
|
return null;
|
|
438
396
|
}
|
|
397
|
+
// The callee is the text this fix deletes, so it is also the text
|
|
398
|
+
// whatever carried the hook stops being read from: `useMemo` for a
|
|
399
|
+
// bare call, `React` for a member call. A binding left with no
|
|
400
|
+
// reference at all is unbound here, in this same fix — stripping
|
|
401
|
+
// its last use and keeping the declaration trades this rule's
|
|
402
|
+
// report for an unused-import one, and nothing re-reports that debt
|
|
403
|
+
// once the rewrite has resolved the original violation.
|
|
404
|
+
//
|
|
405
|
+
// Orphanhood is judged against this one callee's removal and the
|
|
406
|
+
// file as it stands. A second `useMemo` call site keeps the
|
|
407
|
+
// specifier, even one this rule also reports: that sibling may be
|
|
408
|
+
// `eslint-disable`d (suppression is applied after a rule emits its
|
|
409
|
+
// reports, so its fix never runs) or lose its fix to a conflict, and
|
|
410
|
+
// either way the surviving call would spell a name nothing binds. A
|
|
411
|
+
// later pass, reading a source where the sibling is already
|
|
412
|
+
// converted, finds the specifier orphaned and removes it then.
|
|
413
|
+
const importRemoval = (0, importRemoval_1.planOrphanedImportRemoval)(context.sourceCode, [node.callee.range]);
|
|
414
|
+
// No plan means a binding is orphaned yet cannot be unbound safely,
|
|
415
|
+
// so the rewrite stays too: the report without a fixer is the lesser
|
|
416
|
+
// damage.
|
|
417
|
+
if (!importRemoval)
|
|
418
|
+
return null;
|
|
439
419
|
const fixes = [];
|
|
440
420
|
// Replace callee
|
|
441
421
|
if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
@@ -446,26 +426,7 @@ exports.preferUseDeepCompareMemo = (0, createRule_1.createRule)({
|
|
|
446
426
|
}
|
|
447
427
|
// Ensure import exists
|
|
448
428
|
fixes.push(...ensureDeepCompareImportFixes(context, fixer));
|
|
449
|
-
|
|
450
|
-
const sourceCode = context.sourceCode;
|
|
451
|
-
const program = sourceCode.ast;
|
|
452
|
-
const reactImport = program.body.find((n) => n.type === utils_1.AST_NODE_TYPES.ImportDeclaration &&
|
|
453
|
-
n.source.value === 'react');
|
|
454
|
-
if (reactImport) {
|
|
455
|
-
// remove named useMemo if unused
|
|
456
|
-
const useMemoSpec = reactImport.specifiers.find((s) => s.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
|
|
457
|
-
s.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
458
|
-
s.imported.name === 'useMemo');
|
|
459
|
-
if (useMemoSpec &&
|
|
460
|
-
!isIdentifierReferenced(sourceCode, useMemoSpec.local.name)) {
|
|
461
|
-
fixes.push(...removeImportSpecifierFixes(sourceCode, fixer, reactImport, useMemoSpec));
|
|
462
|
-
}
|
|
463
|
-
const defaultSpec = reactImport.specifiers.find((s) => s.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier);
|
|
464
|
-
if (defaultSpec &&
|
|
465
|
-
!isIdentifierReferenced(sourceCode, defaultSpec.local.name)) {
|
|
466
|
-
fixes.push(...removeImportSpecifierFixes(sourceCode, fixer, reactImport, defaultSpec));
|
|
467
|
-
}
|
|
468
|
-
}
|
|
429
|
+
fixes.push(...importRemoval.map((range) => fixer.removeRange([range[0], range[1]])));
|
|
469
430
|
return fixes;
|
|
470
431
|
},
|
|
471
432
|
});
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.91",
|
|
4
|
+
"date": "2026-08-03T15:15:42.831Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "prefer-use-deep-compare-memo",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1661
|
|
11
|
+
],
|
|
12
|
+
"summary": "unbind the orphaned useMemo import (closes #1661)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.90",
|
|
18
|
+
"date": "2026-08-03T13:37:18.271Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "enforce-assert-safe-object-key",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1659
|
|
25
|
+
],
|
|
26
|
+
"summary": "decline the fix inside a jest mock factory (closes #1659)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "prefer-fragment-component",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
1660
|
|
33
|
+
],
|
|
34
|
+
"summary": "decline the fix inside a jest mock factory (closes #1660)"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
},
|
|
2
38
|
{
|
|
3
39
|
"version": "1.20.89",
|
|
4
40
|
"date": "2026-08-03T12:31:01.823Z",
|