@blumintinc/eslint-plugin-blumint 1.20.100 → 1.20.102
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 +87 -9
- package/lib/rules/enforce-identifiable-firestore-type.js +69 -1
- package/lib/rules/ensure-pointer-events-none.js +49 -7
- package/lib/rules/no-unnecessary-destructuring-rename.js +51 -2
- package/lib/rules/semantic-function-prefixes.js +63 -40
- package/lib/utils/renameFixes.js +6 -5
- package/package.json +1 -1
- package/release-manifest.json +60 -0
package/lib/index.js
CHANGED
|
@@ -4,7 +4,60 @@ exports.enforceCentralizedMockFirestore = void 0;
|
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const importInsertion_1 = require("../utils/importInsertion");
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The centralized module's identity: the path segments that name it, without
|
|
9
|
+
* the relative prefix a specifier needs to reach it. Keeping the identity
|
|
10
|
+
* separate from the specifier is what makes the self-file guard below correct
|
|
11
|
+
* no matter what prefix the fixer emits — the emitted depth is a hardcoded
|
|
12
|
+
* guess (Issue #1387) and a guard keyed on it would exempt the wrong files.
|
|
13
|
+
*/
|
|
14
|
+
const MOCK_FIRESTORE_MODULE = '__test-utils__/mockFirestore';
|
|
15
|
+
const MOCK_FIRESTORE_PATH = `../../../../../${MOCK_FIRESTORE_MODULE}`;
|
|
16
|
+
const SOURCE_EXTENSION = /\.(?:ts|tsx|js|jsx)$/;
|
|
17
|
+
/**
|
|
18
|
+
* The module every other file is told to import from is the one module that
|
|
19
|
+
* must define `mockFirestore` locally: rewriting it produces an import of
|
|
20
|
+
* itself, so the canonical implementation is deleted and the module imports a
|
|
21
|
+
* name it no longer defines.
|
|
22
|
+
*
|
|
23
|
+
* The linted path is matched by suffix because it reaches the rule in whatever
|
|
24
|
+
* form the caller used — absolute (`/repo/src/__test-utils__/mockFirestore.ts`)
|
|
25
|
+
* or project-relative (`__test-utils__/mockFirestore.ts`). The suffix has to
|
|
26
|
+
* land on a path-segment boundary, otherwise `not__test-utils__/mockFirestore`
|
|
27
|
+
* — an unrelated module — would be exempted too.
|
|
28
|
+
*/
|
|
29
|
+
const isCentralizedMockModule = (filename) => {
|
|
30
|
+
const normalized = filename.replace(/\\/g, '/').replace(SOURCE_EXTENSION, '');
|
|
31
|
+
if (!normalized.endsWith(MOCK_FIRESTORE_MODULE)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const suffixStart = normalized.length - MOCK_FIRESTORE_MODULE.length;
|
|
35
|
+
return suffixStart === 0 || normalized[suffixStart - 1] === '/';
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Reports whether a flagged declaration sits on the module's export surface,
|
|
39
|
+
* either as `export const mockFirestore = …` or as one binding of an exported
|
|
40
|
+
* multi-declarator `const`.
|
|
41
|
+
*
|
|
42
|
+
* An exported name is a cross-file contract whose importers a single-file fixer
|
|
43
|
+
* cannot reach: retiring the declaration drops the name from the surface and
|
|
44
|
+
* every importer fails to resolve it. The hazard lives entirely in those other
|
|
45
|
+
* files, so it does not depend on whether the declaring file also uses the name
|
|
46
|
+
* — a mock module with no local use sites is the most exposed shape, not the
|
|
47
|
+
* safest. `global-const-style` and `renameFixes` withhold their fixes on the
|
|
48
|
+
* same grounds.
|
|
49
|
+
*
|
|
50
|
+
* Only `ExportNamedDeclaration` can front a flagged node: `export default`
|
|
51
|
+
* takes an expression, never a `const`, and a class property — the other shape
|
|
52
|
+
* this rule flags — belongs to its class rather than to the module, so
|
|
53
|
+
* `export default class { mockFirestore = … }` exports nothing by that name.
|
|
54
|
+
*/
|
|
55
|
+
function isExportedDeclaration(node) {
|
|
56
|
+
const statement = node.parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration
|
|
57
|
+
? node.parent
|
|
58
|
+
: node;
|
|
59
|
+
return statement.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
|
|
60
|
+
}
|
|
8
61
|
function isHorizontalWhitespace(character) {
|
|
9
62
|
return character === ' ' || character === '\t';
|
|
10
63
|
}
|
|
@@ -58,16 +111,20 @@ const STATEMENT_CONTAINERS = new Set([
|
|
|
58
111
|
utils_1.AST_NODE_TYPES.TSModuleBlock,
|
|
59
112
|
]);
|
|
60
113
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
114
|
+
* The declaration when it stands as a statement of its own, and nothing
|
|
115
|
+
* otherwise.
|
|
116
|
+
*
|
|
117
|
+
* An `export const` is deliberately not widened to the `export` that fronts it:
|
|
118
|
+
* swallowing the keyword retires the name from the module's export surface, so
|
|
119
|
+
* such a declaration has no retirable statement at all and its enclosing
|
|
120
|
+
* `ExportNamedDeclaration` is absent from `STATEMENT_CONTAINERS` for that
|
|
121
|
+
* reason. `isExportedDeclaration` refuses the same shape ahead of this call and
|
|
122
|
+
* covers the multi-declarator form this branch never sees.
|
|
63
123
|
*/
|
|
64
124
|
function retirableStatement(declaration) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return statement.parent &&
|
|
69
|
-
STATEMENT_CONTAINERS.has(statement.parent.type)
|
|
70
|
-
? statement
|
|
125
|
+
return declaration.parent &&
|
|
126
|
+
STATEMENT_CONTAINERS.has(declaration.parent.type)
|
|
127
|
+
? declaration
|
|
71
128
|
: undefined;
|
|
72
129
|
}
|
|
73
130
|
function retiredSpan(node) {
|
|
@@ -167,6 +224,20 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
167
224
|
},
|
|
168
225
|
defaultOptions: [],
|
|
169
226
|
create(context) {
|
|
227
|
+
// A processor hands the rule a virtual filename for an extracted code block;
|
|
228
|
+
// the physical path is the one that identifies the module on disk.
|
|
229
|
+
const filename = context.getPhysicalFilename
|
|
230
|
+
? context.getPhysicalFilename()
|
|
231
|
+
: context.getFilename();
|
|
232
|
+
// The centralized module is exempt outright rather than reported without a
|
|
233
|
+
// fix: its local definition IS the canonical one this rule directs every
|
|
234
|
+
// other file to, so there is nothing for its author to do — the message's
|
|
235
|
+
// remedy, "import mockFirestore from the centralized test util", names the
|
|
236
|
+
// file it is reported in. `use-custom-memo`, `use-custom-router` and
|
|
237
|
+
// `use-custom-link` exempt their wrapper implementations the same way.
|
|
238
|
+
if (isCentralizedMockModule(filename)) {
|
|
239
|
+
return {};
|
|
240
|
+
}
|
|
170
241
|
let hasCentralizedImport = false;
|
|
171
242
|
const mockFirestoreNodes = new Set();
|
|
172
243
|
const customMockFirestoreNames = new Set();
|
|
@@ -302,6 +373,13 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
302
373
|
// them.
|
|
303
374
|
const removals = [];
|
|
304
375
|
for (const node of mockFirestoreNodes) {
|
|
376
|
+
if (isExportedDeclaration(node)) {
|
|
377
|
+
// Retiring an exported declaration takes the name off the
|
|
378
|
+
// module's export surface, breaking importers this fixer
|
|
379
|
+
// cannot see. The report stands so the local mock is still
|
|
380
|
+
// surfaced; collapsing it is a cross-file edit a human owns.
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
305
383
|
const span = retiredSpan(node);
|
|
306
384
|
if (!span) {
|
|
307
385
|
// A declaration that cannot be excised cleanly gets no
|
|
@@ -8,6 +8,14 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const createRule_1 = require("../utils/createRule");
|
|
10
10
|
const TRANSPARENT_TYPE_NAMES = new Set(['Readonly', 'Resolve']);
|
|
11
|
+
// A binding introduced by any of these declares a type whose shape lives in
|
|
12
|
+
// another module. This rule is purely syntactic and reads a single file, so
|
|
13
|
+
// such a type is opaque to it.
|
|
14
|
+
const IMPORT_BINDING_NODE_TYPES = new Set([
|
|
15
|
+
utils_1.AST_NODE_TYPES.ImportSpecifier,
|
|
16
|
+
utils_1.AST_NODE_TYPES.ImportDefaultSpecifier,
|
|
17
|
+
utils_1.AST_NODE_TYPES.ImportNamespaceSpecifier,
|
|
18
|
+
]);
|
|
11
19
|
exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
12
20
|
name: 'enforce-identifiable-firestore-type',
|
|
13
21
|
meta: {
|
|
@@ -47,6 +55,10 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
47
55
|
let matchingAliasNode = null;
|
|
48
56
|
let matchingAliasInlineExported = false;
|
|
49
57
|
let matchingAliasHasIdentifiable = false;
|
|
58
|
+
// Set when resolving the matching alias runs into a type that is declared
|
|
59
|
+
// in another module. Such a type is opaque to this single-file walk, so the
|
|
60
|
+
// absence of `Identifiable` is unproven rather than disproven.
|
|
61
|
+
let matchingAliasLeavesModule = false;
|
|
50
62
|
const locallyExportedNames = new Set();
|
|
51
63
|
return {
|
|
52
64
|
Program() {
|
|
@@ -54,6 +66,7 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
54
66
|
matchingAliasNode = null;
|
|
55
67
|
matchingAliasInlineExported = false;
|
|
56
68
|
matchingAliasHasIdentifiable = false;
|
|
69
|
+
matchingAliasLeavesModule = false;
|
|
57
70
|
locallyExportedNames.clear();
|
|
58
71
|
},
|
|
59
72
|
'Program:exit'(node) {
|
|
@@ -69,7 +82,13 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
69
82
|
},
|
|
70
83
|
});
|
|
71
84
|
}
|
|
72
|
-
else if (!matchingAliasHasIdentifiable
|
|
85
|
+
else if (!matchingAliasHasIdentifiable &&
|
|
86
|
+
!matchingAliasLeavesModule) {
|
|
87
|
+
// A chain that leaves the module is unknowable here: the imported
|
|
88
|
+
// type may well intersect `Identifiable`, and reporting would demand
|
|
89
|
+
// a type-theoretically redundant `Identifiable &` to silence a claim
|
|
90
|
+
// the rule cannot substantiate. Staying silent trades a false
|
|
91
|
+
// positive for a false negative, which this repo prefers.
|
|
73
92
|
context.report({
|
|
74
93
|
node,
|
|
75
94
|
messageId: 'notExtendingIdentifiable',
|
|
@@ -95,6 +114,23 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
95
114
|
matchingAliasNode = node;
|
|
96
115
|
matchingAliasInlineExported =
|
|
97
116
|
node.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration;
|
|
117
|
+
// Raised by the resolution walk below whenever a referenced type name
|
|
118
|
+
// resolves to an imported binding, i.e. the alias chain ran out of
|
|
119
|
+
// this file. Scoped to this alias so an import that no chain ever
|
|
120
|
+
// reaches — an unrelated `Timestamp`, or a type argument the walk
|
|
121
|
+
// never descends into — grants no amnesty.
|
|
122
|
+
let leavesModule = false;
|
|
123
|
+
const isImportedBinding = (name) => {
|
|
124
|
+
let scope = context.getScope();
|
|
125
|
+
while (scope) {
|
|
126
|
+
const variable = scope.variables.find((variableNode) => variableNode.name === name);
|
|
127
|
+
if (variable) {
|
|
128
|
+
return variable.defs.some((definition) => IMPORT_BINDING_NODE_TYPES.has(definition.node.type));
|
|
129
|
+
}
|
|
130
|
+
scope = scope.upper;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
};
|
|
98
134
|
const findTypeAliasAnnotation = (typeName) => {
|
|
99
135
|
let scope = context.getScope();
|
|
100
136
|
while (scope) {
|
|
@@ -108,11 +144,37 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
108
144
|
definition.node.typeAnnotation) {
|
|
109
145
|
return definition.node.typeAnnotation;
|
|
110
146
|
}
|
|
147
|
+
// The name is bound, but only to something this file cannot
|
|
148
|
+
// see through: an import. Record that the chain crossed the
|
|
149
|
+
// module boundary so the caller distinguishes "proved no
|
|
150
|
+
// Identifiable" from "could not look". A name with no binding
|
|
151
|
+
// at all (a lib global such as `Readonly` or `Map`) is left
|
|
152
|
+
// alone: those are known not to be Identifiable-bearing
|
|
153
|
+
// aliases, and treating them as unknown would silence the
|
|
154
|
+
// rule almost everywhere.
|
|
155
|
+
if (variable.defs.some((definition) => IMPORT_BINDING_NODE_TYPES.has(definition.node.type))) {
|
|
156
|
+
leavesModule = true;
|
|
157
|
+
}
|
|
111
158
|
}
|
|
112
159
|
scope = scope.upper;
|
|
113
160
|
}
|
|
114
161
|
return null;
|
|
115
162
|
};
|
|
163
|
+
// `Types.Team` from `import * as Types from '../Team'` names a type
|
|
164
|
+
// in another module just as `Team` from a named import does, but it
|
|
165
|
+
// carries a TSQualifiedName that the walks below never resolve. The
|
|
166
|
+
// leftmost identifier is the namespace binding, so it answers the
|
|
167
|
+
// same question: did the chain leave this file?
|
|
168
|
+
const noteQualifiedNamespaceImport = (qualifiedName) => {
|
|
169
|
+
let left = qualifiedName.left;
|
|
170
|
+
while (left.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
|
|
171
|
+
left = left.left;
|
|
172
|
+
}
|
|
173
|
+
if (left.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
174
|
+
isImportedBinding(left.name)) {
|
|
175
|
+
leavesModule = true;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
116
178
|
const isParenthesizedType = (node) => node?.type === 'TSParenthesizedType';
|
|
117
179
|
const isReadonlyTypeOperator = (node) => node?.type === utils_1.AST_NODE_TYPES.TSTypeOperator &&
|
|
118
180
|
node.operator === 'readonly';
|
|
@@ -229,12 +291,18 @@ exports.enforceIdentifiableFirestoreType = (0, createRule_1.createRule)({
|
|
|
229
291
|
const aliasAnnotation = findTypeAliasAnnotation(typeName);
|
|
230
292
|
return checkType(aliasAnnotation, visitedTypes);
|
|
231
293
|
}
|
|
294
|
+
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
295
|
+
resolvedType.typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
|
|
296
|
+
noteQualifiedNamespaceImport(resolvedType.typeName);
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
232
299
|
if (resolvedType.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
233
300
|
return resolvedType.types.some((part) => checkType(part, new Set(visitedTypes)));
|
|
234
301
|
}
|
|
235
302
|
return false;
|
|
236
303
|
};
|
|
237
304
|
matchingAliasHasIdentifiable = checkType(node.typeAnnotation);
|
|
305
|
+
matchingAliasLeavesModule = leavesModule;
|
|
238
306
|
}
|
|
239
307
|
},
|
|
240
308
|
};
|
|
@@ -25,8 +25,19 @@ function isPointerEventsProperty(propertyName) {
|
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* The inset offsets that position a pseudo-element relative to its origin box.
|
|
28
|
+
* The `inset` shorthand and its logical-property spellings carry the same sign
|
|
29
|
+
* semantics as the longhands, so an overlay must not change verdict on spelling
|
|
30
|
+
* alone.
|
|
28
31
|
*/
|
|
29
|
-
const INSET_PROPERTIES = new Set([
|
|
32
|
+
const INSET_PROPERTIES = new Set([
|
|
33
|
+
'top',
|
|
34
|
+
'right',
|
|
35
|
+
'bottom',
|
|
36
|
+
'left',
|
|
37
|
+
'inset',
|
|
38
|
+
'insetInline',
|
|
39
|
+
'insetBlock',
|
|
40
|
+
]);
|
|
30
41
|
/**
|
|
31
42
|
* Classifies the sign of a leading numeric length in a string offset value
|
|
32
43
|
* (e.g. '-6px' -> negative, '0'/'0px' -> zero, '6px' -> positive). Anything
|
|
@@ -42,11 +53,34 @@ function classifyOffsetString(raw) {
|
|
|
42
53
|
return match[1] === '-' ? 'negative' : 'positive';
|
|
43
54
|
}
|
|
44
55
|
/**
|
|
45
|
-
* Classifies
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
56
|
+
* Classifies a value that may carry several space-separated lengths, as the
|
|
57
|
+
* `inset` shorthand does (up to four). A positive component anywhere outranks a
|
|
58
|
+
* negative one: it pulls an edge inside the origin box, where the overlay can
|
|
59
|
+
* occlude the control.
|
|
60
|
+
*/
|
|
61
|
+
function classifyOffsetComponents(raw) {
|
|
62
|
+
// A CSS function such as calc() or var() embeds whitespace inside a single
|
|
63
|
+
// component, so splitting on whitespace would misread its parts as lengths.
|
|
64
|
+
if (raw.includes('('))
|
|
65
|
+
return 'unknown';
|
|
66
|
+
const signs = raw
|
|
67
|
+
.trim()
|
|
68
|
+
.split(/\s+/)
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.map(classifyOffsetString);
|
|
71
|
+
if (signs.length === 0)
|
|
72
|
+
return 'unknown';
|
|
73
|
+
if (signs.includes('positive'))
|
|
74
|
+
return 'positive';
|
|
75
|
+
if (signs.includes('negative'))
|
|
76
|
+
return 'negative';
|
|
77
|
+
return signs.every((sign) => sign === 'zero') ? 'zero' : 'unknown';
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Classifies an inset offset property value as a positive, negative, zero, or
|
|
81
|
+
* unknown length. Only the shapes that can be resolved statically are
|
|
82
|
+
* classified; variables, member expressions, and calls are treated as unknown
|
|
83
|
+
* and never counted toward the hit-slop exemption.
|
|
50
84
|
*/
|
|
51
85
|
function classifyOffsetValue(value) {
|
|
52
86
|
if (value.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
@@ -56,10 +90,18 @@ function classifyOffsetValue(value) {
|
|
|
56
90
|
return value.value < 0 ? 'negative' : 'positive';
|
|
57
91
|
}
|
|
58
92
|
if (typeof value.value === 'string') {
|
|
59
|
-
return
|
|
93
|
+
return classifyOffsetComponents(value.value);
|
|
60
94
|
}
|
|
61
95
|
return 'unknown';
|
|
62
96
|
}
|
|
97
|
+
// An offset derived from a named constant states its direction in the leading
|
|
98
|
+
// literal text, whatever the interpolation resolves to: an interpolated
|
|
99
|
+
// negative would render `--8px`, which is not a valid length. Every other
|
|
100
|
+
// interpolation stays opaque and earns no exemption.
|
|
101
|
+
if (value.type === utils_1.AST_NODE_TYPES.TemplateLiteral) {
|
|
102
|
+
const leading = value.quasis[0]?.value.raw.trim() ?? '';
|
|
103
|
+
return leading.startsWith('-') ? 'negative' : 'unknown';
|
|
104
|
+
}
|
|
63
105
|
// Negative numeric literals parse as `-` UnaryExpression over a number.
|
|
64
106
|
if (value.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
|
65
107
|
value.operator === '-' &&
|
|
@@ -95,6 +95,49 @@ function getRenamedPropertyInfo(property) {
|
|
|
95
95
|
}
|
|
96
96
|
return null;
|
|
97
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Node types that can sit between a renamed property and the declarator whose
|
|
100
|
+
* binding pattern it belongs to.
|
|
101
|
+
*/
|
|
102
|
+
const BINDING_PATTERN_TYPES = new Set([
|
|
103
|
+
utils_1.AST_NODE_TYPES.ObjectPattern,
|
|
104
|
+
utils_1.AST_NODE_TYPES.ArrayPattern,
|
|
105
|
+
utils_1.AST_NODE_TYPES.AssignmentPattern,
|
|
106
|
+
utils_1.AST_NODE_TYPES.RestElement,
|
|
107
|
+
utils_1.AST_NODE_TYPES.Property,
|
|
108
|
+
]);
|
|
109
|
+
/**
|
|
110
|
+
* Reports whether `pattern` destructures an `export const { … } = …`
|
|
111
|
+
* declaration.
|
|
112
|
+
*
|
|
113
|
+
* The alias of an exported destructuring IS the module's public export name, so
|
|
114
|
+
* collapsing `export const { id: renamedId }` to `export const { id }` renames
|
|
115
|
+
* the export. Every importer spells the old name out in a file this single-file
|
|
116
|
+
* fixer cannot reach, so the rewrite breaks them all (TS2305/TS2724) with no
|
|
117
|
+
* local symptom. The hazard therefore does not depend on how the declaring file
|
|
118
|
+
* uses the binding — a module that only re-publishes the value is the most
|
|
119
|
+
* exposed shape, not the safest. `global-const-style` and the shared rename
|
|
120
|
+
* fixer withhold their rewrites on the same grounds.
|
|
121
|
+
*
|
|
122
|
+
* The walk climbs the binding pattern rather than reading the property's
|
|
123
|
+
* immediate parent because the rename can be nested
|
|
124
|
+
* (`export const { user: { name: userName } } = data`). It stops at the first
|
|
125
|
+
* ancestor that is not part of a binding pattern, so a destructured parameter
|
|
126
|
+
* of an exported function answers false: that binding is function-local and
|
|
127
|
+
* never an export name.
|
|
128
|
+
*/
|
|
129
|
+
function isExportedDestructuringPattern(pattern) {
|
|
130
|
+
let current = pattern;
|
|
131
|
+
while (current && BINDING_PATTERN_TYPES.has(current.type)) {
|
|
132
|
+
current = current.parent;
|
|
133
|
+
}
|
|
134
|
+
if (!current || current.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
const declaration = current.parent;
|
|
138
|
+
return (declaration?.type === utils_1.AST_NODE_TYPES.VariableDeclaration &&
|
|
139
|
+
declaration.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration);
|
|
140
|
+
}
|
|
98
141
|
exports.noUnnecessaryDestructuringRename = (0, createRule_1.createRule)({
|
|
99
142
|
name: 'no-unnecessary-destructuring-rename',
|
|
100
143
|
meta: {
|
|
@@ -310,10 +353,16 @@ exports.noUnnecessaryDestructuringRename = (0, createRule_1.createRule)({
|
|
|
310
353
|
return fixes;
|
|
311
354
|
}
|
|
312
355
|
function reportAndFixCandidates(candidatesByPattern) {
|
|
313
|
-
for (const patternCandidates of candidatesByPattern
|
|
356
|
+
for (const [pattern, patternCandidates] of candidatesByPattern) {
|
|
357
|
+
// The destructuring edit and the object-literal edit are one atomic
|
|
358
|
+
// batch — applying either alone leaves the file referencing a name that
|
|
359
|
+
// no longer binds — so an exported pattern withholds the whole batch and
|
|
360
|
+
// stays report-only. Every candidate in a group shares one pattern, so
|
|
361
|
+
// the export answer is a property of the group.
|
|
362
|
+
const withholdFix = isExportedDestructuringPattern(pattern);
|
|
314
363
|
patternCandidates.forEach((candidate, index) => {
|
|
315
364
|
const { propertyNode, originalName, aliasIdentifier } = candidate;
|
|
316
|
-
const isFixCarrier = index === 0;
|
|
365
|
+
const isFixCarrier = index === 0 && !withholdFix;
|
|
317
366
|
context.report({
|
|
318
367
|
node: propertyNode,
|
|
319
368
|
messageId: 'unnecessaryDestructuringRename',
|
|
@@ -16,6 +16,24 @@ const NEXTJS_DATA_FUNCTIONS = new Set([
|
|
|
16
16
|
'getStaticProps',
|
|
17
17
|
'getStaticPaths',
|
|
18
18
|
]);
|
|
19
|
+
/**
|
|
20
|
+
* Lexicalized verb-particle compounds whose head happens to be a disallowed
|
|
21
|
+
* prefix. "check in" names an operation exactly — its meaning is not the verb
|
|
22
|
+
* `check` applied to the object `in` — so the generic-prefix heuristic is
|
|
23
|
+
* measuring the wrong lexeme and reports a false positive.
|
|
24
|
+
*
|
|
25
|
+
* Entries are `<verb> <particle>` in lowercase and are matched against the
|
|
26
|
+
* first TWO camelCase segments, so derived names (checkInAndSet, checkOutTeam)
|
|
27
|
+
* inherit the exemption while `check` + object (checkUserPermissions) does not.
|
|
28
|
+
*
|
|
29
|
+
* Criterion for adding an entry: the verb+particle pair must be a *lexicalized*
|
|
30
|
+
* compound — a phrasal verb or domain noun whose meaning is not the sum of its
|
|
31
|
+
* parts (check in, check out) — and its head must be a disallowed prefix.
|
|
32
|
+
* A merely grammatical verb+particle sequence does NOT qualify: `getOutOfSync`,
|
|
33
|
+
* `updateInPlace`, and `processOutQueue` are compositional, so the generic verb
|
|
34
|
+
* still hides what the function does and the rule must keep reporting them.
|
|
35
|
+
*/
|
|
36
|
+
const COMPOUND_LEXEMES = new Set(['check in', 'check out']);
|
|
19
37
|
const SUGGESTED_ALTERNATIVES = {
|
|
20
38
|
get: ['fetch', 'retrieve', 'compute', 'derive'],
|
|
21
39
|
update: ['modify', 'set', 'apply'],
|
|
@@ -50,6 +68,24 @@ function extractFirstWord(name) {
|
|
|
50
68
|
}
|
|
51
69
|
return firstWord;
|
|
52
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* The first two camelCase segments of a name, joined by a space
|
|
73
|
+
* (`checkInAndSet` -> `check in`).
|
|
74
|
+
*
|
|
75
|
+
* Whole-segment equality is load-bearing. `checkInput` segments as
|
|
76
|
+
* `check` + `Input`, so a substring test such as `name.startsWith('checkin')`
|
|
77
|
+
* would exempt it — along with checkInputValidation, CheckInputData and every
|
|
78
|
+
* other `check` + object name that merely begins with a particle's letters.
|
|
79
|
+
*/
|
|
80
|
+
function extractCompoundHead(name) {
|
|
81
|
+
const firstWord = extractFirstWord(name);
|
|
82
|
+
const remainder = name.slice(firstWord.length);
|
|
83
|
+
const secondWord = remainder ? extractFirstWord(remainder) : '';
|
|
84
|
+
return `${firstWord} ${secondWord}`.toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
function isCompoundLexeme(name) {
|
|
87
|
+
return COMPOUND_LEXEMES.has(extractCompoundHead(name));
|
|
88
|
+
}
|
|
53
89
|
exports.semanticFunctionPrefixes = (0, createRule_1.createRule)({
|
|
54
90
|
name: 'semantic-function-prefixes',
|
|
55
91
|
meta: {
|
|
@@ -65,32 +101,33 @@ exports.semanticFunctionPrefixes = (0, createRule_1.createRule)({
|
|
|
65
101
|
},
|
|
66
102
|
defaultOptions: [],
|
|
67
103
|
create(context) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
// Skip if method starts with 'is' (boolean check methods are okay)
|
|
77
|
-
if (methodName.startsWith('is'))
|
|
104
|
+
/**
|
|
105
|
+
* Single detection path for every visited shape. Methods and functions
|
|
106
|
+
* differ only in which node carries the report, so sharing the guards keeps
|
|
107
|
+
* an exemption from applying to one shape and not the other.
|
|
108
|
+
*/
|
|
109
|
+
function reportIfGenericPrefix(reportNode, functionName) {
|
|
110
|
+
// Skip if the name starts with 'is' (boolean check functions are okay)
|
|
111
|
+
if (functionName.startsWith('is'))
|
|
78
112
|
return;
|
|
79
113
|
// Skip Next.js data-fetching functions
|
|
80
|
-
if (NEXTJS_DATA_FUNCTIONS.has(
|
|
114
|
+
if (NEXTJS_DATA_FUNCTIONS.has(functionName))
|
|
115
|
+
return;
|
|
116
|
+
// Skip compound lexemes whose head merely coincides with a banned prefix
|
|
117
|
+
if (isCompoundLexeme(functionName))
|
|
81
118
|
return;
|
|
82
119
|
// Extract first word from PascalCase/camelCase
|
|
83
|
-
const firstWord = extractFirstWord(
|
|
120
|
+
const firstWord = extractFirstWord(functionName);
|
|
84
121
|
// Check for disallowed prefixes
|
|
85
122
|
// Only flag if the disallowed word is used as a prefix (not the entire name)
|
|
86
123
|
for (const prefix of DISALLOWED_PREFIXES) {
|
|
87
124
|
if (firstWord.toLowerCase() === prefix.toLowerCase() &&
|
|
88
|
-
firstWord.length <
|
|
125
|
+
firstWord.length < functionName.length) {
|
|
89
126
|
context.report({
|
|
90
|
-
node:
|
|
127
|
+
node: reportNode,
|
|
91
128
|
messageId: 'avoidGenericPrefix',
|
|
92
129
|
data: {
|
|
93
|
-
functionName
|
|
130
|
+
functionName,
|
|
94
131
|
prefix,
|
|
95
132
|
alternatives: SUGGESTED_ALTERNATIVES[prefix].join(', '),
|
|
96
133
|
},
|
|
@@ -99,6 +136,16 @@ exports.semanticFunctionPrefixes = (0, createRule_1.createRule)({
|
|
|
99
136
|
}
|
|
100
137
|
}
|
|
101
138
|
}
|
|
139
|
+
function checkMethodName(node) {
|
|
140
|
+
// Skip getters and setters
|
|
141
|
+
if (node.kind === 'get' || node.kind === 'set') {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const methodName = node.key.type === utils_1.AST_NODE_TYPES.Identifier ? node.key.name : '';
|
|
145
|
+
if (!methodName)
|
|
146
|
+
return;
|
|
147
|
+
reportIfGenericPrefix(node.key, methodName);
|
|
148
|
+
}
|
|
102
149
|
function checkFunctionName(node) {
|
|
103
150
|
// Skip anonymous functions
|
|
104
151
|
if (!node.id && node.parent?.type !== utils_1.AST_NODE_TYPES.VariableDeclarator) {
|
|
@@ -115,31 +162,7 @@ exports.semanticFunctionPrefixes = (0, createRule_1.createRule)({
|
|
|
115
162
|
}
|
|
116
163
|
if (!functionName)
|
|
117
164
|
return;
|
|
118
|
-
|
|
119
|
-
if (functionName.startsWith('is'))
|
|
120
|
-
return;
|
|
121
|
-
// Skip Next.js data-fetching functions
|
|
122
|
-
if (NEXTJS_DATA_FUNCTIONS.has(functionName))
|
|
123
|
-
return;
|
|
124
|
-
// Extract first word from PascalCase/camelCase
|
|
125
|
-
const firstWord = extractFirstWord(functionName);
|
|
126
|
-
// Check for disallowed prefixes
|
|
127
|
-
// Only flag if the disallowed word is used as a prefix (not the entire name)
|
|
128
|
-
for (const prefix of DISALLOWED_PREFIXES) {
|
|
129
|
-
if (firstWord.toLowerCase() === prefix.toLowerCase() &&
|
|
130
|
-
firstWord.length < functionName.length) {
|
|
131
|
-
context.report({
|
|
132
|
-
node: node.id || node,
|
|
133
|
-
messageId: 'avoidGenericPrefix',
|
|
134
|
-
data: {
|
|
135
|
-
functionName,
|
|
136
|
-
prefix,
|
|
137
|
-
alternatives: SUGGESTED_ALTERNATIVES[prefix].join(', '),
|
|
138
|
-
},
|
|
139
|
-
});
|
|
140
|
-
break;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
165
|
+
reportIfGenericPrefix(node.id || node, functionName);
|
|
143
166
|
}
|
|
144
167
|
return {
|
|
145
168
|
FunctionDeclaration: checkFunctionName,
|
package/lib/utils/renameFixes.js
CHANGED
|
@@ -157,11 +157,12 @@ const buildVariableRenameFixes = ({ fixer, sourceCode, variable, declarationId,
|
|
|
157
157
|
// above. Skipping it also avoids overlapping fix ranges, which ESLint rejects.
|
|
158
158
|
const references = variable.references.filter((reference) => reference.identifier !== declarationId);
|
|
159
159
|
// An exported symbol is a cross-file contract whose importers a single-file
|
|
160
|
-
// fixer cannot reach.
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
160
|
+
// fixer cannot reach. Renaming the declaration breaks every importer, so
|
|
161
|
+
// decline. (The hazard lives entirely in those other files, so it does not
|
|
162
|
+
// depend on whether the declaring file also uses the name: a bare
|
|
163
|
+
// `export const` with no in-file reference is the most exposed shape, not the
|
|
164
|
+
// safest one. `global-const-style` withholds its rename on the same grounds.)
|
|
165
|
+
if (isExportedDeclaration(declarationId)) {
|
|
165
166
|
return null;
|
|
166
167
|
}
|
|
167
168
|
const fixes = [declarationFix];
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,64 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.102",
|
|
4
|
+
"date": "2026-08-04T17:20:02.924Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "ensure-pointer-events-none",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1707
|
|
11
|
+
],
|
|
12
|
+
"summary": "extend the hit-slop carve-out to the inset shorthand (closes #1707)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.101",
|
|
18
|
+
"date": "2026-08-04T16:47:09.253Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "enforce-centralized-mock-firestore",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1703
|
|
25
|
+
],
|
|
26
|
+
"summary": "exempt the centralized module and exported declarations (closes #1703)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "enforce-identifiable-firestore-type",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
1705
|
|
33
|
+
],
|
|
34
|
+
"summary": "stay silent when the alias chain leaves the module (closes #1705)"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "enforce-react-type-naming",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
1701
|
|
41
|
+
],
|
|
42
|
+
"summary": "withhold the rename for every exported declaration (closes #1701)"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "no-unnecessary-destructuring-rename",
|
|
46
|
+
"changeType": "fix",
|
|
47
|
+
"issues": [
|
|
48
|
+
1702
|
|
49
|
+
],
|
|
50
|
+
"summary": "withhold the fix on an exported pattern (closes #1702)"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "semantic-function-prefixes",
|
|
54
|
+
"changeType": "fix",
|
|
55
|
+
"issues": [
|
|
56
|
+
1704
|
|
57
|
+
],
|
|
58
|
+
"summary": "exempt the compound lexemes checkIn and checkOut (closes #1704)"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
2
62
|
{
|
|
3
63
|
"version": "1.20.100",
|
|
4
64
|
"date": "2026-08-04T13:28:51.191Z",
|