@blumintinc/eslint-plugin-blumint 1.20.110 → 1.20.112
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-css-media-queries.d.ts +12 -0
- package/lib/rules/enforce-css-media-queries.js +149 -1
- package/lib/rules/enforce-global-constants.js +5 -1
- package/lib/rules/logical-top-to-bottom-grouping.js +7 -1
- package/lib/rules/no-uuidv4-base62-as-key.js +28 -2
- package/lib/rules/prefer-clone-deep.js +32 -14
- package/lib/rules/vertically-group-related-functions.js +8 -2
- package/lib/utils/shebang.d.ts +19 -0
- package/lib/utils/shebang.js +32 -0
- package/package.json +1 -1
- package/release-manifest.json +60 -0
package/lib/index.js
CHANGED
|
@@ -2,5 +2,17 @@ import { TSESLint } from '@typescript-eslint/utils';
|
|
|
2
2
|
/**
|
|
3
3
|
* This rule enforces the use of CSS media queries instead of JavaScript-based breakpoints
|
|
4
4
|
* in React components for better performance and separation of concerns.
|
|
5
|
+
*
|
|
6
|
+
* Two exemptions exist, both resting on the same principle: the rule reports
|
|
7
|
+
* only where its remedy exists. A query testing capability or preference has no
|
|
8
|
+
* CSS remedy, and neither does a viewport breakpoint whose result never reaches
|
|
9
|
+
* a style.
|
|
10
|
+
*
|
|
11
|
+
* Known limitation of the destination exemption: a value handed to a child
|
|
12
|
+
* component through an ordinary prop is exempt here even if the child applies it
|
|
13
|
+
* to a class, because the walk stops at this file's props. That false negative
|
|
14
|
+
* is the accepted price of an analysis that stays inside one file; the
|
|
15
|
+
* alternative — reporting every value that leaves the component — is the
|
|
16
|
+
* unactionable report this exemption exists to remove.
|
|
5
17
|
*/
|
|
6
18
|
export declare const enforceCssMediaQueries: TSESLint.RuleModule<"enforceCssMediaQueries", [], TSESLint.RuleListener>;
|
|
@@ -55,6 +55,18 @@ const NON_LAYOUT_FEATURES = new Set([
|
|
|
55
55
|
]);
|
|
56
56
|
/** Every `prefers-*` feature is a user preference, never a layout measurement. */
|
|
57
57
|
const PREFERENCE_FEATURE_PREFIX = 'prefers-';
|
|
58
|
+
/**
|
|
59
|
+
* JSX attributes and object properties that carry CSS. A value reaching one of
|
|
60
|
+
* these has the remedy the report prescribes — declare the breakpoint in a
|
|
61
|
+
* `@media` rule and let the class name change — so it keeps reporting.
|
|
62
|
+
*/
|
|
63
|
+
const STYLE_DESTINATIONS = new Set([
|
|
64
|
+
'sx',
|
|
65
|
+
'style',
|
|
66
|
+
'className',
|
|
67
|
+
'classes',
|
|
68
|
+
'css',
|
|
69
|
+
]);
|
|
58
70
|
/**
|
|
59
71
|
* Guards the text a query carries outside its feature groups — media types and
|
|
60
72
|
* combinators such as `screen`, `and`, `not`. A layout name appearing there
|
|
@@ -65,6 +77,12 @@ const LAYOUT_NAME = new RegExp([...LAYOUT_FEATURES].join('|'));
|
|
|
65
77
|
const FEATURE_GROUP = /\(([^()]*)\)/g;
|
|
66
78
|
/** Follows at most this many indirections while resolving a query argument. */
|
|
67
79
|
const MAX_RESOLUTION_DEPTH = 4;
|
|
80
|
+
/**
|
|
81
|
+
* Follows at most this many hops while tracing a value to its destinations. A
|
|
82
|
+
* chain longer than this is unresolved and therefore reported, so the bound only
|
|
83
|
+
* ever costs an exemption.
|
|
84
|
+
*/
|
|
85
|
+
const MAX_DESTINATION_DEPTH = 8;
|
|
68
86
|
/**
|
|
69
87
|
* The feature a parenthesized query group tests, or `null` when the group's
|
|
70
88
|
* shape leaves it ambiguous (range syntax such as `(width >= 600px)`, a nested
|
|
@@ -154,9 +172,133 @@ function resolveBinding(node, scope, depth) {
|
|
|
154
172
|
}
|
|
155
173
|
return resolveQuery(definition.node.init, scope, depth + 1);
|
|
156
174
|
}
|
|
175
|
+
/** The name a property is keyed by, or `null` when the key is computed. */
|
|
176
|
+
function propertyKeyName(property) {
|
|
177
|
+
const { key } = property;
|
|
178
|
+
if (!property.computed && key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
179
|
+
return key.name;
|
|
180
|
+
}
|
|
181
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
|
|
182
|
+
return key.value;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Whether a JSX expression container hands its value to a prop CSS cannot
|
|
188
|
+
* express. A container in children position decides which markup renders, which
|
|
189
|
+
* a class name can do, so it is not exempt; neither is a style attribute nor a
|
|
190
|
+
* namespaced one, whose name this walk does not read.
|
|
191
|
+
*/
|
|
192
|
+
function isNonStyleAttributeValue(container) {
|
|
193
|
+
const attribute = container.parent;
|
|
194
|
+
if (attribute?.type !== utils_1.AST_NODE_TYPES.JSXAttribute) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
return (attribute.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier &&
|
|
198
|
+
!STYLE_DESTINATIONS.has(attribute.name.name));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Whether every destination the value reaches is one CSS cannot express — a
|
|
202
|
+
* `timeout`, an `anchorOrigin`, any prop a stylesheet has no way to select.
|
|
203
|
+
*
|
|
204
|
+
* The walk climbs from the value through the expressions that merely carry it
|
|
205
|
+
* (a conditional, an object it is nested in, a `const` it is bound to) until it
|
|
206
|
+
* reaches somewhere the value is consumed. It answers `false` for a style
|
|
207
|
+
* destination AND for every shape it does not model, because the exemption
|
|
208
|
+
* exists only where the rule's remedy is provably unavailable: a value returned,
|
|
209
|
+
* exported, passed to a call, or spread into props escapes to a destination this
|
|
210
|
+
* walk cannot see, and an unseen destination may well be a stylesheet.
|
|
211
|
+
*/
|
|
212
|
+
function reachesOnlyNonStyleDestinations(node, context, depth) {
|
|
213
|
+
if (depth > MAX_DESTINATION_DEPTH) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
const parent = node.parent;
|
|
217
|
+
if (!parent) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
switch (parent.type) {
|
|
221
|
+
// Expressions that pass the value along: where their own result lands is
|
|
222
|
+
// the question that decides the original value.
|
|
223
|
+
case utils_1.AST_NODE_TYPES.ArrayExpression:
|
|
224
|
+
case utils_1.AST_NODE_TYPES.BinaryExpression:
|
|
225
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
226
|
+
case utils_1.AST_NODE_TYPES.LogicalExpression:
|
|
227
|
+
case utils_1.AST_NODE_TYPES.ObjectExpression:
|
|
228
|
+
case utils_1.AST_NODE_TYPES.SpreadElement:
|
|
229
|
+
case utils_1.AST_NODE_TYPES.TemplateLiteral:
|
|
230
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
231
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
232
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
233
|
+
case utils_1.AST_NODE_TYPES.TSTypeAssertion:
|
|
234
|
+
case utils_1.AST_NODE_TYPES.UnaryExpression:
|
|
235
|
+
return reachesOnlyNonStyleDestinations(parent, context, depth + 1);
|
|
236
|
+
// A `sx`/`style`/`classes` key names a style destination wherever the object
|
|
237
|
+
// itself ends up, so the property is checked before the object is followed.
|
|
238
|
+
case utils_1.AST_NODE_TYPES.Property: {
|
|
239
|
+
const key = propertyKeyName(parent);
|
|
240
|
+
return ((key === null || !STYLE_DESTINATIONS.has(key)) &&
|
|
241
|
+
reachesOnlyNonStyleDestinations(parent, context, depth + 1));
|
|
242
|
+
}
|
|
243
|
+
case utils_1.AST_NODE_TYPES.JSXExpressionContainer:
|
|
244
|
+
return isNonStyleAttributeValue(parent);
|
|
245
|
+
case utils_1.AST_NODE_TYPES.VariableDeclarator:
|
|
246
|
+
return (parent.init === node &&
|
|
247
|
+
bindingReachesOnlyNonStyleDestinations(parent, context, depth));
|
|
248
|
+
default:
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Whether every read of the binding this declarator introduces reaches a
|
|
254
|
+
* non-style destination.
|
|
255
|
+
*
|
|
256
|
+
* A binding with no reads is not exempt, mirroring `testsOnlyNonLayoutFeatures`
|
|
257
|
+
* on the query axis: a query naming no feature proves nothing, and neither does
|
|
258
|
+
* a value going nowhere.
|
|
259
|
+
*/
|
|
260
|
+
function bindingReachesOnlyNonStyleDestinations(declarator, context, depth) {
|
|
261
|
+
if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
// Only a `const` proves the reads below observe the value declared here; a
|
|
265
|
+
// `let` may hold something else by the time a style reads it. An exported
|
|
266
|
+
// binding is read in files this walk cannot open.
|
|
267
|
+
const declaration = declarator.parent;
|
|
268
|
+
if (declaration?.type !== utils_1.AST_NODE_TYPES.VariableDeclaration ||
|
|
269
|
+
declaration.kind !== 'const' ||
|
|
270
|
+
declaration.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
const scope = ASTHelpers_1.ASTHelpers.getScope(context, declarator.id);
|
|
274
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(scope, declarator.id.name);
|
|
275
|
+
if (!variable || variable.defs.length !== 1) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
const reads = variable.references.filter((reference) => reference.identifier !== declarator.id);
|
|
279
|
+
if (reads.length === 0) {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
return reads.every((reference) => reference.isRead() &&
|
|
283
|
+
// `export { isMobile }` hands the value to another file.
|
|
284
|
+
reference.identifier.parent?.type !== utils_1.AST_NODE_TYPES.ExportSpecifier &&
|
|
285
|
+
reachesOnlyNonStyleDestinations(reference.identifier, context, depth + 1));
|
|
286
|
+
}
|
|
157
287
|
/**
|
|
158
288
|
* This rule enforces the use of CSS media queries instead of JavaScript-based breakpoints
|
|
159
289
|
* in React components for better performance and separation of concerns.
|
|
290
|
+
*
|
|
291
|
+
* Two exemptions exist, both resting on the same principle: the rule reports
|
|
292
|
+
* only where its remedy exists. A query testing capability or preference has no
|
|
293
|
+
* CSS remedy, and neither does a viewport breakpoint whose result never reaches
|
|
294
|
+
* a style.
|
|
295
|
+
*
|
|
296
|
+
* Known limitation of the destination exemption: a value handed to a child
|
|
297
|
+
* component through an ordinary prop is exempt here even if the child applies it
|
|
298
|
+
* to a class, because the walk stops at this file's props. That false negative
|
|
299
|
+
* is the accepted price of an analysis that stays inside one file; the
|
|
300
|
+
* alternative — reporting every value that leaves the component — is the
|
|
301
|
+
* unactionable report this exemption exists to remove.
|
|
160
302
|
*/
|
|
161
303
|
exports.enforceCssMediaQueries = (0, createRule_1.createRule)({
|
|
162
304
|
name: 'enforce-css-media-queries',
|
|
@@ -187,7 +329,8 @@ exports.enforceCssMediaQueries = (0, createRule_1.createRule)({
|
|
|
187
329
|
data: { source },
|
|
188
330
|
});
|
|
189
331
|
const localNamesOf = (node) => node.specifiers.map((specifier) => specifier.local.name);
|
|
190
|
-
|
|
332
|
+
/** Whether the query the call carries is provably free of layout. */
|
|
333
|
+
const testsExemptQuery = (node) => {
|
|
191
334
|
const [argument] = node.arguments;
|
|
192
335
|
if (!argument) {
|
|
193
336
|
return false;
|
|
@@ -195,6 +338,11 @@ exports.enforceCssMediaQueries = (0, createRule_1.createRule)({
|
|
|
195
338
|
const query = resolveQuery(argument, ASTHelpers_1.ASTHelpers.getScope(context, node));
|
|
196
339
|
return query !== null && testsOnlyNonLayoutFeatures(query);
|
|
197
340
|
};
|
|
341
|
+
// A zero-argument hook such as `useMobile` carries no query, so the
|
|
342
|
+
// query axis can never clear it; the destination axis is the only one that
|
|
343
|
+
// can, and it applies to every media hook alike.
|
|
344
|
+
const isExemptCall = (node) => testsExemptQuery(node) ||
|
|
345
|
+
reachesOnlyNonStyleDestinations(node, context, 0);
|
|
198
346
|
return {
|
|
199
347
|
// Only react-responsive is handled at the declaration level to avoid duplicates.
|
|
200
348
|
ImportDeclaration(node) {
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.enforceGlobalConstants = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const shebang_1 = require("../utils/shebang");
|
|
6
7
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
8
|
exports.enforceGlobalConstants = (0, createRule_1.createRule)({
|
|
8
9
|
name: 'enforce-global-constants',
|
|
@@ -269,7 +270,10 @@ exports.enforceGlobalConstants = (0, createRule_1.createRule)({
|
|
|
269
270
|
}
|
|
270
271
|
else {
|
|
271
272
|
const body = program.body;
|
|
272
|
-
|
|
273
|
+
// A shebang has to stay at character 0 or the file stops parsing
|
|
274
|
+
// (TS18026), so it bounds the insertion the same way the
|
|
275
|
+
// directive prologue below does.
|
|
276
|
+
let insertPos = (0, shebang_1.afterShebang)(text);
|
|
273
277
|
let afterDirectiveIdx = -1;
|
|
274
278
|
for (let i = 0; i < body.length; i++) {
|
|
275
279
|
const stmt = body[i];
|
|
@@ -4,6 +4,7 @@ exports.logicalTopToBottomGrouping = void 0;
|
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
6
6
|
const createRule_1 = require("../utils/createRule");
|
|
7
|
+
const shebang_1 = require("../utils/shebang");
|
|
7
8
|
const TYPE_EXPRESSION_WRAPPERS = new Set([
|
|
8
9
|
utils_1.AST_NODE_TYPES.TSAsExpression,
|
|
9
10
|
utils_1.AST_NODE_TYPES.TSTypeAssertion,
|
|
@@ -730,7 +731,12 @@ function findEarliestSafeIndex(body, startIndex, dependencies, { allowHooks, sto
|
|
|
730
731
|
* comments directly above it reads as its preamble.
|
|
731
732
|
*/
|
|
732
733
|
function getLeadingComments(statement, sourceCode) {
|
|
733
|
-
|
|
734
|
+
// A shebang belongs to the file, not to the statement below it. Left in the
|
|
735
|
+
// preamble, relocating the first statement carries `#!` off character 0 and
|
|
736
|
+
// the output stops parsing.
|
|
737
|
+
const comments = sourceCode
|
|
738
|
+
.getCommentsBefore(statement)
|
|
739
|
+
.filter((comment) => !(0, shebang_1.isShebangComment)(sourceCode, comment));
|
|
734
740
|
const ownLine = comments.findIndex((comment) => {
|
|
735
741
|
const previous = sourceCode.getTokenBefore(comment, {
|
|
736
742
|
includeComments: true,
|
|
@@ -4,6 +4,33 @@ exports.noUuidv4Base62AsKey = void 0;
|
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
/**
|
|
8
|
+
* The helper's own module, identified by its file name rather than by a fixed
|
|
9
|
+
* list of specifiers: it is reachable as a package subpath, a tsconfig alias,
|
|
10
|
+
* or a relative path, and every one of those spellings imports the same
|
|
11
|
+
* function.
|
|
12
|
+
*/
|
|
13
|
+
const UUIDV4_BASE62_MODULE = 'uuidv4Base62';
|
|
14
|
+
/** The barrel that re-exports the helper alongside unrelated utilities. */
|
|
15
|
+
const UUIDV4_BASE62_BARREL = '@blumint/utils';
|
|
16
|
+
const MODULE_EXTENSION = /\.(?:tsx?|jsx?)$/;
|
|
17
|
+
/**
|
|
18
|
+
* Matches the final path segment exactly rather than testing the whole
|
|
19
|
+
* specifier with a suffix check. A suffix test has no module resolution behind
|
|
20
|
+
* it, so it conflates monorepo tiers (`functions/src/util/uuidv4Base62` versus
|
|
21
|
+
* `src/util/uuidv4Base62`) and, worse, accepts sibling modules whose names
|
|
22
|
+
* merely end with the helper's name. An exact basename comparison keeps
|
|
23
|
+
* `../../util/uuidv4Base62Stable` — a different helper — out.
|
|
24
|
+
*/
|
|
25
|
+
function isUuidv4Base62Module(source) {
|
|
26
|
+
if (typeof source !== 'string')
|
|
27
|
+
return false;
|
|
28
|
+
if (source === UUIDV4_BASE62_BARREL)
|
|
29
|
+
return true;
|
|
30
|
+
const segments = source.split('/');
|
|
31
|
+
const basename = segments[segments.length - 1].replace(MODULE_EXTENSION, '');
|
|
32
|
+
return basename === UUIDV4_BASE62_MODULE;
|
|
33
|
+
}
|
|
7
34
|
exports.noUuidv4Base62AsKey = (0, createRule_1.createRule)({
|
|
8
35
|
name: 'no-uuidv4-base62-as-key',
|
|
9
36
|
meta: {
|
|
@@ -236,8 +263,7 @@ exports.noUuidv4Base62AsKey = (0, createRule_1.createRule)({
|
|
|
236
263
|
},
|
|
237
264
|
// Track imports of uuidv4Base62
|
|
238
265
|
ImportDeclaration(node) {
|
|
239
|
-
if (node.source.value
|
|
240
|
-
node.source.value === '@blumint/utils') {
|
|
266
|
+
if (isUuidv4Base62Module(node.source.value)) {
|
|
241
267
|
for (const specifier of node.specifiers) {
|
|
242
268
|
if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier) {
|
|
243
269
|
if (specifier.imported.name === 'uuidv4Base62' ||
|
|
@@ -96,10 +96,14 @@ exports.preferCloneDeep = (0, createRule_1.createRule)({
|
|
|
96
96
|
*
|
|
97
97
|
* Merging unrelated sources copies nothing twice and is safe, which is why
|
|
98
98
|
* shapes such as `{ ...a, nested: { ...b } }`, MUI `sx` style maps and
|
|
99
|
-
* static config maps must not be flagged (#1371).
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
99
|
+
* static config maps must not be flagged (#1371). That verdict depends on
|
|
100
|
+
* ALL of a literal's sources, not on any one of them: `{ ...props, sx: {
|
|
101
|
+
* ...DEFAULT_SX, ...props.sx } }` builds `sx` fresh out of two sources and
|
|
102
|
+
* aliases neither, so it is a merge even though one source happens to be a
|
|
103
|
+
* sub-path of the base (#1745). A spread of the exact same path
|
|
104
|
+
* (`{ ...a, x: { ...a } }`) is deliberately excluded as well: it is a
|
|
105
|
+
* redundant copy rather than a partial one, and this repo prefers false
|
|
106
|
+
* negatives over false positives.
|
|
103
107
|
*/
|
|
104
108
|
function isPartialDeepCopy(node) {
|
|
105
109
|
const cached = partialDeepCopyCache.get(node);
|
|
@@ -109,15 +113,18 @@ exports.preferCloneDeep = (0, createRule_1.createRule)({
|
|
|
109
113
|
let hasFunction = false;
|
|
110
114
|
let hasSymbol = false;
|
|
111
115
|
const basePaths = new Set();
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
// Spread paths kept grouped by the literal that writes them, because a
|
|
117
|
+
// literal is classified by its sources as a set: flattening them loses
|
|
118
|
+
// the co-spread relation the merge exemption is stated over.
|
|
119
|
+
const nestedGroups = [];
|
|
120
|
+
function visit(current, depth = 0, group = []) {
|
|
114
121
|
if (current.type === utils_1.AST_NODE_TYPES.SpreadElement) {
|
|
115
122
|
const path = accessPathOf(current.argument);
|
|
116
123
|
if (depth === 0) {
|
|
117
124
|
basePaths.add(path);
|
|
118
125
|
}
|
|
119
126
|
else {
|
|
120
|
-
|
|
127
|
+
group.push(path);
|
|
121
128
|
}
|
|
122
129
|
}
|
|
123
130
|
else if (current.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
@@ -145,24 +152,35 @@ exports.preferCloneDeep = (0, createRule_1.createRule)({
|
|
|
145
152
|
// `...spread` at depth 0, where it names a base rather than a
|
|
146
153
|
// hand-copied sub-path.
|
|
147
154
|
if (current.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
148
|
-
|
|
155
|
+
// Every literal owns the spreads written directly inside it. The
|
|
156
|
+
// root's spreads name bases instead, so only descendants contribute
|
|
157
|
+
// a group.
|
|
158
|
+
const ownGroup = [];
|
|
159
|
+
if (current !== node) {
|
|
160
|
+
nestedGroups.push(ownGroup);
|
|
161
|
+
}
|
|
162
|
+
current.properties.forEach((prop) => visit(prop, depth, ownGroup));
|
|
149
163
|
}
|
|
150
164
|
else if (current.type === utils_1.AST_NODE_TYPES.Property) {
|
|
151
|
-
visit(current.value, depth + 1);
|
|
165
|
+
visit(current.value, depth + 1, group);
|
|
152
166
|
}
|
|
153
167
|
else if (current.type === utils_1.AST_NODE_TYPES.SpreadElement) {
|
|
154
|
-
visit(current.argument, depth);
|
|
168
|
+
visit(current.argument, depth, group);
|
|
155
169
|
}
|
|
156
170
|
}
|
|
157
171
|
visit(node);
|
|
172
|
+
// The separators guard against a sibling whose name merely starts with a
|
|
173
|
+
// base's name (`abc.x` is not a sub-path of `ab`).
|
|
174
|
+
const isBaseSubPath = (nested) => [...basePaths].some((base) => nested.startsWith(`${base}.`) || nested.startsWith(`${base}[`));
|
|
158
175
|
// cloneDeep cannot faithfully reproduce functions or symbol keys, so
|
|
159
176
|
// their presence suppresses the report regardless of the copy shape.
|
|
160
177
|
const result = !hasFunction &&
|
|
161
178
|
!hasSymbol &&
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
179
|
+
// A nested literal is a hand-written partial copy only when EVERY
|
|
180
|
+
// source it spreads is a sub-path of a spread base. One foreign source
|
|
181
|
+
// makes the literal a fresh merge of both, which aliases nothing and is
|
|
182
|
+
// not expressible as cloneDeep overrides.
|
|
183
|
+
nestedGroups.some((group) => group.length > 0 && group.every(isBaseSubPath));
|
|
166
184
|
partialDeepCopyCache.set(node, result);
|
|
167
185
|
return result;
|
|
168
186
|
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.verticallyGroupRelatedFunctions = void 0;
|
|
4
4
|
const createRule_1 = require("../utils/createRule");
|
|
5
5
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
6
|
+
const shebang_1 = require("../utils/shebang");
|
|
6
7
|
const DEFAULT_OPTIONS = {
|
|
7
8
|
exportPlacement: 'ignore',
|
|
8
9
|
dependencyDirection: 'callers-first',
|
|
@@ -329,10 +330,15 @@ function getStatementRangeWithComments(statement, sourceCode, consumedComments,
|
|
|
329
330
|
// below would drag an interleaved statement's own end-of-line comment along
|
|
330
331
|
// when the following function is relocated. Only own-line comments count as
|
|
331
332
|
// leading comments.
|
|
332
|
-
const leadingCommentsOf = (target) => filterComments(sourceCode.getCommentsBefore(target) || [])
|
|
333
|
+
const leadingCommentsOf = (target) => filterComments(sourceCode.getCommentsBefore(target) || [])
|
|
334
|
+
.filter((comment) => {
|
|
333
335
|
const tokenBefore = sourceCode.getTokenBefore(comment);
|
|
334
336
|
return (!tokenBefore || tokenBefore.loc.end.line !== comment.loc.start.line);
|
|
335
|
-
})
|
|
337
|
+
})
|
|
338
|
+
// A shebang belongs to the file, not to the statement below it. Left in
|
|
339
|
+
// the span, relocating the first statement carries `#!` off character 0
|
|
340
|
+
// and the output stops parsing.
|
|
341
|
+
.filter((comment) => !(0, shebang_1.isShebangComment)(sourceCode, comment));
|
|
336
342
|
const commentsBefore = leadingCommentsOf(statement);
|
|
337
343
|
const nextLeadingComments = nextStatement
|
|
338
344
|
? new Set(leadingCommentsOf(nextStatement))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
/**
|
|
3
|
+
* A shebang is only a shebang at character 0. One byte in front of it and the
|
|
4
|
+
* file stops parsing outright (`TS18026: '#!' can only be used at the start of
|
|
5
|
+
* a file`) and stops being executable.
|
|
6
|
+
*
|
|
7
|
+
* ESLint hands it to rules as an ordinary leading comment of the first
|
|
8
|
+
* statement, which is what makes it easy to lose: a fixer that relocates a
|
|
9
|
+
* statement together with its leading comments carries the shebang into the
|
|
10
|
+
* middle of the file, and one that splices at offset 0 pushes it off line 1.
|
|
11
|
+
* `importInsertion` already encodes this for the rules that add an import;
|
|
12
|
+
* these are the same rule for the ones that reorder or hoist.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isShebangComment(sourceCode: Pick<TSESLint.SourceCode, 'text'>, comment: TSESTree.Comment): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* The first offset at which text may be spliced without displacing a shebang.
|
|
17
|
+
* Zero for the overwhelmingly common file that has none.
|
|
18
|
+
*/
|
|
19
|
+
export declare function afterShebang(text: string): number;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.afterShebang = exports.isShebangComment = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* A shebang is only a shebang at character 0. One byte in front of it and the
|
|
6
|
+
* file stops parsing outright (`TS18026: '#!' can only be used at the start of
|
|
7
|
+
* a file`) and stops being executable.
|
|
8
|
+
*
|
|
9
|
+
* ESLint hands it to rules as an ordinary leading comment of the first
|
|
10
|
+
* statement, which is what makes it easy to lose: a fixer that relocates a
|
|
11
|
+
* statement together with its leading comments carries the shebang into the
|
|
12
|
+
* middle of the file, and one that splices at offset 0 pushes it off line 1.
|
|
13
|
+
* `importInsertion` already encodes this for the rules that add an import;
|
|
14
|
+
* these are the same rule for the ones that reorder or hoist.
|
|
15
|
+
*/
|
|
16
|
+
function isShebangComment(sourceCode, comment) {
|
|
17
|
+
return comment.range[0] === 0 && sourceCode.text.startsWith('#!');
|
|
18
|
+
}
|
|
19
|
+
exports.isShebangComment = isShebangComment;
|
|
20
|
+
/**
|
|
21
|
+
* The first offset at which text may be spliced without displacing a shebang.
|
|
22
|
+
* Zero for the overwhelmingly common file that has none.
|
|
23
|
+
*/
|
|
24
|
+
function afterShebang(text) {
|
|
25
|
+
if (!text.startsWith('#!')) {
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
const lineEnd = text.indexOf('\n');
|
|
29
|
+
return lineEnd === -1 ? text.length : lineEnd + 1;
|
|
30
|
+
}
|
|
31
|
+
exports.afterShebang = afterShebang;
|
|
32
|
+
//# sourceMappingURL=shebang.js.map
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,64 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.112",
|
|
4
|
+
"date": "2026-08-05T16:17:08.283Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-css-media-queries",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1746
|
|
11
|
+
],
|
|
12
|
+
"summary": "exempt breakpoints that reach no style (closes #1746)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "no-uuidv4-base62-as-key",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
1744
|
|
19
|
+
],
|
|
20
|
+
"summary": "recognize the helper by module basename (closes #1744)"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "prefer-clone-deep",
|
|
24
|
+
"changeType": "fix",
|
|
25
|
+
"issues": [
|
|
26
|
+
1745
|
|
27
|
+
],
|
|
28
|
+
"summary": "classify a nested literal by all its sources (closes #1745)"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "1.20.111",
|
|
34
|
+
"date": "2026-08-05T13:37:20.101Z",
|
|
35
|
+
"rules": [
|
|
36
|
+
{
|
|
37
|
+
"name": "enforce-global-constants",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
1739
|
|
41
|
+
],
|
|
42
|
+
"summary": "hoist below a shebang, not above it (closes #1739)"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "logical-top-to-bottom-grouping",
|
|
46
|
+
"changeType": "fix",
|
|
47
|
+
"issues": [
|
|
48
|
+
1738
|
|
49
|
+
],
|
|
50
|
+
"summary": "keep a shebang at character 0 when reordering (closes #1738)"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "vertically-group-related-functions",
|
|
54
|
+
"changeType": "fix",
|
|
55
|
+
"issues": [
|
|
56
|
+
1737
|
|
57
|
+
],
|
|
58
|
+
"summary": "keep a shebang at character 0 when reordering (closes #1737)"
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
2
62
|
{
|
|
3
63
|
"version": "1.20.110",
|
|
4
64
|
"date": "2026-08-05T11:59:59.800Z",
|