@blumintinc/eslint-plugin-blumint 1.20.97 → 1.20.99
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-centralized-mock-firestore.js +193 -65
- package/lib/rules/enforce-memoize-async.js +54 -0
- package/lib/rules/enforce-microdiff.js +140 -56
- package/lib/rules/no-redundant-usecallback-wrapper.js +43 -71
- package/package.json +1 -1
- package/release-manifest.json +46 -0
package/lib/index.js
CHANGED
|
@@ -3,7 +3,154 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.enforceCentralizedMockFirestore = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const importInsertion_1 = require("../utils/importInsertion");
|
|
6
7
|
const MOCK_FIRESTORE_PATH = '../../../../../__test-utils__/mockFirestore';
|
|
8
|
+
function isHorizontalWhitespace(character) {
|
|
9
|
+
return character === ' ' || character === '\t';
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The span a retired declaration occupies, widened to its whole line only when
|
|
13
|
+
* the declaration is the sole occupant of that line. Anything else sharing the
|
|
14
|
+
* line — a live statement, a trailing comment, an `eslint-disable-line`
|
|
15
|
+
* directive — is outside the declaration's range and must survive the
|
|
16
|
+
* retirement: unrelated statements would become unbound references, and a
|
|
17
|
+
* deleted comment is the one damage class a formatter can never restore.
|
|
18
|
+
*/
|
|
19
|
+
function retirementEdit(text, start, end) {
|
|
20
|
+
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
|
21
|
+
const newlineIndex = text.indexOf('\n', end);
|
|
22
|
+
const lineEnd = newlineIndex === -1 ? text.length : newlineIndex;
|
|
23
|
+
const before = text.slice(lineStart, start);
|
|
24
|
+
const after = text.slice(end, lineEnd);
|
|
25
|
+
if (/^\s*$/.test(before) && /^\s*$/.test(after)) {
|
|
26
|
+
return {
|
|
27
|
+
start: lineStart,
|
|
28
|
+
end: Math.min(lineEnd + 1, text.length),
|
|
29
|
+
text: '',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (/^\s*$/.test(before)) {
|
|
33
|
+
// Code follows on this line, so the retired declaration hands over the
|
|
34
|
+
// indentation it was occupying rather than leaving the successor adrift.
|
|
35
|
+
let cursor = end;
|
|
36
|
+
while (isHorizontalWhitespace(text[cursor])) {
|
|
37
|
+
cursor++;
|
|
38
|
+
}
|
|
39
|
+
return { start, end: cursor, text: '' };
|
|
40
|
+
}
|
|
41
|
+
// Code precedes on this line; absorb the gap so no trailing space is left.
|
|
42
|
+
let cursor = start;
|
|
43
|
+
while (cursor > lineStart && isHorizontalWhitespace(text[cursor - 1])) {
|
|
44
|
+
cursor--;
|
|
45
|
+
}
|
|
46
|
+
return { start: cursor, end, text: '' };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Node types whose children are free-standing statements. A declaration
|
|
50
|
+
* anywhere else — a `for (const mockFirestore of …)` head, say — cannot be
|
|
51
|
+
* excised without leaving the enclosing construct malformed.
|
|
52
|
+
*/
|
|
53
|
+
const STATEMENT_CONTAINERS = new Set([
|
|
54
|
+
utils_1.AST_NODE_TYPES.Program,
|
|
55
|
+
utils_1.AST_NODE_TYPES.BlockStatement,
|
|
56
|
+
utils_1.AST_NODE_TYPES.StaticBlock,
|
|
57
|
+
utils_1.AST_NODE_TYPES.SwitchCase,
|
|
58
|
+
utils_1.AST_NODE_TYPES.TSModuleBlock,
|
|
59
|
+
]);
|
|
60
|
+
/**
|
|
61
|
+
* Widens a declaration to the `export` that fronts it, whose keyword lives
|
|
62
|
+
* outside the declaration's own range and would otherwise be stranded.
|
|
63
|
+
*/
|
|
64
|
+
function retirableStatement(declaration) {
|
|
65
|
+
const statement = declaration.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration
|
|
66
|
+
? declaration.parent
|
|
67
|
+
: declaration;
|
|
68
|
+
return statement.parent &&
|
|
69
|
+
STATEMENT_CONTAINERS.has(statement.parent.type)
|
|
70
|
+
? statement
|
|
71
|
+
: undefined;
|
|
72
|
+
}
|
|
73
|
+
function retiredSpan(node) {
|
|
74
|
+
const parent = node.parent;
|
|
75
|
+
if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
|
|
76
|
+
const { declarations } = parent;
|
|
77
|
+
if (declarations.length === 1) {
|
|
78
|
+
const statement = retirableStatement(parent);
|
|
79
|
+
if (!statement) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
start: statement.range[0],
|
|
84
|
+
end: statement.range[1],
|
|
85
|
+
whole: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const index = declarations.indexOf(node);
|
|
89
|
+
if (index > 0) {
|
|
90
|
+
return {
|
|
91
|
+
start: declarations[index - 1].range[1],
|
|
92
|
+
end: node.range[1],
|
|
93
|
+
whole: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (index === 0) {
|
|
97
|
+
// Reach forward to the next declarator instead of backward past the
|
|
98
|
+
// `const`, whose trailing space still belongs to the survivors.
|
|
99
|
+
return {
|
|
100
|
+
start: node.range[0],
|
|
101
|
+
end: declarations[1].range[0],
|
|
102
|
+
whole: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
if (node.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
|
|
108
|
+
return { start: node.range[0], end: node.range[1], whole: true };
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Collapses edits that touch, so an overlap can never rewrite a range twice.
|
|
114
|
+
*/
|
|
115
|
+
function mergeEdits(edits) {
|
|
116
|
+
const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
117
|
+
const merged = [];
|
|
118
|
+
for (const edit of sorted) {
|
|
119
|
+
const last = merged[merged.length - 1];
|
|
120
|
+
if (last && edit.start < last.end) {
|
|
121
|
+
last.end = Math.max(last.end, edit.end);
|
|
122
|
+
last.text += edit.text;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
merged.push({ ...edit });
|
|
126
|
+
}
|
|
127
|
+
return merged;
|
|
128
|
+
}
|
|
129
|
+
function editOffset(anchor) {
|
|
130
|
+
return anchor.kind === 'before' ? anchor.target.range[0] : anchor.index;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Where the import may be spliced in given the edits retiring the local mock.
|
|
134
|
+
*
|
|
135
|
+
* Widening to the anchor's line start is what lets the emitted
|
|
136
|
+
* `${indent}import …\n` leave the displaced statement on the indentation it
|
|
137
|
+
* already had, but that is sound only while whitespace is all that precedes
|
|
138
|
+
* the anchor: a `'use client';` sharing the anchor's line would be demoted by
|
|
139
|
+
* an insertion at column 0. When the resulting position still falls inside a
|
|
140
|
+
* retirement — the anchor statement is itself the declaration being retired,
|
|
141
|
+
* and the retirement claims the indentation ahead of it — the insertion moves
|
|
142
|
+
* to that edit's start, since ESLint rejects a fix nested inside another.
|
|
143
|
+
*/
|
|
144
|
+
function importPlacement(sourceCode, anchor, edits) {
|
|
145
|
+
const anchorStart = editOffset(anchor);
|
|
146
|
+
const lineStart = sourceCode.text.lastIndexOf('\n', anchorStart - 1) + 1;
|
|
147
|
+
const placement = /^[ \t]*$/.test(sourceCode.text.slice(lineStart, anchorStart))
|
|
148
|
+
? (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor)
|
|
149
|
+
: anchor;
|
|
150
|
+
const offset = editOffset(placement);
|
|
151
|
+
const enclosing = edits.find((edit) => edit.start < offset && offset < edit.end);
|
|
152
|
+
return enclosing ? { kind: 'index', index: enclosing.start } : placement;
|
|
153
|
+
}
|
|
7
154
|
exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
8
155
|
name: 'enforce-centralized-mock-firestore',
|
|
9
156
|
meta: {
|
|
@@ -149,85 +296,66 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
149
296
|
requiredPath: MOCK_FIRESTORE_PATH,
|
|
150
297
|
},
|
|
151
298
|
fix(fixer) {
|
|
152
|
-
// Instead of trying to modify the code incrementally, we'll generate the entire fixed code
|
|
153
299
|
const originalText = sourceCode.getText();
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (node.parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
|
|
167
|
-
// If it's the only declarator, remove the entire declaration
|
|
168
|
-
if (node.parent.declarations.length === 1) {
|
|
169
|
-
const declStartLine = sourceCode.getLocFromIndex(node.parent.range[0]).line - 1;
|
|
170
|
-
const declEndLine = sourceCode.getLocFromIndex(node.parent.range[1]).line - 1;
|
|
171
|
-
for (let i = declStartLine; i <= declEndLine; i++) {
|
|
172
|
-
linesToRemove.add(i);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
else {
|
|
176
|
-
// Otherwise, just remove this declarator
|
|
177
|
-
for (let i = startLine; i <= endLine; i++) {
|
|
178
|
-
linesToRemove.add(i);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
else if (node.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
|
|
183
|
-
// Remove class property
|
|
184
|
-
for (let i = startLine; i <= endLine; i++) {
|
|
185
|
-
linesToRemove.add(i);
|
|
186
|
-
}
|
|
300
|
+
// Retire each declaration by its own character range. Line
|
|
301
|
+
// indices would take every other occupant of those lines with
|
|
302
|
+
// them.
|
|
303
|
+
const removals = [];
|
|
304
|
+
for (const node of mockFirestoreNodes) {
|
|
305
|
+
const span = retiredSpan(node);
|
|
306
|
+
if (!span) {
|
|
307
|
+
// A declaration that cannot be excised cleanly gets no
|
|
308
|
+
// autofix at all: leaving it behind while adding the import
|
|
309
|
+
// would report forever, and cutting it anyway would emit
|
|
310
|
+
// code that no longer parses.
|
|
311
|
+
return null;
|
|
187
312
|
}
|
|
188
|
-
|
|
313
|
+
removals.push(span.whole
|
|
314
|
+
? retirementEdit(originalText, span.start, span.end)
|
|
315
|
+
: { start: span.start, end: span.end, text: '' });
|
|
316
|
+
}
|
|
189
317
|
// Replace custom mockFirestore references with the standard one
|
|
190
318
|
const replacements = [];
|
|
191
319
|
// Add replacements for custom mockFirestore names
|
|
192
320
|
customMockFirestoreCallExpressions.forEach((node) => {
|
|
193
321
|
if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
194
|
-
replacements.push(
|
|
195
|
-
node.callee.range[0],
|
|
196
|
-
node.callee.
|
|
197
|
-
'mockFirestore',
|
|
198
|
-
|
|
322
|
+
replacements.push({
|
|
323
|
+
start: node.callee.range[0],
|
|
324
|
+
end: node.callee.range[1],
|
|
325
|
+
text: 'mockFirestore',
|
|
326
|
+
});
|
|
199
327
|
}
|
|
200
328
|
});
|
|
201
329
|
// Add replacements for this.mockFirestore
|
|
202
330
|
thisExpressions.forEach((expr) => {
|
|
203
|
-
replacements.push(
|
|
204
|
-
expr.range[0],
|
|
205
|
-
|
|
206
|
-
'mockFirestore',
|
|
207
|
-
|
|
331
|
+
replacements.push({
|
|
332
|
+
start: expr.range[0],
|
|
333
|
+
end: expr.range[1],
|
|
334
|
+
text: 'mockFirestore',
|
|
335
|
+
});
|
|
208
336
|
});
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
//
|
|
220
|
-
const
|
|
221
|
-
.split('\n')
|
|
222
|
-
.filter((_, i) => !linesToRemove.has(i));
|
|
223
|
-
// Add the import statement at the beginning
|
|
337
|
+
// A reference inside a retired declaration goes away with it, so
|
|
338
|
+
// rewriting it would only fight the removal for the same range.
|
|
339
|
+
const survivingReplacements = replacements.filter((replacement) => !removals.some((removal) => replacement.start >= removal.start &&
|
|
340
|
+
replacement.end <= removal.end));
|
|
341
|
+
const edits = mergeEdits([...removals, ...survivingReplacements]);
|
|
342
|
+
// Every edit is bounded to the characters it owns. Rebuilding
|
|
343
|
+
// the file and writing it over the `Program` node instead would
|
|
344
|
+
// emit whatever precedes `Program.range[0]` — a header comment,
|
|
345
|
+
// a `@ts-nocheck`, a license block — a second time, and would
|
|
346
|
+
// claim a fix range spanning the file, which wins every pass-1
|
|
347
|
+
// fixer race and suppresses sibling rules' fixes.
|
|
348
|
+
const fixes = edits.map(({ start, end, text }) => fixer.replaceTextRange([start, end], text));
|
|
224
349
|
if (!hasCentralizedImport) {
|
|
225
|
-
|
|
350
|
+
// The shared anchor keeps the import below the file's
|
|
351
|
+
// prologue: spliced above a `'use client'` / `'use server'`
|
|
352
|
+
// directive it demotes the directive to a plain expression,
|
|
353
|
+
// and above a `#!` shebang it leaves the file unparseable.
|
|
354
|
+
const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
|
|
355
|
+
const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
|
|
356
|
+
fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, importPlacement(sourceCode, anchor, edits), `${indent}import { mockFirestore } from '${MOCK_FIRESTORE_PATH}';\n`));
|
|
226
357
|
}
|
|
227
|
-
|
|
228
|
-
const result = fixedLines.join('\n');
|
|
229
|
-
// Return the fixed text
|
|
230
|
-
return fixer.replaceText(sourceCode.ast, result);
|
|
358
|
+
return fixes;
|
|
231
359
|
},
|
|
232
360
|
});
|
|
233
361
|
}
|
|
@@ -43,6 +43,46 @@ function bindsMemoize(variable) {
|
|
|
43
43
|
ALLOWED_MEMOIZE_MODULES.has(String(declaration.source.value)));
|
|
44
44
|
}));
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* `jest.mock` hoists its module factory above the file's imports;
|
|
48
|
+
* `doMock`/`setMock` register a factory of the same shape at call time. The
|
|
49
|
+
* hoist rejects a factory that reads any out-of-scope binding whose name does
|
|
50
|
+
* not begin with `mock`, which is what puts a module-scope `Memoize` binding —
|
|
51
|
+
* injected or already present — out of reach inside one.
|
|
52
|
+
*/
|
|
53
|
+
const MOCK_REGISTRARS = new Set(['mock', 'doMock', 'setMock']);
|
|
54
|
+
/** Whether the call registers a module factory with `jest`. */
|
|
55
|
+
function isMockRegistrarCall(node) {
|
|
56
|
+
const { callee } = node;
|
|
57
|
+
if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression || callee.computed) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const { object, property } = callee;
|
|
61
|
+
return (object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
62
|
+
object.name === 'jest' &&
|
|
63
|
+
property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
64
|
+
MOCK_REGISTRARS.has(property.name));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether the node sits inside the factory a jest registrar hoists — the second
|
|
68
|
+
* argument of the call. The module specifier that precedes it is evaluated in
|
|
69
|
+
* place and keeps its access to the file's imports, so only the factory subtree
|
|
70
|
+
* is out of reach.
|
|
71
|
+
*/
|
|
72
|
+
function isInsideMockFactory(node) {
|
|
73
|
+
let child = node;
|
|
74
|
+
let parent = node.parent;
|
|
75
|
+
while (parent) {
|
|
76
|
+
if (parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
77
|
+
parent.arguments[1] === child &&
|
|
78
|
+
isMockRegistrarCall(parent)) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
child = parent;
|
|
82
|
+
parent = parent.parent;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
46
86
|
/**
|
|
47
87
|
* Whether a declared return type annotation promises no value: `void` or
|
|
48
88
|
* `Promise<void>`.
|
|
@@ -297,6 +337,20 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
|
|
|
297
337
|
if (isReportSuppressed(node)) {
|
|
298
338
|
return null;
|
|
299
339
|
}
|
|
340
|
+
// A jest registrar's factory is hoisted above every import in the
|
|
341
|
+
// file, so the decorator emitted inside one names a binding that
|
|
342
|
+
// does not exist yet: the hoist admits only globals and
|
|
343
|
+
// `mock`-prefixed bindings, and rejects the module at transform
|
|
344
|
+
// time otherwise, taking the whole suite down with it. That holds
|
|
345
|
+
// for an alias or namespace decorator too — those read a
|
|
346
|
+
// module-scope import the factory cannot reach either. Declining
|
|
347
|
+
// here, ahead of the import carrier claim below, leaves the import
|
|
348
|
+
// to a violation that does fix, and leaves the report standing so
|
|
349
|
+
// the author reaches for a remedy the factory can hold, such as
|
|
350
|
+
// decorating the real class the mock stands in for.
|
|
351
|
+
if (isInsideMockFactory(node)) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
300
354
|
const fixes = [];
|
|
301
355
|
const sourceCode = context.sourceCode;
|
|
302
356
|
// Determine which identifier to use for the decorator
|
|
@@ -207,6 +207,98 @@ function isRewrittenCallee(identifier) {
|
|
|
207
207
|
parent.callee === identifier &&
|
|
208
208
|
hasRewritableArity(parent));
|
|
209
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* The node types that open a scope of their own. The body walk stops at them
|
|
212
|
+
* because the emitted `diff` is only checked against the reported function's
|
|
213
|
+
* scope: a comparison inside a callback whose parameter is named `diff` would
|
|
214
|
+
* be rewritten into a call on that parameter, with no diagnostic to show for
|
|
215
|
+
* it.
|
|
216
|
+
*/
|
|
217
|
+
const FUNCTION_NODE_TYPES = new Set([
|
|
218
|
+
utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
|
|
219
|
+
utils_1.AST_NODE_TYPES.FunctionDeclaration,
|
|
220
|
+
utils_1.AST_NODE_TYPES.FunctionExpression,
|
|
221
|
+
]);
|
|
222
|
+
/**
|
|
223
|
+
* Whether an expression is a call of `JSON.stringify`. The callee's shape
|
|
224
|
+
* decides rather than the source text, so a `stringify` read off anything but
|
|
225
|
+
* `JSON` — a local serializer bound under the same property name — keeps its
|
|
226
|
+
* call site.
|
|
227
|
+
*/
|
|
228
|
+
function isJsonStringify(node) {
|
|
229
|
+
return (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
230
|
+
node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
231
|
+
node.callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
232
|
+
node.callee.object.name === 'JSON' &&
|
|
233
|
+
node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
234
|
+
node.callee.property.name === 'stringify');
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The stringify comparison an expression makes, or null for every other
|
|
238
|
+
* expression.
|
|
239
|
+
*
|
|
240
|
+
* A zero-argument `JSON.stringify()` yields null. The indexed argument read is
|
|
241
|
+
* typed non-optional, so nothing else forces the check, and there is no operand
|
|
242
|
+
* to hand `diff`.
|
|
243
|
+
*/
|
|
244
|
+
function toStringifyComparison(node) {
|
|
245
|
+
if (node.type !== utils_1.AST_NODE_TYPES.BinaryExpression ||
|
|
246
|
+
(node.operator !== '===' && node.operator !== '!==')) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
if (!isJsonStringify(node.left) || !isJsonStringify(node.right)) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const left = node.left.arguments[0];
|
|
253
|
+
const right = node.right.arguments[0];
|
|
254
|
+
if (!left || !right) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
return { node, left, right, isEqual: node.operator === '===' };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Every stringify comparison `body` makes in its own scope, nested functions
|
|
261
|
+
* excluded.
|
|
262
|
+
*
|
|
263
|
+
* A rewrite driven by the enclosing function's parameter list answers a
|
|
264
|
+
* question the source never asked: `JSON.stringify(a.settings) !==
|
|
265
|
+
* JSON.stringify(b.settings)` compares two properties, and the signature's
|
|
266
|
+
* operands widen that to the whole objects. Reading the comparison itself is
|
|
267
|
+
* also what makes a destructuring parameter a non-issue, since the operands
|
|
268
|
+
* name what to diff whatever the signature binds.
|
|
269
|
+
*/
|
|
270
|
+
function collectStringifyComparisons(body) {
|
|
271
|
+
const comparisons = [];
|
|
272
|
+
const pending = [body];
|
|
273
|
+
while (pending.length > 0) {
|
|
274
|
+
const current = pending.pop();
|
|
275
|
+
if (current !== body && FUNCTION_NODE_TYPES.has(current.type)) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const comparison = toStringifyComparison(current);
|
|
279
|
+
if (comparison) {
|
|
280
|
+
comparisons.push(comparison);
|
|
281
|
+
}
|
|
282
|
+
for (const key in current) {
|
|
283
|
+
// The parent link closes a cycle over every node in the file.
|
|
284
|
+
if (key === 'parent') {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const value = current[key];
|
|
288
|
+
if (Array.isArray(value)) {
|
|
289
|
+
value.forEach((child) => {
|
|
290
|
+
if (ASTHelpers_1.ASTHelpers.isNode(child)) {
|
|
291
|
+
pending.push(child);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
296
|
+
pending.push(value);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return comparisons;
|
|
301
|
+
}
|
|
210
302
|
/**
|
|
211
303
|
* Whether a bare `diff` written at `scope` reaches microdiff's function.
|
|
212
304
|
* Resolving through the scope chain catches both failure modes: a module-scope
|
|
@@ -354,6 +446,17 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
|
|
|
354
446
|
: '\n\n';
|
|
355
447
|
return (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, `${MICRODIFF_IMPORT}${separator}`);
|
|
356
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* The microdiff form of a stringify comparison: a change list whose
|
|
451
|
+
* emptiness carries the sense of the operator it replaces, so `===` becomes
|
|
452
|
+
* `.length === 0` and `!==` becomes `.length > 0`.
|
|
453
|
+
*/
|
|
454
|
+
function buildDiffComparison(comparison) {
|
|
455
|
+
const left = sourceCode.getText(comparison.left);
|
|
456
|
+
const right = sourceCode.getText(comparison.right);
|
|
457
|
+
const emptiness = comparison.isEqual ? '.length === 0' : '.length > 0';
|
|
458
|
+
return `${DIFF_NAME}(${left}, ${right})${emptiness}`;
|
|
459
|
+
}
|
|
357
460
|
// Add a specific set to track which import names are used
|
|
358
461
|
const usedImportNames = new Set();
|
|
359
462
|
// Check if a node is an object or array type
|
|
@@ -559,46 +662,29 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
|
|
|
559
662
|
return;
|
|
560
663
|
}
|
|
561
664
|
// Check for JSON.stringify comparison pattern
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
expr.callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
568
|
-
expr.callee.object.name === 'JSON' &&
|
|
569
|
-
expr.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
570
|
-
expr.callee.property.name === 'stringify');
|
|
571
|
-
};
|
|
572
|
-
if (isJsonStringify(node.left) && isJsonStringify(node.right)) {
|
|
573
|
-
const leftArg = node.left.arguments[0];
|
|
574
|
-
const rightArg = node.right.arguments[0];
|
|
575
|
-
// A zero-argument JSON.stringify() leaves these undefined. The
|
|
576
|
-
// indexed read is typed non-optional, so nothing forces the check.
|
|
577
|
-
if (!leftArg || !rightArg) {
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
if (isObjectOrArrayType(leftArg) && isObjectOrArrayType(rightArg)) {
|
|
581
|
-
reportedNodes.add(node);
|
|
582
|
-
const isEqual = node.operator === '===';
|
|
583
|
-
context.report({
|
|
584
|
-
node,
|
|
585
|
-
messageId: 'enforceMicrodiff',
|
|
586
|
-
fix(fixer) {
|
|
587
|
-
if (!canEmitDiffAt(node)) {
|
|
588
|
-
return null;
|
|
589
|
-
}
|
|
590
|
-
const compareFix = fixer.replaceText(node, `${DIFF_NAME}(${sourceCode.getText(leftArg)}, ${sourceCode.getText(rightArg)})${isEqual ? '.length === 0' : '.length > 0'}`);
|
|
591
|
-
// The comparison this rewrites almost always sits inside a
|
|
592
|
-
// function, and the `diff` it emits needs an import whatever
|
|
593
|
-
// encloses it. Deciding on the enclosing node left every
|
|
594
|
-
// nested comparison calling a `diff` nothing bound.
|
|
595
|
-
const importFix = buildMicrodiffImportFix(fixer);
|
|
596
|
-
return importFix ? [importFix, compareFix] : compareFix;
|
|
597
|
-
},
|
|
598
|
-
});
|
|
599
|
-
}
|
|
600
|
-
}
|
|
665
|
+
const comparison = toStringifyComparison(node);
|
|
666
|
+
if (!comparison ||
|
|
667
|
+
!isObjectOrArrayType(comparison.left) ||
|
|
668
|
+
!isObjectOrArrayType(comparison.right)) {
|
|
669
|
+
return;
|
|
601
670
|
}
|
|
671
|
+
reportedNodes.add(node);
|
|
672
|
+
context.report({
|
|
673
|
+
node,
|
|
674
|
+
messageId: 'enforceMicrodiff',
|
|
675
|
+
fix(fixer) {
|
|
676
|
+
if (!canEmitDiffAt(node)) {
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
const compareFix = fixer.replaceText(node, buildDiffComparison(comparison));
|
|
680
|
+
// The comparison this rewrites almost always sits inside a
|
|
681
|
+
// function, and the `diff` it emits needs an import whatever
|
|
682
|
+
// encloses it. Deciding on the enclosing node left every nested
|
|
683
|
+
// comparison calling a `diff` nothing bound.
|
|
684
|
+
const importFix = buildMicrodiffImportFix(fixer);
|
|
685
|
+
return importFix ? [importFix, compareFix] : compareFix;
|
|
686
|
+
},
|
|
687
|
+
});
|
|
602
688
|
},
|
|
603
689
|
// Check for custom deep comparison functions
|
|
604
690
|
FunctionDeclaration(node) {
|
|
@@ -629,29 +715,27 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
|
|
|
629
715
|
bodyText.includes('JSON.stringify') &&
|
|
630
716
|
bodyText.includes('!==')) {
|
|
631
717
|
reportedNodes.add(node);
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
const [
|
|
638
|
-
const operands = firstParam.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
639
|
-
secondParam.type === utils_1.AST_NODE_TYPES.Identifier
|
|
640
|
-
? [firstParam.name, secondParam.name]
|
|
641
|
-
: null;
|
|
718
|
+
// Exactly one comparison is the condition for a fix. With none
|
|
719
|
+
// there is no expression to rewrite, and with several the rule
|
|
720
|
+
// cannot tell which one the function's answer turns on, so the
|
|
721
|
+
// report stands on its own.
|
|
722
|
+
const comparisons = collectStringifyComparisons(body);
|
|
723
|
+
const comparison = comparisons.length === 1 ? comparisons[0] : null;
|
|
642
724
|
context.report({
|
|
643
725
|
node,
|
|
644
726
|
messageId: 'enforceMicrodiff',
|
|
645
727
|
fix(fixer) {
|
|
646
|
-
if (!
|
|
728
|
+
if (!comparison || !canEmitDiffAt(node)) {
|
|
647
729
|
return null;
|
|
648
730
|
}
|
|
649
|
-
// Only the
|
|
650
|
-
// annotations, its modifiers
|
|
651
|
-
// it
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
|
|
731
|
+
// Only the comparison's own range is rewritten. The signature
|
|
732
|
+
// keeps its type annotations, its modifiers and any `export`
|
|
733
|
+
// in front of it, and the body keeps everything the
|
|
734
|
+
// comparison shares it with: side effects, guard clauses,
|
|
735
|
+
// locals, and the comments around them. Re-emitting the body
|
|
736
|
+
// as a single return drops all of that silently — the fix
|
|
737
|
+
// compiles, so nothing downstream flags the loss.
|
|
738
|
+
const bodyFix = fixer.replaceText(comparison.node, buildDiffComparison(comparison));
|
|
655
739
|
const importFix = buildMicrodiffImportFix(fixer);
|
|
656
740
|
return importFix ? [importFix, bodyFix] : bodyFix;
|
|
657
741
|
},
|
|
@@ -20,69 +20,38 @@ function isKnownHookCallee(callee, knownHooks, assumeAllUseAreMemoized) {
|
|
|
20
20
|
}
|
|
21
21
|
return false;
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
const EVENT_SUPPRESSION_METHODS = new Set([
|
|
24
|
+
'preventDefault',
|
|
25
|
+
'stopPropagation',
|
|
26
|
+
'stopImmediatePropagation',
|
|
27
|
+
]);
|
|
28
|
+
/**
|
|
29
|
+
* A wrapper that suppresses the event carries behaviour the rule's own remedy
|
|
30
|
+
* cannot preserve: passing the memoized callback directly both drops the
|
|
31
|
+
* suppression call and hands React's event to a callback that took no
|
|
32
|
+
* arguments. Such a wrapper is not redundant, so the receiver is deliberately
|
|
33
|
+
* unconstrained — deleting `x.preventDefault()` changes behaviour whether `x`
|
|
34
|
+
* is a parameter, a captured value or a nested member.
|
|
35
|
+
*/
|
|
36
|
+
function isEventSuppressionCall(stmt) {
|
|
24
37
|
if (stmt.type !== utils_1.AST_NODE_TYPES.ExpressionStatement)
|
|
25
38
|
return false;
|
|
26
|
-
const expr = stmt.expression;
|
|
27
|
-
if (expr.type !== utils_1.AST_NODE_TYPES.CallExpression)
|
|
39
|
+
const expr = unwrapChainExpression(stmt.expression);
|
|
40
|
+
if (!expr || expr.type !== utils_1.AST_NODE_TYPES.CallExpression)
|
|
28
41
|
return false;
|
|
29
|
-
|
|
42
|
+
const callee = unwrapChainExpression(expr.callee);
|
|
43
|
+
if (!callee)
|
|
30
44
|
return false;
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
member.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
35
|
-
(member.property.name === 'preventDefault' ||
|
|
36
|
-
member.property.name === 'stopPropagation' ||
|
|
37
|
-
member.property.name === 'stopImmediatePropagation')) {
|
|
38
|
-
return true;
|
|
45
|
+
// A destructured `({ preventDefault })` reaches the method without a receiver.
|
|
46
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
47
|
+
return EVENT_SUPPRESSION_METHODS.has(callee.name);
|
|
39
48
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
for (const p of node.params) {
|
|
45
|
-
if (p.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
46
|
-
names.add(p.name);
|
|
47
|
-
}
|
|
48
|
-
else if (p.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
|
|
49
|
-
for (const prop of p.properties) {
|
|
50
|
-
if (prop.type === utils_1.AST_NODE_TYPES.Property) {
|
|
51
|
-
// Collect bound identifier names (aliases/defaults)
|
|
52
|
-
if (prop.value.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
53
|
-
names.add(prop.value.name);
|
|
54
|
-
}
|
|
55
|
-
else if (prop.value.type === utils_1.AST_NODE_TYPES.AssignmentPattern &&
|
|
56
|
-
prop.value.left.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
57
|
-
names.add(prop.value.left.name);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
else if (prop.type === utils_1.AST_NODE_TYPES.RestElement) {
|
|
61
|
-
if (prop.argument.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
62
|
-
names.add(prop.argument.name);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
else if (p.type === utils_1.AST_NODE_TYPES.ArrayPattern) {
|
|
68
|
-
for (const element of p.elements) {
|
|
69
|
-
if (!element)
|
|
70
|
-
continue;
|
|
71
|
-
if (element.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
72
|
-
names.add(element.name);
|
|
73
|
-
}
|
|
74
|
-
else if (element.type === utils_1.AST_NODE_TYPES.AssignmentPattern &&
|
|
75
|
-
element.left.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
76
|
-
names.add(element.left.name);
|
|
77
|
-
}
|
|
78
|
-
else if (element.type === utils_1.AST_NODE_TYPES.RestElement &&
|
|
79
|
-
element.argument.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
80
|
-
names.add(element.argument.name);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
49
|
+
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
50
|
+
!callee.computed &&
|
|
51
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
52
|
+
return EVENT_SUPPRESSION_METHODS.has(callee.property.name);
|
|
84
53
|
}
|
|
85
|
-
return
|
|
54
|
+
return false;
|
|
86
55
|
}
|
|
87
56
|
function isIdentifierOrMemberOn(obj, nameSet) {
|
|
88
57
|
if (obj.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
@@ -224,7 +193,6 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
224
193
|
(unwrappedArg.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
225
194
|
unwrappedArg.type === utils_1.AST_NODE_TYPES.FunctionExpression)) {
|
|
226
195
|
const fn = unwrappedArg;
|
|
227
|
-
const params = getParams(fn);
|
|
228
196
|
// Handle implicit return: () => memoizedFn()
|
|
229
197
|
if (fn.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
|
|
230
198
|
fn.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
|
|
@@ -263,21 +231,25 @@ exports.noRedundantUseCallbackWrapper = (0, createRule_1.createRule)({
|
|
|
263
231
|
}
|
|
264
232
|
return;
|
|
265
233
|
}
|
|
266
|
-
// Handle block body: () => {
|
|
234
|
+
// Handle block body: () => { return memoizedFn(); }
|
|
267
235
|
if (fn.body && fn.body.type === utils_1.AST_NODE_TYPES.BlockStatement) {
|
|
268
236
|
const stmts = fn.body.body.filter(Boolean);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
237
|
+
// An event-suppression call disqualifies the wrapper outright: no
|
|
238
|
+
// spelling of "pass the callback directly" keeps it, so reporting
|
|
239
|
+
// here would prescribe a remedy that does not exist.
|
|
240
|
+
if (stmts.some(isEventSuppressionCall))
|
|
241
|
+
return;
|
|
242
|
+
// Exactly one statement. A wrapper that sequences a second call is
|
|
243
|
+
// doing work the delegate alone does not, so collapsing it would
|
|
244
|
+
// drop that call — only the branch's own statement is ever read,
|
|
245
|
+
// so a wider count silently discards whatever it did not look at.
|
|
246
|
+
if (stmts.length === 1) {
|
|
247
|
+
const first = stmts[0];
|
|
248
|
+
if (first.type === utils_1.AST_NODE_TYPES.ReturnStatement ||
|
|
249
|
+
first.type === utils_1.AST_NODE_TYPES.ExpressionStatement) {
|
|
250
|
+
const expr = first.type === utils_1.AST_NODE_TYPES.ReturnStatement
|
|
251
|
+
? first.argument
|
|
252
|
+
: first.expression;
|
|
281
253
|
if (expr && expr.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
282
254
|
const callee = unwrapChainExpression(expr.callee);
|
|
283
255
|
const isHookProp = callee &&
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,50 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.99",
|
|
4
|
+
"date": "2026-08-04T12:38:33.934Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-memoize-async",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1697
|
|
11
|
+
],
|
|
12
|
+
"summary": "decline the decorator inside a jest.mock factory (closes #1697)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "no-redundant-usecallback-wrapper",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1696,
|
|
19
|
+
1699
|
|
20
|
+
],
|
|
21
|
+
"summary": "require a single-statement body before collapsing (closes #1699); treat event suppression as disqualifying, not skippable (closes #1696)"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"version": "1.20.98",
|
|
27
|
+
"date": "2026-08-04T10:58:56.821Z",
|
|
28
|
+
"rules": [
|
|
29
|
+
{
|
|
30
|
+
"name": "enforce-centralized-mock-firestore",
|
|
31
|
+
"changeType": "fix",
|
|
32
|
+
"issues": [
|
|
33
|
+
1694,
|
|
34
|
+
1695
|
|
35
|
+
],
|
|
36
|
+
"summary": "place the import by anchor instead of rewriting the Program (closes #1695); retire declarations by range, not by line index (closes #1694)"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "enforce-microdiff",
|
|
40
|
+
"changeType": "fix",
|
|
41
|
+
"issues": [
|
|
42
|
+
1693
|
|
43
|
+
],
|
|
44
|
+
"summary": "rewrite the comparison in place instead of the whole body (closes #1693)"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
2
48
|
{
|
|
3
49
|
"version": "1.20.97",
|
|
4
50
|
"date": "2026-08-04T08:50:14.394Z",
|