@blumintinc/eslint-plugin-blumint 1.20.24 → 1.20.26
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-assert-safe-object-key.js +17 -0
- package/lib/rules/enforce-memoize-async.js +19 -2
- package/lib/rules/enforce-memoize-getters.js +17 -0
- package/lib/rules/enforce-querykey-ts.js +25 -3
- package/lib/rules/enforce-stable-hash-spread-props.js +16 -0
- package/lib/rules/fast-deep-equal-over-microdiff.js +14 -0
- package/lib/rules/logical-top-to-bottom-grouping.js +226 -48
- package/lib/rules/no-array-length-in-deps.js +23 -1
- package/lib/rules/no-entire-object-hook-deps.js +89 -89
- package/lib/rules/prefer-fragment-component.js +52 -1
- package/lib/rules/prefer-map-over-conditional-dispatch.js +120 -1
- package/lib/rules/prefer-type-over-interface.js +38 -11
- package/lib/rules/prefer-usecallback-over-usememo-for-functions.js +23 -2
- package/lib/rules/require-memoize-jsx-returners.js +17 -0
- package/lib/utils/disableDirectives.d.ts +51 -0
- package/lib/utils/disableDirectives.js +168 -0
- package/package.json +1 -1
- package/release-manifest.json +125 -0
|
@@ -55,6 +55,21 @@ function isArrayOrPrimitive(checker, esTreeNode, nodeMap) {
|
|
|
55
55
|
return false;
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
function renderPathSegments(baseName, segments) {
|
|
59
|
+
let path = baseName;
|
|
60
|
+
for (const segment of segments) {
|
|
61
|
+
if (segment.computed) {
|
|
62
|
+
// why: an optional computed access must render as `?.[` — a bare `?`
|
|
63
|
+
// before `[` parses as a conditional expression, so `state?[0]` is a
|
|
64
|
+
// syntax error while `state?.[0]` is the valid optional element access.
|
|
65
|
+
path += segment.optional ? `?.${segment.text}` : segment.text;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
path += segment.optional ? `?.${segment.text}` : `.${segment.text}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return path;
|
|
72
|
+
}
|
|
58
73
|
function unwrapExpression(expr) {
|
|
59
74
|
let current = expr;
|
|
60
75
|
while (current.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
@@ -67,6 +82,10 @@ function unwrapExpression(expr) {
|
|
|
67
82
|
}
|
|
68
83
|
function getObjectUsagesInHook(hookBody, objectName) {
|
|
69
84
|
const usages = new Map(); // Track usage and its position
|
|
85
|
+
// why: derived dependency paths (first-optional intermediate, array base)
|
|
86
|
+
// must be re-rendered from structured links — string surgery on the
|
|
87
|
+
// rendered path cannot place `?.` markers correctly.
|
|
88
|
+
const pathSegments = new Map();
|
|
70
89
|
const visited = new Set();
|
|
71
90
|
let needsEntireObject = false;
|
|
72
91
|
let isUsed = false;
|
|
@@ -123,23 +142,33 @@ function getObjectUsagesInHook(hookBody, objectName) {
|
|
|
123
142
|
'includes',
|
|
124
143
|
]);
|
|
125
144
|
function buildAccessPath(node) {
|
|
126
|
-
|
|
145
|
+
// why: optionality belongs to individual links, not the whole chain.
|
|
146
|
+
// Rendering from per-link segments keeps `?.` markers at their real
|
|
147
|
+
// position (a.b?.[0] stays a.b?.[0], not a?.b[0]) and forms the mandatory
|
|
148
|
+
// `?.[` for optional computed access (state?.[0], never state?[0]).
|
|
149
|
+
const segments = [];
|
|
127
150
|
let current = node;
|
|
128
|
-
|
|
129
|
-
// Collect all parts from leaf to root
|
|
151
|
+
// Collect all links from leaf to root
|
|
130
152
|
while (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
131
153
|
const memberExpr = current;
|
|
132
154
|
// Handle computed properties (like array indices)
|
|
133
155
|
if (memberExpr.computed) {
|
|
134
|
-
// why: only a *literal* computed key (obj[0],
|
|
135
|
-
// narrows to a single, stable field. EVERY other
|
|
136
|
-
// Identifier (obj[i]), CallExpression
|
|
137
|
-
//
|
|
138
|
-
// TSAsExpression (obj[k as K]), TemplateLiteral
|
|
139
|
-
// — is a dynamic access that can read
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
156
|
+
// why: only a *literal* string/number computed key (obj[0],
|
|
157
|
+
// obj['special-key']) narrows to a single, stable field. EVERY other
|
|
158
|
+
// computed key — Identifier (obj[i]), CallExpression
|
|
159
|
+
// (obj[assertSafe(i)]), BinaryExpression (obj[i+1]), MemberExpression
|
|
160
|
+
// (obj[keys[j]]), TSAsExpression (obj[k as K]), TemplateLiteral
|
|
161
|
+
// (obj[`row-${i}`]), etc. — is a dynamic access that can read
|
|
162
|
+
// arbitrary elements across an iteration. Remaining literal kinds
|
|
163
|
+
// (boolean/null/regex/bigint keys) have no rendering the fixer can
|
|
164
|
+
// guarantee round-trips, so they decline narrowing too rather than
|
|
165
|
+
// emit unreliable text. In every declined case the whole object is a
|
|
166
|
+
// legitimate dependency: resolve the base and mark it accordingly.
|
|
167
|
+
const literalValue = memberExpr.property.type === utils_1.AST_NODE_TYPES.Literal
|
|
168
|
+
? memberExpr.property.value
|
|
169
|
+
: undefined;
|
|
170
|
+
if (typeof literalValue !== 'number' &&
|
|
171
|
+
typeof literalValue !== 'string') {
|
|
143
172
|
// Check if this is accessing our target object
|
|
144
173
|
let currentBase = unwrapExpression(memberExpr.object);
|
|
145
174
|
while (currentBase.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
@@ -147,24 +176,18 @@ function getObjectUsagesInHook(hookBody, objectName) {
|
|
|
147
176
|
}
|
|
148
177
|
if (currentBase.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
149
178
|
currentBase.name === objectName) {
|
|
150
|
-
//
|
|
151
|
-
// so we need the entire object (no narrowable field exists).
|
|
179
|
+
// No narrowable field exists, so the entire object is required.
|
|
152
180
|
needsEntireObject = true;
|
|
153
181
|
}
|
|
154
182
|
return null;
|
|
155
183
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
else {
|
|
165
|
-
// For other literal computed properties (e.g. boolean/null), wildcard
|
|
166
|
-
parts.unshift('[*]');
|
|
167
|
-
}
|
|
184
|
+
segments.unshift({
|
|
185
|
+
text: typeof literalValue === 'number'
|
|
186
|
+
? `[${literalValue}]`
|
|
187
|
+
: `[${JSON.stringify(literalValue)}]`,
|
|
188
|
+
computed: true,
|
|
189
|
+
optional: memberExpr.optional,
|
|
190
|
+
});
|
|
168
191
|
}
|
|
169
192
|
else {
|
|
170
193
|
// Regular property access
|
|
@@ -175,57 +198,37 @@ function getObjectUsagesInHook(hookBody, objectName) {
|
|
|
175
198
|
if (memberExpr.property.name &&
|
|
176
199
|
(ARRAY_METHODS.has(memberExpr.property.name) ||
|
|
177
200
|
STRING_METHODS.has(memberExpr.property.name))) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
186
|
-
pathParts.unshift(currentMember.property.name);
|
|
187
|
-
}
|
|
188
|
-
if (currentMember.optional) {
|
|
189
|
-
hasOptionalChainingInMethod = true;
|
|
190
|
-
}
|
|
191
|
-
currentBase = unwrapExpression(currentMember.object);
|
|
192
|
-
}
|
|
193
|
-
if (currentBase.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
194
|
-
currentBase.name === objectName) {
|
|
195
|
-
if (pathParts.length === 0) {
|
|
196
|
-
// Direct method call on the object (e.g., userData.map(...))
|
|
197
|
-
needsEntireObject = true;
|
|
198
|
-
}
|
|
199
|
-
else {
|
|
200
|
-
// Method call on a property (e.g., userData.items.map(...) or userData?.items?.map(...))
|
|
201
|
-
let path = objectName + (hasOptionalChainingInMethod ? '?' : '');
|
|
202
|
-
path += '.' + pathParts.join('.');
|
|
201
|
+
const methodTarget = unwrapExpression(memberExpr.object);
|
|
202
|
+
if (methodTarget.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
203
|
+
// Method call on a property (e.g., userData.items.map(...) or
|
|
204
|
+
// userData?.items?.map(...)): depend on that property's own path,
|
|
205
|
+
// rendered with the same per-link optional markers as any other
|
|
206
|
+
// access.
|
|
207
|
+
const path = buildAccessPath(methodTarget);
|
|
208
|
+
if (path) {
|
|
203
209
|
usages.set(path, memberExpr.range?.[0] || 0);
|
|
204
210
|
}
|
|
205
211
|
}
|
|
212
|
+
else if (methodTarget.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
213
|
+
methodTarget.name === objectName) {
|
|
214
|
+
// Direct method call on the object (e.g., userData.map(...))
|
|
215
|
+
needsEntireObject = true;
|
|
216
|
+
}
|
|
206
217
|
return null;
|
|
207
218
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
219
|
+
segments.unshift({
|
|
220
|
+
text: memberExpr.property.name,
|
|
221
|
+
computed: false,
|
|
222
|
+
optional: memberExpr.optional,
|
|
223
|
+
});
|
|
212
224
|
}
|
|
213
225
|
current = unwrapExpression(memberExpr.object);
|
|
214
226
|
}
|
|
215
227
|
// Check if we reached the target identifier
|
|
216
228
|
const base = unwrapExpression(current);
|
|
217
229
|
if (base.type === utils_1.AST_NODE_TYPES.Identifier && base.name === objectName) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// Add each part with proper formatting (dot notation or bracket notation)
|
|
221
|
-
for (const part of parts) {
|
|
222
|
-
if (part.startsWith('[')) {
|
|
223
|
-
path += part; // Already formatted as bracket notation
|
|
224
|
-
}
|
|
225
|
-
else {
|
|
226
|
-
path += '.' + part; // Dot notation
|
|
227
|
-
}
|
|
228
|
-
}
|
|
230
|
+
const path = renderPathSegments(objectName, segments);
|
|
231
|
+
pathSegments.set(path, segments);
|
|
229
232
|
return path;
|
|
230
233
|
}
|
|
231
234
|
return null;
|
|
@@ -446,29 +449,24 @@ function getObjectUsagesInHook(hookBody, objectName) {
|
|
|
446
449
|
paths.forEach((path) => {
|
|
447
450
|
// Always include the main path
|
|
448
451
|
finalPaths.add(path);
|
|
449
|
-
|
|
450
|
-
if (
|
|
451
|
-
|
|
452
|
-
// For userData?.profile.settings.theme.primary, we want to include userData?.profile
|
|
453
|
-
const firstOptionalIndex = path.indexOf('?.');
|
|
454
|
-
if (firstOptionalIndex !== -1) {
|
|
455
|
-
// Find the end of the first property after the optional chaining
|
|
456
|
-
const afterOptional = path.substring(firstOptionalIndex + 2);
|
|
457
|
-
const nextDotIndex = afterOptional.indexOf('.');
|
|
458
|
-
if (nextDotIndex !== -1) {
|
|
459
|
-
// There are more properties after the first optional property
|
|
460
|
-
const firstOptionalPath = path.substring(0, firstOptionalIndex + 2 + nextDotIndex);
|
|
461
|
-
finalPaths.add(firstOptionalPath);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
452
|
+
const segments = pathSegments.get(path);
|
|
453
|
+
if (!segments) {
|
|
454
|
+
return;
|
|
464
455
|
}
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
456
|
+
// Include the FIRST optional link as an intermediate dependency when more
|
|
457
|
+
// links follow it: for userData?.profile.settings.theme.primary we also
|
|
458
|
+
// want userData?.profile.
|
|
459
|
+
const firstOptionalIndex = segments.findIndex((segment) => segment.optional);
|
|
460
|
+
if (firstOptionalIndex !== -1 && firstOptionalIndex < segments.length - 1) {
|
|
461
|
+
finalPaths.add(renderPathSegments(objectName, segments.slice(0, firstOptionalIndex + 1)));
|
|
462
|
+
}
|
|
463
|
+
// For array access, include the array property itself: for
|
|
464
|
+
// userData.items[0] we also want userData.items. A bracket directly on
|
|
465
|
+
// the base (state[0], state?.[0]) adds nothing — its "array" is the
|
|
466
|
+
// entire object dependency this rule exists to narrow away.
|
|
467
|
+
const firstComputedIndex = segments.findIndex((segment) => segment.computed);
|
|
468
|
+
if (firstComputedIndex > 0) {
|
|
469
|
+
finalPaths.add(renderPathSegments(objectName, segments.slice(0, firstComputedIndex)));
|
|
472
470
|
}
|
|
473
471
|
});
|
|
474
472
|
// Convert to array for sorting
|
|
@@ -477,9 +475,11 @@ function getObjectUsagesInHook(hookBody, objectName) {
|
|
|
477
475
|
// Exception: keep array paths with optional chaining as they represent different dependencies
|
|
478
476
|
const filteredPaths = pathsArray.filter((path) => {
|
|
479
477
|
// Skip array paths if we're accessing specific indices, unless it's optional chaining
|
|
478
|
+
// why: an optional bracket renders as `?.[`, so the specific-index probe
|
|
479
|
+
// must accept both `base[0]` and `base?.[0]` shapes.
|
|
480
480
|
const isArrayWithSpecificIndices = pathsArray.some((otherPath) => otherPath !== path &&
|
|
481
481
|
(otherPath.startsWith(path + '[') ||
|
|
482
|
-
otherPath.
|
|
482
|
+
otherPath.startsWith(path + '?.[')));
|
|
483
483
|
// Keep array paths with optional chaining even if specific indices are accessed
|
|
484
484
|
if (isArrayWithSpecificIndices && path.includes('?.')) {
|
|
485
485
|
return true;
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.preferFragmentComponent = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
6
7
|
exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
7
8
|
name: 'prefer-fragment-component',
|
|
8
9
|
meta: {
|
|
@@ -21,6 +22,13 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
21
22
|
defaultOptions: [],
|
|
22
23
|
create(context) {
|
|
23
24
|
const sourceCode = context.sourceCode;
|
|
25
|
+
// The `import { Fragment } from 'react'` edit rides on one violation's fix,
|
|
26
|
+
// making that violation the file's import carrier. ESLint builds fixes
|
|
27
|
+
// eagerly and drops inline-disabled reports afterwards, so a suppressed
|
|
28
|
+
// carrier would take the import down with it while the surviving
|
|
29
|
+
// violations still emit <Fragment>. Resolving suppression before the latch
|
|
30
|
+
// is read hands the carrier slot to the first violation that survives.
|
|
31
|
+
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
24
32
|
let hasFragmentImport = false;
|
|
25
33
|
let reactImportNode = null;
|
|
26
34
|
let defaultReactImportNode = null;
|
|
@@ -90,6 +98,29 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
90
98
|
}
|
|
91
99
|
}
|
|
92
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Where a brand-new import statement can be spliced in without changing
|
|
103
|
+
* what the file's directives govern. A whole-line insertion directly before
|
|
104
|
+
* the first statement slides in between an `eslint-disable-next-line`
|
|
105
|
+
* comment and the line it applies to, silently retargeting that directive
|
|
106
|
+
* at the import. Anchoring above such comments keeps every directive
|
|
107
|
+
* pointed at the code its author aimed it at.
|
|
108
|
+
*/
|
|
109
|
+
function findImportAnchor() {
|
|
110
|
+
const firstStatement = sourceCode.ast.body[0];
|
|
111
|
+
let anchor = firstStatement;
|
|
112
|
+
const leadingComments = sourceCode.getCommentsBefore(firstStatement);
|
|
113
|
+
for (let index = leadingComments.length - 1; index >= 0; index--) {
|
|
114
|
+
const comment = leadingComments[index];
|
|
115
|
+
const [directive] = (0, disableDirectives_1.parseDisableDirectives)([comment]);
|
|
116
|
+
if (directive?.kind !== 'disable-next-line' ||
|
|
117
|
+
comment.loc.end.line + 1 !== anchor.loc.start.line) {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
anchor = comment;
|
|
121
|
+
}
|
|
122
|
+
return anchor;
|
|
123
|
+
}
|
|
93
124
|
/**
|
|
94
125
|
* Adds Fragment import to an appropriate React import or creates a new one
|
|
95
126
|
*/
|
|
@@ -116,7 +147,7 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
116
147
|
// No React import found, create a new one
|
|
117
148
|
const importText = "import { Fragment } from 'react';\n";
|
|
118
149
|
const indentation = sourceCode.text.match(/^[ \t]*/m)?.[0] || '';
|
|
119
|
-
return fixer.insertTextBefore(
|
|
150
|
+
return fixer.insertTextBefore(findImportAnchor(), indentation + importText);
|
|
120
151
|
}
|
|
121
152
|
return {
|
|
122
153
|
ImportDeclaration: checkFragmentImport,
|
|
@@ -149,6 +180,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
149
180
|
messageId: 'preferFragment',
|
|
150
181
|
data: { type: 'shorthand fragment (<>)' },
|
|
151
182
|
fix(fixer) {
|
|
183
|
+
// A suppressed report is discarded together with its fix, so it
|
|
184
|
+
// must not claim the import carrier slot.
|
|
185
|
+
if (isReportSuppressed(node)) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
152
188
|
const fixes = [];
|
|
153
189
|
// Add Fragment import if needed
|
|
154
190
|
if (!hasFragmentImport) {
|
|
@@ -204,6 +240,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
204
240
|
messageId: 'preferFragment',
|
|
205
241
|
data: { type: 'shorthand fragment (<>)' },
|
|
206
242
|
fix(fixer) {
|
|
243
|
+
// A suppressed report is discarded together with its fix, so it
|
|
244
|
+
// must not claim the import carrier slot.
|
|
245
|
+
if (isReportSuppressed(node)) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
207
248
|
const fixes = [];
|
|
208
249
|
// Add Fragment import if needed
|
|
209
250
|
if (!hasFragmentImport) {
|
|
@@ -250,6 +291,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
250
291
|
messageId: 'preferFragment',
|
|
251
292
|
data: { type: 'React.Fragment' },
|
|
252
293
|
fix(fixer) {
|
|
294
|
+
// A suppressed report is discarded together with its fix, so it
|
|
295
|
+
// must not claim the import carrier slot.
|
|
296
|
+
if (isReportSuppressed(node.name)) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
253
299
|
const fixes = [];
|
|
254
300
|
// Add Fragment import if needed
|
|
255
301
|
if (!hasFragmentImport) {
|
|
@@ -281,6 +327,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
281
327
|
messageId: 'preferFragment',
|
|
282
328
|
data: { type: 'React.Fragment' },
|
|
283
329
|
fix(fixer) {
|
|
330
|
+
// A suppressed report is discarded together with its fix, so it
|
|
331
|
+
// must not claim the import carrier slot.
|
|
332
|
+
if (isReportSuppressed(node.name)) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
284
335
|
const fixes = [];
|
|
285
336
|
// Add Fragment import if needed
|
|
286
337
|
if (!hasFragmentImport) {
|
|
@@ -50,6 +50,18 @@ const FUNCTION_TYPES = new Set([
|
|
|
50
50
|
utils_1.AST_NODE_TYPES.FunctionExpression,
|
|
51
51
|
utils_1.AST_NODE_TYPES.FunctionDeclaration,
|
|
52
52
|
]);
|
|
53
|
+
/**
|
|
54
|
+
* Function/constructor/conditional type notation must be parenthesized to
|
|
55
|
+
* appear as a `|` union member, or the emitted annotation does not parse
|
|
56
|
+
* ("Function type notation must be parenthesized when used in a union type").
|
|
57
|
+
* Over-wrapping is harmless — a parenthesized type is valid in any type
|
|
58
|
+
* position — so this tests the printed text conservatively instead of
|
|
59
|
+
* re-deriving the printer's precedence rules.
|
|
60
|
+
*/
|
|
61
|
+
function parenthesizeForUnion(text) {
|
|
62
|
+
const needsParens = text.includes('=>') || /^new\b/.test(text) || /\bextends\b/.test(text);
|
|
63
|
+
return needsParens ? `(${text})` : text;
|
|
64
|
+
}
|
|
53
65
|
exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
54
66
|
name: 'prefer-map-over-conditional-dispatch',
|
|
55
67
|
meta: {
|
|
@@ -194,7 +206,13 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
194
206
|
parts.push(text);
|
|
195
207
|
}
|
|
196
208
|
}
|
|
197
|
-
|
|
209
|
+
if (parts.length === 0) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
if (parts.length === 1) {
|
|
213
|
+
return parts[0];
|
|
214
|
+
}
|
|
215
|
+
return parts.map(parenthesizeForUnion).join(' | ');
|
|
198
216
|
}
|
|
199
217
|
function discriminantTypeText(type, discriminant) {
|
|
200
218
|
try {
|
|
@@ -209,6 +227,94 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
209
227
|
return null;
|
|
210
228
|
}
|
|
211
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* `checker.typeToString()` prints a symbol's bare name with no regard for
|
|
232
|
+
* whether that name resolves at the fix site (an unimported helper type
|
|
233
|
+
* prints the same as an imported one), and exotic printer output can be
|
|
234
|
+
* invalid in an annotation position. Shipping such an annotation breaks
|
|
235
|
+
* the build, so the fix is gated on the synthesized text (a) parsing
|
|
236
|
+
* standalone and (b) referencing only names in scope where the Record is
|
|
237
|
+
* inserted. Failing the gate downgrades to the report-only path — the
|
|
238
|
+
* plugin prefers a skipped fix over one that does not compile.
|
|
239
|
+
*/
|
|
240
|
+
function validateAnnotation(annotationText, fixSite) {
|
|
241
|
+
const sourceFile = ts.createSourceFile('__annotation__.ts', `type __T = ${annotationText};`, ts.ScriptTarget.Latest, true);
|
|
242
|
+
// parseDiagnostics is absent from the public SourceFile type but always
|
|
243
|
+
// populated at runtime; a diagnostic here means the annotation cannot
|
|
244
|
+
// ship (e.g. printer truncation, or an unparenthesized member shape the
|
|
245
|
+
// union-wrapping heuristic does not recognize).
|
|
246
|
+
const parseDiagnostics = sourceFile.parseDiagnostics;
|
|
247
|
+
if (!parseDiagnostics || parseDiagnostics.length > 0) {
|
|
248
|
+
return 'unparseable';
|
|
249
|
+
}
|
|
250
|
+
const typeReferenceRoots = new Set();
|
|
251
|
+
const typeQueryRoots = new Set();
|
|
252
|
+
let hasImportType = false;
|
|
253
|
+
const rootNameOf = (name) => {
|
|
254
|
+
let current = name;
|
|
255
|
+
while (ts.isQualifiedName(current)) {
|
|
256
|
+
current = current.left;
|
|
257
|
+
}
|
|
258
|
+
return current.text;
|
|
259
|
+
};
|
|
260
|
+
const collect = (node) => {
|
|
261
|
+
if (ts.isTypeReferenceNode(node)) {
|
|
262
|
+
typeReferenceRoots.add(rootNameOf(node.typeName));
|
|
263
|
+
}
|
|
264
|
+
else if (ts.isTypeQueryNode(node)) {
|
|
265
|
+
typeQueryRoots.add(rootNameOf(node.exprName));
|
|
266
|
+
}
|
|
267
|
+
else if (ts.isImportTypeNode(node)) {
|
|
268
|
+
// `import("...").T` paths printed by the checker are absolute or
|
|
269
|
+
// resolver-relative — never portable source text.
|
|
270
|
+
hasImportType = true;
|
|
271
|
+
}
|
|
272
|
+
ts.forEachChild(node, collect);
|
|
273
|
+
};
|
|
274
|
+
collect(sourceFile);
|
|
275
|
+
if (hasImportType) {
|
|
276
|
+
return 'unresolvable';
|
|
277
|
+
}
|
|
278
|
+
// `Record` is the wrapper this rule itself emits — a TS lib global that
|
|
279
|
+
// is present in any real project. The isolated single-file program a
|
|
280
|
+
// RuleTester builds loads no lib files at all, so requiring it in scope
|
|
281
|
+
// would falsely downgrade every fix under test.
|
|
282
|
+
typeReferenceRoots.delete('Record');
|
|
283
|
+
if (typeReferenceRoots.size === 0 && typeQueryRoots.size === 0) {
|
|
284
|
+
return 'ok';
|
|
285
|
+
}
|
|
286
|
+
const tsFixSite = esTreeNodeToTSNodeMap.get(fixSite);
|
|
287
|
+
if (!tsFixSite) {
|
|
288
|
+
return 'unresolvable';
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const namesInScope = (meaning) => new Set(checker
|
|
292
|
+
.getSymbolsInScope(tsFixSite, meaning)
|
|
293
|
+
.map((symbol) => symbol.name));
|
|
294
|
+
if (typeReferenceRoots.size > 0) {
|
|
295
|
+
const typeNames = namesInScope(ts.SymbolFlags.Type |
|
|
296
|
+
ts.SymbolFlags.Namespace |
|
|
297
|
+
ts.SymbolFlags.Alias);
|
|
298
|
+
for (const root of typeReferenceRoots) {
|
|
299
|
+
if (!typeNames.has(root)) {
|
|
300
|
+
return 'unresolvable';
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (typeQueryRoots.size > 0) {
|
|
305
|
+
const valueNames = namesInScope(ts.SymbolFlags.Value | ts.SymbolFlags.Alias);
|
|
306
|
+
for (const root of typeQueryRoots) {
|
|
307
|
+
if (!valueNames.has(root)) {
|
|
308
|
+
return 'unresolvable';
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return 'unresolvable';
|
|
315
|
+
}
|
|
316
|
+
return 'ok';
|
|
317
|
+
}
|
|
212
318
|
// ---- AST helpers --------------------------------------------------------
|
|
213
319
|
/** Whether a discriminant expression is an identifier or a call-free,
|
|
214
320
|
* non-optional member chain (safe to collapse repeated evaluations). */
|
|
@@ -500,6 +606,19 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
500
606
|
});
|
|
501
607
|
return;
|
|
502
608
|
}
|
|
609
|
+
const verdict = validateAnnotation(`Record<${dText}, ${vText}>`, discriminantOf(node));
|
|
610
|
+
if (verdict !== 'ok') {
|
|
611
|
+
context.report({
|
|
612
|
+
node,
|
|
613
|
+
messageId: 'preferMapManual',
|
|
614
|
+
data: {
|
|
615
|
+
reason: verdict === 'unparseable'
|
|
616
|
+
? 'the branch value type does not print as a parseable annotation — write the Record with an explicit type manually'
|
|
617
|
+
: 'the annotation would name types that are not in scope at the fix site — import them or write the Record manually',
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
503
622
|
context.report({
|
|
504
623
|
node,
|
|
505
624
|
messageId: 'preferMap',
|
|
@@ -30,19 +30,46 @@ exports.preferTypeOverInterface = (0, createRule_1.createRule)({
|
|
|
30
30
|
},
|
|
31
31
|
fix(fixer) {
|
|
32
32
|
const sourceCode = context.sourceCode;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
33
|
+
// The `=` must land after the entire declaration header (the
|
|
34
|
+
// name plus any type-parameter list); anchoring on the
|
|
35
|
+
// identifier alone emits unparseable `type Name =<T> {`.
|
|
36
|
+
const header = node.typeParameters ?? node.id;
|
|
37
|
+
const keywordSpan = [
|
|
38
|
+
node.range[0],
|
|
39
|
+
node.id.range[0],
|
|
38
40
|
];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
// Everything between the header and the opening brace is
|
|
42
|
+
// rewritten wholesale rather than patched token by token: the
|
|
43
|
+
// heritage list needs `,` turned into `&` and the `extends`
|
|
44
|
+
// keyword dropped, and surgical edits leave the separators and
|
|
45
|
+
// the keyword's surrounding whitespace behind. The body starts at
|
|
46
|
+
// the opening brace, so this span cannot swallow a `{` belonging
|
|
47
|
+
// to a heritage type argument or a type-parameter constraint.
|
|
48
|
+
const headerSpan = [
|
|
49
|
+
header.range[1],
|
|
50
|
+
node.body.range[0],
|
|
51
|
+
];
|
|
52
|
+
// Both rewritten spans are replaced in full, so a comment sitting
|
|
53
|
+
// inside either one would be silently destroyed (and a line
|
|
54
|
+
// comment would even swallow the `=` that follows it). Reporting
|
|
55
|
+
// without a fix preserves the author's prose; the conversion is
|
|
56
|
+
// then made by hand.
|
|
57
|
+
const clobbersComment = sourceCode
|
|
58
|
+
.getAllComments()
|
|
59
|
+
.some((comment) => [keywordSpan, headerSpan].some(([start, end]) => comment.range[0] < end && comment.range[1] > start));
|
|
60
|
+
if (clobbersComment) {
|
|
61
|
+
return null;
|
|
44
62
|
}
|
|
45
|
-
|
|
63
|
+
const heritage = node.extends ?? [];
|
|
64
|
+
// `getText` round-trips type arguments and qualified names, so
|
|
65
|
+
// `extends ns.B<T>, C` becomes `ns.B<T> & C`.
|
|
66
|
+
const intersection = heritage
|
|
67
|
+
.map((clause) => sourceCode.getText(clause))
|
|
68
|
+
.join(' & ');
|
|
69
|
+
return [
|
|
70
|
+
fixer.replaceTextRange(keywordSpan, 'type '),
|
|
71
|
+
fixer.replaceTextRange(headerSpan, heritage.length > 0 ? ` = ${intersection} & ` : ' = '),
|
|
72
|
+
];
|
|
46
73
|
},
|
|
47
74
|
});
|
|
48
75
|
},
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.preferUseCallbackOverUseMemoForFunctions = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
6
7
|
exports.preferUseCallbackOverUseMemoForFunctions = (0, createRule_1.createRule)({
|
|
7
8
|
name: 'prefer-usecallback-over-usememo-for-functions',
|
|
8
9
|
meta: {
|
|
@@ -119,6 +120,14 @@ exports.preferUseCallbackOverUseMemoForFunctions = (0, createRule_1.createRule)(
|
|
|
119
120
|
// knowing every conversion in the file: useMemo may only be dropped from the
|
|
120
121
|
// import when no reference to it survives the fixes.
|
|
121
122
|
const candidates = [];
|
|
123
|
+
/**
|
|
124
|
+
* A suppressed report is discarded together with its fix, yet its
|
|
125
|
+
* `useMemo(...)` call stays in the file. Both halves of the import rewrite
|
|
126
|
+
* therefore hinge on suppression: the rename must not ride on a violation
|
|
127
|
+
* that disappears, and it must not retire a specifier a suppressed call
|
|
128
|
+
* still resolves to.
|
|
129
|
+
*/
|
|
130
|
+
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
122
131
|
return {
|
|
123
132
|
CallExpression(node) {
|
|
124
133
|
// Check if the call is to useMemo
|
|
@@ -149,7 +158,7 @@ exports.preferUseCallbackOverUseMemoForFunctions = (0, createRule_1.createRule)(
|
|
|
149
158
|
if (candidates.length === 0) {
|
|
150
159
|
return;
|
|
151
160
|
}
|
|
152
|
-
const plan = planConversion(context, candidates);
|
|
161
|
+
const plan = planConversion(context, candidates, isReportSuppressed);
|
|
153
162
|
for (const candidate of candidates) {
|
|
154
163
|
reportAndFix(candidate.node, context, plan);
|
|
155
164
|
}
|
|
@@ -261,7 +270,7 @@ function removeImportSpecifierFixes(sourceCode, fixer, binding) {
|
|
|
261
270
|
* Works out which conversions can be autofixed and how the import list must
|
|
262
271
|
* change so the emitted useCallback resolves and useMemo is not left dangling.
|
|
263
272
|
*/
|
|
264
|
-
function planConversion(context, candidates) {
|
|
273
|
+
function planConversion(context, candidates, isReportSuppressed) {
|
|
265
274
|
const sourceCode = context.getSourceCode();
|
|
266
275
|
const program = sourceCode.ast;
|
|
267
276
|
const existingUseCallback = findImportedSpecifier(program, 'useCallback');
|
|
@@ -270,7 +279,13 @@ function planConversion(context, candidates) {
|
|
|
270
279
|
: 'useCallback';
|
|
271
280
|
// A local binding of the target name would capture the emitted call, so those
|
|
272
281
|
// conversions are reported without a fix rather than silently miscompiled.
|
|
282
|
+
// A suppressed violation is skipped for the same reason it is skipped for the
|
|
283
|
+
// import: ESLint drops its fix, so treating it as unfixable keeps the plan
|
|
284
|
+
// honest about which useMemo calls actually go away.
|
|
273
285
|
const fixableCandidates = candidates.filter((candidate) => {
|
|
286
|
+
if (isReportSuppressed(candidate.node)) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
274
289
|
const bound = resolveVariable(candidate.scope, calleeName);
|
|
275
290
|
if (!existingUseCallback) {
|
|
276
291
|
return bound === null;
|
|
@@ -298,6 +313,10 @@ function planConversion(context, candidates) {
|
|
|
298
313
|
const convertedReferences = new Set(fixableCandidates
|
|
299
314
|
.filter((candidate) => toImportBinding(resolveVariable(candidate.scope, candidate.node.callee.name))?.variable === useMemoBinding.variable)
|
|
300
315
|
.map((candidate) => candidate.node.callee));
|
|
316
|
+
// Every reference the fixes do not rewrite still needs the specifier: a plain
|
|
317
|
+
// value memo, a bare `useMemo` mention, a conversion blocked by shadowing, or
|
|
318
|
+
// the call behind a disable directive. Any one of them turns the rename into
|
|
319
|
+
// a plain insertion of useCallback beside useMemo.
|
|
301
320
|
const useMemoSurvives = useMemoBinding.variable.references.some((reference) => !convertedReferences.has(reference.identifier));
|
|
302
321
|
const shouldAdd = existingUseCallback === null;
|
|
303
322
|
const shouldRemove = !useMemoSurvives;
|
|
@@ -307,6 +326,8 @@ function planConversion(context, candidates) {
|
|
|
307
326
|
return {
|
|
308
327
|
calleeName,
|
|
309
328
|
fixable,
|
|
329
|
+
// The carrier is the first violation whose fix actually survives, so a
|
|
330
|
+
// suppressed leading violation cannot take the import edit down with it.
|
|
310
331
|
importOwner: fixableCandidates[0].node,
|
|
311
332
|
importFixes: (fixer) => {
|
|
312
333
|
if (shouldAdd && shouldRemove) {
|