@blumintinc/eslint-plugin-blumint 1.20.8 → 1.20.10
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-early-destructuring.js +14 -0
- package/lib/rules/enforce-m3-sentence-case.js +211 -45
- package/lib/rules/enforce-props-naming-consistency.js +127 -8
- package/lib/rules/enforce-querykey-ts.js +170 -16
- package/lib/rules/enforce-react-type-naming.d.ts +2 -1
- package/lib/rules/enforce-react-type-naming.js +46 -17
- package/lib/rules/enforce-snapshot-state-narrowing.d.ts +3 -1
- package/lib/rules/enforce-snapshot-state-narrowing.js +213 -53
- package/lib/rules/no-class-instance-destructuring.js +7 -0
- package/lib/rules/no-excessive-parent-chain.js +43 -10
- package/lib/rules/no-unnecessary-destructuring.js +8 -1
- package/lib/rules/prefer-clone-deep.d.ts +2 -1
- package/lib/rules/prefer-clone-deep.js +250 -192
- package/lib/rules/prefer-destructuring-no-class.js +8 -0
- package/lib/rules/prefer-spread-over-reassembly.d.ts +2 -1
- package/lib/rules/prefer-spread-over-reassembly.js +30 -2
- package/lib/rules/prefer-usecallback-over-usememo-for-functions.d.ts +2 -1
- package/lib/rules/prefer-usecallback-over-usememo-for-functions.js +192 -5
- package/lib/rules/use-custom-memo.d.ts +2 -1
- package/lib/rules/use-custom-memo.js +51 -36
- package/lib/rules/use-latest-callback.js +142 -92
- package/lib/utils/renameFixes.d.ts +69 -0
- package/lib/utils/renameFixes.js +202 -0
- package/package.json +1 -1
- package/release-manifest.json +132 -0
package/lib/index.js
CHANGED
|
@@ -739,6 +739,8 @@ function buildDestructuringGroups(callback, depTextSet, visitorKeys, sourceCode)
|
|
|
739
739
|
declarations,
|
|
740
740
|
inits,
|
|
741
741
|
baseName: existingGroup?.baseName ?? baseName ?? null,
|
|
742
|
+
hasTypeAnnotation: Boolean(existingGroup?.hasTypeAnnotation) ||
|
|
743
|
+
Boolean(declarator.id.typeAnnotation),
|
|
742
744
|
});
|
|
743
745
|
}
|
|
744
746
|
}
|
|
@@ -764,6 +766,18 @@ function validateGroupsForHoisting(groups, callback, scope, visitorKeys, reserve
|
|
|
764
766
|
if (hasCrossGroupNameCollision(groups)) {
|
|
765
767
|
return null;
|
|
766
768
|
}
|
|
769
|
+
// The hoisted declaration re-emits the pattern from property texts against a
|
|
770
|
+
// `(obj) ?? {}` initializer. A declarator annotation cannot survive that: the
|
|
771
|
+
// `{}` fallback almost never satisfies the annotation, and widening it to one
|
|
772
|
+
// that does requires the type checker. Reporting without a fix is preferable
|
|
773
|
+
// to silently dropping the annotation or manufacturing a type error, and a
|
|
774
|
+
// single annotated declarator withholds the whole fix because a partial hoist
|
|
775
|
+
// would still rewrite the dependency array around the statement left behind.
|
|
776
|
+
for (const group of groups.values()) {
|
|
777
|
+
if (group.hasTypeAnnotation) {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
767
781
|
const declarationsToRemove = new Set();
|
|
768
782
|
const initsToIgnore = new Set();
|
|
769
783
|
for (const group of groups.values()) {
|
|
@@ -198,42 +198,193 @@ function isAllCapsViolation(text, ignoredWordsSet) {
|
|
|
198
198
|
return !allExempt;
|
|
199
199
|
}
|
|
200
200
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
201
|
+
* Splits a whitespace-delimited token into its leading punctuation, the bare
|
|
202
|
+
* word, and its trailing punctuation, so casing can be applied to the word
|
|
203
|
+
* without disturbing quotes/brackets/periods around it. Only *surrounding*
|
|
204
|
+
* punctuation is peeled off, so a possessive stays whole: `USER'S` yields the
|
|
205
|
+
* single core `USER'S` rather than a `USER` word and a stray `'S`.
|
|
205
206
|
*/
|
|
206
|
-
function
|
|
207
|
+
function splitWordAffixes(raw) {
|
|
208
|
+
const lead = /^[^\w]*/.exec(raw)?.[0] ?? '';
|
|
209
|
+
const rest = raw.slice(lead.length);
|
|
210
|
+
const trail = /[^\w]*$/.exec(rest)?.[0] ?? '';
|
|
211
|
+
return { lead, core: rest.slice(0, rest.length - trail.length), trail };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Upper-cases the first alphabetic character, leaving any leading punctuation
|
|
215
|
+
* untouched so `(text` becomes `(Text` rather than staying lower-case.
|
|
216
|
+
*/
|
|
217
|
+
function capitalizeFirstLetter(text) {
|
|
218
|
+
const index = text.search(/[a-zA-Z]/);
|
|
219
|
+
if (index === -1)
|
|
220
|
+
return text;
|
|
221
|
+
return (text.slice(0, index) +
|
|
222
|
+
text.charAt(index).toUpperCase() +
|
|
223
|
+
text.slice(index + 1));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* True when every letter in the token is upper-case (`USER'S`, `FORM`), which
|
|
227
|
+
* means lower-casing just the leading character would leave a shouting tail.
|
|
228
|
+
*/
|
|
229
|
+
function isAllUpperCase(text) {
|
|
230
|
+
const letters = text.replace(/[^a-zA-Z]/g, '');
|
|
231
|
+
return letters.length > 0 && letters === letters.toUpperCase();
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Maps the lower-cased form of every ignored word to its canonical spelling so
|
|
235
|
+
* an ALL-CAPS occurrence (`GOOGLE`) can be restored to `Google` rather than
|
|
236
|
+
* flattened to `google`.
|
|
237
|
+
*/
|
|
238
|
+
function buildCanonicalIgnoredWords(ignoredWordsSet) {
|
|
239
|
+
const canonical = new Map();
|
|
240
|
+
ignoredWordsSet.forEach((word) => {
|
|
241
|
+
const key = word.toLowerCase();
|
|
242
|
+
if (!canonical.has(key))
|
|
243
|
+
canonical.set(key, word);
|
|
244
|
+
});
|
|
245
|
+
return canonical;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Builds the corrected text for an ALL-CAPS violation: the entire string is
|
|
249
|
+
* lower-cased and each sentence's first letter is capitalised. Only words in
|
|
250
|
+
* the explicit acronym allowlist and ignored/proper nouns keep their casing —
|
|
251
|
+
* the length-based acronym heuristic cannot be used here because *every* token
|
|
252
|
+
* of an ALL-CAPS string looks like an acronym.
|
|
253
|
+
*/
|
|
254
|
+
function buildAllCapsSuggestionText(text, ignoredWordsSet) {
|
|
255
|
+
const canonicalIgnoredWords = buildCanonicalIgnoredWords(ignoredWordsSet);
|
|
256
|
+
return splitIntoSentences(text)
|
|
257
|
+
.map((sentence) => sentence
|
|
258
|
+
.split(/\s+/)
|
|
259
|
+
.map((raw, index) => {
|
|
260
|
+
const { lead, core, trail } = splitWordAffixes(raw);
|
|
261
|
+
if (!core)
|
|
262
|
+
return raw;
|
|
263
|
+
const canonical = canonicalIgnoredWords.get(core.toLowerCase());
|
|
264
|
+
if (canonical)
|
|
265
|
+
return `${lead}${canonical}${trail}`;
|
|
266
|
+
if (ACRONYM_ALLOWLIST.has(core))
|
|
267
|
+
return raw;
|
|
268
|
+
const lowered = core.toLowerCase();
|
|
269
|
+
const cased = index === 0 ? capitalizeFirstLetter(lowered) : lowered;
|
|
270
|
+
return `${lead}${cased}${trail}`;
|
|
271
|
+
})
|
|
272
|
+
.join(' '))
|
|
273
|
+
.join(' ');
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Builds the corrected text for a Title Case violation: non-first words that
|
|
277
|
+
* are neither acronyms nor proper nouns are lower-cased. Mixed-case tokens
|
|
278
|
+
* only lose their leading capital (`Name` → `name`) so intra-word casing such
|
|
279
|
+
* as `McDonald` survives, while shouting tokens (`CHANGES`) are lower-cased in
|
|
280
|
+
* full.
|
|
281
|
+
*/
|
|
282
|
+
function buildTitleCaseSuggestionText(text, ignoredWordsSet) {
|
|
207
283
|
const sentences = splitIntoSentences(text);
|
|
208
284
|
return sentences
|
|
209
285
|
.map((sentence) => {
|
|
210
286
|
const words = sentence.split(/\s+/);
|
|
211
287
|
return words
|
|
212
288
|
.map((raw, index) => {
|
|
213
|
-
const
|
|
214
|
-
if (!
|
|
289
|
+
const { lead, core, trail } = splitWordAffixes(raw);
|
|
290
|
+
if (!core)
|
|
215
291
|
return raw;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// first words, lower-case everything except the initial letter.
|
|
219
|
-
if (ignoredWordsSet.has(word))
|
|
220
|
-
return raw;
|
|
221
|
-
if (isAcronymToken(word))
|
|
222
|
-
return raw;
|
|
223
|
-
// Sentence-start: ensure first letter is capital
|
|
224
|
-
return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
|
|
225
|
-
}
|
|
226
|
-
if (ignoredWordsSet.has(word))
|
|
292
|
+
// Proper nouns and acronyms keep their original capitalisation.
|
|
293
|
+
if (ignoredWordsSet.has(core))
|
|
227
294
|
return raw;
|
|
228
|
-
if (isAcronymToken(
|
|
295
|
+
if (isAcronymToken(core))
|
|
229
296
|
return raw;
|
|
230
|
-
|
|
231
|
-
|
|
297
|
+
const lowered = isAllUpperCase(core)
|
|
298
|
+
? core.toLowerCase()
|
|
299
|
+
: core.charAt(0).toLowerCase() + core.slice(1);
|
|
300
|
+
// Sentence-start: ensure the first letter is a capital.
|
|
301
|
+
const cased = index === 0 ? capitalizeFirstLetter(lowered) : lowered;
|
|
302
|
+
return `${lead}${cased}${trail}`;
|
|
232
303
|
})
|
|
233
304
|
.join(' ');
|
|
234
305
|
})
|
|
235
306
|
.join(' ');
|
|
236
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* Escape sequences for characters that cannot appear literally inside a
|
|
310
|
+
* JavaScript string literal.
|
|
311
|
+
*/
|
|
312
|
+
const JS_STRING_ESCAPES = new Map([
|
|
313
|
+
['\\', '\\\\'],
|
|
314
|
+
['\n', '\\n'],
|
|
315
|
+
['\r', '\\r'],
|
|
316
|
+
['\t', '\\t'],
|
|
317
|
+
['\b', '\\b'],
|
|
318
|
+
['\f', '\\f'],
|
|
319
|
+
['\v', '\\v'],
|
|
320
|
+
['\u2028', '\\u2028'],
|
|
321
|
+
['\u2029', '\\u2029'],
|
|
322
|
+
]);
|
|
323
|
+
/**
|
|
324
|
+
* Escapes text for re-emission inside a JavaScript string literal delimited by
|
|
325
|
+
* `quote`. Without this the rebuilt literal is unparseable as soon as the text
|
|
326
|
+
* contains the delimiter (`'THE USER\'S FILE'`), a backslash, or a line
|
|
327
|
+
* terminator.
|
|
328
|
+
*/
|
|
329
|
+
function escapeJsString(text, quote) {
|
|
330
|
+
let escaped = '';
|
|
331
|
+
for (let index = 0; index < text.length; index++) {
|
|
332
|
+
const char = text.charAt(index);
|
|
333
|
+
if (char === quote) {
|
|
334
|
+
escaped += `\\${char}`;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const mapped = JS_STRING_ESCAPES.get(char);
|
|
338
|
+
if (mapped) {
|
|
339
|
+
escaped += mapped;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (char < ' ' || char === '\u007f') {
|
|
343
|
+
escaped += `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
escaped += char;
|
|
347
|
+
}
|
|
348
|
+
return escaped;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Escapes text for re-emission inside a JSX attribute string (`label="…"`).
|
|
352
|
+
* JSX attribute strings do not process backslash escapes, so the delimiter can
|
|
353
|
+
* only be represented as a character reference. `&` is re-encoded because the
|
|
354
|
+
* value read off the AST has already been entity-decoded.
|
|
355
|
+
*/
|
|
356
|
+
function escapeJsxAttributeString(text, quote) {
|
|
357
|
+
const escaped = text.split('&').join('&');
|
|
358
|
+
return quote === "'"
|
|
359
|
+
? escaped.split("'").join(''')
|
|
360
|
+
: escaped.split('"').join('"');
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Characters that change the meaning of JSX children and must therefore be
|
|
364
|
+
* written back as character references. `JSXText.value` is entity-decoded, so
|
|
365
|
+
* emitting it verbatim can break parsing (`<` → `<`) or silently turn text
|
|
366
|
+
* into an expression container (`{x}` → `{x}`).
|
|
367
|
+
*/
|
|
368
|
+
const JSX_TEXT_ENTITIES = new Map([
|
|
369
|
+
['&', '&'],
|
|
370
|
+
['<', '<'],
|
|
371
|
+
['>', '>'],
|
|
372
|
+
['{', '{'],
|
|
373
|
+
['}', '}'],
|
|
374
|
+
]);
|
|
375
|
+
function escapeJsxText(text) {
|
|
376
|
+
return text.replace(/[&<>{}]/g, (char) => JSX_TEXT_ENTITIES.get(char) ?? char);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Replaces the first occurrence of `search` without treating `$` sequences in
|
|
380
|
+
* the replacement as `String.prototype.replace` patterns.
|
|
381
|
+
*/
|
|
382
|
+
function replaceFirst(text, search, replacement) {
|
|
383
|
+
const index = text.indexOf(search);
|
|
384
|
+
if (index === -1)
|
|
385
|
+
return replacement;
|
|
386
|
+
return text.slice(0, index) + replacement + text.slice(index + search.length);
|
|
387
|
+
}
|
|
237
388
|
/**
|
|
238
389
|
* Returns the trimmed text to check and whether it is worth checking.
|
|
239
390
|
* JSXText nodes often contain only whitespace / newlines from formatting.
|
|
@@ -314,6 +465,35 @@ exports.enforceM3SentenceCase = (0, createRule_1.createRule)({
|
|
|
314
465
|
return true;
|
|
315
466
|
return false;
|
|
316
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Produces the replacement source text for the reported node, re-escaping
|
|
470
|
+
* the suggestion for whichever literal form it is being written back into.
|
|
471
|
+
* Returns null when the node is not a rewritable literal.
|
|
472
|
+
*/
|
|
473
|
+
function buildFixText(reportNode, checkable, suggestion) {
|
|
474
|
+
if (reportNode.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
475
|
+
if (typeof reportNode.value !== 'string')
|
|
476
|
+
return null;
|
|
477
|
+
const raw = context.getSourceCode().getText(reportNode);
|
|
478
|
+
const quote = raw.charAt(0);
|
|
479
|
+
// Template literals never reach here (they are skipped as dynamic), so
|
|
480
|
+
// only the two string-literal delimiters are rewritable.
|
|
481
|
+
if (quote !== '"' && quote !== "'")
|
|
482
|
+
return null;
|
|
483
|
+
// Surrounding whitespace of the original value is preserved; only the
|
|
484
|
+
// trimmed, checked portion is re-cased.
|
|
485
|
+
const replaced = replaceFirst(reportNode.value, checkable, suggestion);
|
|
486
|
+
const inner = reportNode.parent?.type === utils_1.AST_NODE_TYPES.JSXAttribute
|
|
487
|
+
? escapeJsxAttributeString(replaced, quote)
|
|
488
|
+
: escapeJsString(replaced, quote);
|
|
489
|
+
return `${quote}${inner}${quote}`;
|
|
490
|
+
}
|
|
491
|
+
if (reportNode.type === utils_1.AST_NODE_TYPES.JSXText) {
|
|
492
|
+
const replaced = replaceFirst(reportNode.value, checkable, suggestion);
|
|
493
|
+
return escapeJsxText(replaced);
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
317
497
|
/**
|
|
318
498
|
* Core checker. Reports on `reportNode` if `text` violates M3 sentence case.
|
|
319
499
|
*/
|
|
@@ -325,7 +505,7 @@ exports.enforceM3SentenceCase = (0, createRule_1.createRule)({
|
|
|
325
505
|
return;
|
|
326
506
|
// ALL-CAPS check first (higher severity and different fix)
|
|
327
507
|
if (isAllCapsViolation(checkable, ignoredWordsSet)) {
|
|
328
|
-
const suggestion =
|
|
508
|
+
const suggestion = buildAllCapsSuggestionText(checkable, ignoredWordsSet);
|
|
329
509
|
context.report({
|
|
330
510
|
node: reportNode,
|
|
331
511
|
messageId: 'allCaps',
|
|
@@ -335,17 +515,10 @@ exports.enforceM3SentenceCase = (0, createRule_1.createRule)({
|
|
|
335
515
|
messageId: 'allCaps',
|
|
336
516
|
data: { text: checkable, suggestion },
|
|
337
517
|
fix(fixer) {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
if (reportNode.type === utils_1.AST_NODE_TYPES.JSXText) {
|
|
344
|
-
const original = reportNode.value;
|
|
345
|
-
const replaced = original.replace(checkable, suggestion);
|
|
346
|
-
return fixer.replaceText(reportNode, replaced);
|
|
347
|
-
}
|
|
348
|
-
return null;
|
|
518
|
+
const fixText = buildFixText(reportNode, checkable, suggestion);
|
|
519
|
+
return fixText === null
|
|
520
|
+
? null
|
|
521
|
+
: fixer.replaceText(reportNode, fixText);
|
|
349
522
|
},
|
|
350
523
|
},
|
|
351
524
|
],
|
|
@@ -359,7 +532,7 @@ exports.enforceM3SentenceCase = (0, createRule_1.createRule)({
|
|
|
359
532
|
violatingWords.push(...titleCaseViolatingWords(sentence, ignoredWordsSet));
|
|
360
533
|
});
|
|
361
534
|
if (violatingWords.length > 0) {
|
|
362
|
-
const suggestion =
|
|
535
|
+
const suggestion = buildTitleCaseSuggestionText(checkable, ignoredWordsSet);
|
|
363
536
|
context.report({
|
|
364
537
|
node: reportNode,
|
|
365
538
|
messageId: 'titleCase',
|
|
@@ -369,17 +542,10 @@ exports.enforceM3SentenceCase = (0, createRule_1.createRule)({
|
|
|
369
542
|
messageId: 'titleCase',
|
|
370
543
|
data: { text: checkable, suggestion },
|
|
371
544
|
fix(fixer) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
if (reportNode.type === utils_1.AST_NODE_TYPES.JSXText) {
|
|
378
|
-
const original = reportNode.value;
|
|
379
|
-
const replaced = original.replace(checkable, suggestion);
|
|
380
|
-
return fixer.replaceText(reportNode, replaced);
|
|
381
|
-
}
|
|
382
|
-
return null;
|
|
545
|
+
const fixText = buildFixText(reportNode, checkable, suggestion);
|
|
546
|
+
return fixText === null
|
|
547
|
+
? null
|
|
548
|
+
: fixer.replaceText(reportNode, fixText);
|
|
383
549
|
},
|
|
384
550
|
},
|
|
385
551
|
],
|
|
@@ -3,6 +3,84 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.enforcePropsNamingConsistency = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const renameFixes_1 = require("../utils/renameFixes");
|
|
8
|
+
/**
|
|
9
|
+
* A body-less signature — an interface or object-type method member, or a
|
|
10
|
+
* `declare`/overload constructor — has no statements, so its parameter name is
|
|
11
|
+
* documentation-only and can never be referenced. A declaration-only rename is
|
|
12
|
+
* therefore complete rather than partial, and stays correct even though the
|
|
13
|
+
* scope analyzer models no variable for such a parameter.
|
|
14
|
+
*/
|
|
15
|
+
const isBodylessSignature = (owner) => owner.type === utils_1.AST_NODE_TYPES.TSMethodSignature ||
|
|
16
|
+
owner.type === utils_1.AST_NODE_TYPES.TSEmptyBodyFunctionExpression;
|
|
17
|
+
/**
|
|
18
|
+
* Finds the class (declaration or expression) that owns a method definition.
|
|
19
|
+
*/
|
|
20
|
+
const getEnclosingClass = (node) => {
|
|
21
|
+
const classBody = node.parent;
|
|
22
|
+
if (classBody?.type !== utils_1.AST_NODE_TYPES.ClassBody) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const classNode = classBody.parent;
|
|
26
|
+
if (classNode?.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
|
|
27
|
+
classNode?.type === utils_1.AST_NODE_TYPES.ClassExpression) {
|
|
28
|
+
return classNode;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Reports whether renaming a constructor parameter property is unsafe to
|
|
34
|
+
* autofix.
|
|
35
|
+
*
|
|
36
|
+
* A parameter property (`private readonly settings: FooProps`) declares BOTH a
|
|
37
|
+
* constructor-local binding and a `this.settings` class field. The scope
|
|
38
|
+
* analyzer only models the binding, so a scope-driven rename silently rewrites
|
|
39
|
+
* the field declaration while leaving every `this.settings` access — and every
|
|
40
|
+
* plain `settings` use the analyzer attributes elsewhere — pointing at a name
|
|
41
|
+
* that no longer exists (Issue #1358). Since the field half of the rename
|
|
42
|
+
* cannot be resolved through scope analysis, the fix is withheld whenever the
|
|
43
|
+
* name occurs anywhere in the class other than at its declaration.
|
|
44
|
+
*/
|
|
45
|
+
const parameterPropertyRenameIsUnsafe = (classNode, name, declarationId) => {
|
|
46
|
+
let unsafe = false;
|
|
47
|
+
const visit = (node) => {
|
|
48
|
+
if (unsafe) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (node.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
52
|
+
node.object.type === utils_1.AST_NODE_TYPES.ThisExpression &&
|
|
53
|
+
node.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
54
|
+
node.property.name === name) {
|
|
55
|
+
unsafe = true;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (node.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
59
|
+
node.name === name &&
|
|
60
|
+
node !== declarationId) {
|
|
61
|
+
unsafe = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const key of Object.keys(node)) {
|
|
65
|
+
if (key === 'parent') {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const value = node[key];
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
for (const child of value) {
|
|
71
|
+
if (ASTHelpers_1.ASTHelpers.isNode(child)) {
|
|
72
|
+
visit(child);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
77
|
+
visit(value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
visit(classNode);
|
|
82
|
+
return unsafe;
|
|
83
|
+
};
|
|
6
84
|
exports.enforcePropsNamingConsistency = (0, createRule_1.createRule)({
|
|
7
85
|
name: 'enforce-props-naming-consistency',
|
|
8
86
|
meta: {
|
|
@@ -51,9 +129,39 @@ exports.enforcePropsNamingConsistency = (0, createRule_1.createRule)({
|
|
|
51
129
|
function isPropsNameWithPrefix(paramName) {
|
|
52
130
|
return paramName.endsWith('Props') || paramName === 'props';
|
|
53
131
|
}
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
132
|
+
// Build the complete rename: the parameter declaration AND every in-file
|
|
133
|
+
// reference to it, rewriting only name tokens.
|
|
134
|
+
//
|
|
135
|
+
// `fixer.replaceText(param, 'props')` used to be the whole fix, which was
|
|
136
|
+
// wrong twice over: an `Identifier` range spans its optional marker and its
|
|
137
|
+
// type annotation, so replacing it deleted the very `: FooProps` annotation
|
|
138
|
+
// that triggered the report, and the body kept referencing the old name
|
|
139
|
+
// (Issue #1358). Returns null whenever the rename cannot be applied
|
|
140
|
+
// everywhere, so the report stands on its own instead of corrupting the
|
|
141
|
+
// source.
|
|
142
|
+
function buildParameterRenameFixes(fixer, owner, param) {
|
|
143
|
+
const sourceCode = context.getSourceCode();
|
|
144
|
+
// `getDeclaredVariables` on a function returns every parameter plus
|
|
145
|
+
// `arguments` and, for a declaration, the function's own name — and those
|
|
146
|
+
// can share a name (`function config(config: XProps)`), so the lookup
|
|
147
|
+
// matches on declaration identity instead of on the name.
|
|
148
|
+
const variable = context
|
|
149
|
+
.getDeclaredVariables(owner)
|
|
150
|
+
.find((candidate) => candidate.defs.some((def) => def.name === param)) ?? null;
|
|
151
|
+
if (!variable) {
|
|
152
|
+
if (!isBodylessSignature(owner)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const declarationFix = (0, renameFixes_1.renameIdentifierToken)(fixer, sourceCode, param, 'props');
|
|
156
|
+
return declarationFix ? [declarationFix] : null;
|
|
157
|
+
}
|
|
158
|
+
return (0, renameFixes_1.buildVariableRenameFixes)({
|
|
159
|
+
fixer,
|
|
160
|
+
sourceCode,
|
|
161
|
+
variable,
|
|
162
|
+
declarationId: param,
|
|
163
|
+
newName: 'props',
|
|
164
|
+
});
|
|
57
165
|
}
|
|
58
166
|
// Check function parameters
|
|
59
167
|
function checkFunctionParams(node) {
|
|
@@ -77,7 +185,7 @@ exports.enforcePropsNamingConsistency = (0, createRule_1.createRule)({
|
|
|
77
185
|
node: param,
|
|
78
186
|
messageId: 'usePropsName',
|
|
79
187
|
data: { paramName: param.name },
|
|
80
|
-
fix: (fixer) =>
|
|
188
|
+
fix: (fixer) => buildParameterRenameFixes(fixer, node, param),
|
|
81
189
|
});
|
|
82
190
|
}
|
|
83
191
|
}
|
|
@@ -109,6 +217,7 @@ exports.enforcePropsNamingConsistency = (0, createRule_1.createRule)({
|
|
|
109
217
|
if (propsTypeParams.length > 1) {
|
|
110
218
|
return; // Skip constructors with multiple Props parameters
|
|
111
219
|
}
|
|
220
|
+
const enclosingClass = getEnclosingClass(node);
|
|
112
221
|
for (const param of constructor.params) {
|
|
113
222
|
if (shouldBeNamedProps(param) &&
|
|
114
223
|
param.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
@@ -117,18 +226,28 @@ exports.enforcePropsNamingConsistency = (0, createRule_1.createRule)({
|
|
|
117
226
|
node: param,
|
|
118
227
|
messageId: 'usePropsName',
|
|
119
228
|
data: { paramName: param.name },
|
|
120
|
-
fix: (fixer) =>
|
|
229
|
+
fix: (fixer) => buildParameterRenameFixes(fixer, constructor, param),
|
|
121
230
|
});
|
|
122
231
|
}
|
|
123
232
|
else if (param.type === utils_1.AST_NODE_TYPES.TSParameterProperty &&
|
|
124
233
|
param.parameter.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
125
234
|
shouldBeNamedProps(param.parameter) &&
|
|
126
235
|
!isPropsNameWithPrefix(param.parameter.name)) {
|
|
236
|
+
const declarationId = param.parameter;
|
|
127
237
|
context.report({
|
|
128
|
-
node:
|
|
238
|
+
node: declarationId,
|
|
129
239
|
messageId: 'usePropsName',
|
|
130
|
-
data: { paramName:
|
|
131
|
-
fix: (fixer) =>
|
|
240
|
+
data: { paramName: declarationId.name },
|
|
241
|
+
fix: (fixer) => {
|
|
242
|
+
// A parameter property also declares a `this.<name>` field the
|
|
243
|
+
// scope analyzer does not model, so renaming it is only safe
|
|
244
|
+
// when the name appears nowhere else in the class.
|
|
245
|
+
if (!enclosingClass ||
|
|
246
|
+
parameterPropertyRenameIsUnsafe(enclosingClass, declarationId.name, declarationId)) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
return buildParameterRenameFixes(fixer, constructor, declarationId);
|
|
250
|
+
},
|
|
132
251
|
});
|
|
133
252
|
}
|
|
134
253
|
}
|