@blumintinc/eslint-plugin-blumint 1.20.44 → 1.20.46
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
CHANGED
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.enforceMuiRoundedIcons = void 0;
|
|
4
4
|
const createRule_1 = require("../utils/createRule");
|
|
5
5
|
const utils_1 = require("@typescript-eslint/utils");
|
|
6
|
+
const MUI_ICONS_BARREL = '@mui/icons-material';
|
|
7
|
+
const MUI_ICONS_DEEP_PREFIX = `${MUI_ICONS_BARREL}/`;
|
|
6
8
|
/**
|
|
7
9
|
* Non-Rounded MUI variant suffixes. An icon imported as one of these variants
|
|
8
10
|
* (e.g. `AddReactionOutlined`) maps to the Rounded variant of its BASE name
|
|
@@ -31,11 +33,39 @@ const ICONS_WITHOUT_ROUNDED = new Set([
|
|
|
31
33
|
'X',
|
|
32
34
|
'YouTube',
|
|
33
35
|
]);
|
|
36
|
+
/**
|
|
37
|
+
* Names the @mui/icons-material barrel exports that are not icons, so a
|
|
38
|
+
* `*Rounded` sibling never exists for them. `SvgIconComponent` is the icon
|
|
39
|
+
* component *type*, reached through the same barrel as the icons themselves.
|
|
40
|
+
*/
|
|
41
|
+
const NON_ICON_EXPORTS = new Set(['SvgIconComponent']);
|
|
34
42
|
/** Strips a trailing non-Rounded variant suffix to recover the base icon name. */
|
|
35
43
|
const toBaseIconName = (iconName) => {
|
|
36
44
|
const suffix = VARIANT_SUFFIXES.find((variant) => iconName.endsWith(variant));
|
|
37
45
|
return suffix ? iconName.slice(0, -suffix.length) : iconName;
|
|
38
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Single source of truth for both import forms: maps the name an icon is
|
|
49
|
+
* imported under to the Rounded name that should replace it, or null when the
|
|
50
|
+
* name needs no change — it is already Rounded, it is not an icon, or MUI ships
|
|
51
|
+
* that icon without a Rounded variant.
|
|
52
|
+
*/
|
|
53
|
+
const toRoundedIconName = (iconName) => {
|
|
54
|
+
if (!iconName ||
|
|
55
|
+
iconName.endsWith('Rounded') ||
|
|
56
|
+
NON_ICON_EXPORTS.has(iconName)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// Map a non-Rounded variant to its base icon so the suggestion/fix targets a
|
|
60
|
+
// name that actually exists.
|
|
61
|
+
const baseIconName = toBaseIconName(iconName);
|
|
62
|
+
// Brand icons have no Rounded variant — demanding one is a false positive and
|
|
63
|
+
// the fix would point at a name that does not exist.
|
|
64
|
+
if (ICONS_WITHOUT_ROUNDED.has(baseIconName)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return `${baseIconName}Rounded`;
|
|
68
|
+
};
|
|
39
69
|
exports.enforceMuiRoundedIcons = (0, createRule_1.createRule)({
|
|
40
70
|
name: 'enforce-mui-rounded-icons',
|
|
41
71
|
meta: {
|
|
@@ -52,41 +82,68 @@ exports.enforceMuiRoundedIcons = (0, createRule_1.createRule)({
|
|
|
52
82
|
},
|
|
53
83
|
defaultOptions: [],
|
|
54
84
|
create(context) {
|
|
85
|
+
/** `import LogoutIcon from '@mui/icons-material/Logout'` — the icon is named by the module path. */
|
|
86
|
+
const checkDeepImport = (node, iconPath) => {
|
|
87
|
+
const iconName = iconPath.split('/').pop();
|
|
88
|
+
const roundedVariant = iconName ? toRoundedIconName(iconName) : null;
|
|
89
|
+
if (!roundedVariant) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
context.report({
|
|
93
|
+
node,
|
|
94
|
+
messageId: 'enforceRoundedVariant',
|
|
95
|
+
// The local binding is untouched: only the module the icon comes from
|
|
96
|
+
// changes.
|
|
97
|
+
fix: (fixer) => fixer.replaceText(node.source, `'${MUI_ICONS_DEEP_PREFIX}${roundedVariant}'`),
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
/** `import { Logout } from '@mui/icons-material'` — the icon is named by each specifier. */
|
|
101
|
+
const checkBarrelImport = (node) => {
|
|
102
|
+
// A type-only import names a type, and the barrel's type exports are not
|
|
103
|
+
// icons; nothing it introduces can be rendered.
|
|
104
|
+
if (node.importKind === 'type') {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
for (const specifier of node.specifiers) {
|
|
108
|
+
// Default and namespace imports name no individual icon.
|
|
109
|
+
if (specifier.type !== utils_1.AST_NODE_TYPES.ImportSpecifier ||
|
|
110
|
+
specifier.importKind === 'type') {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const importedName = specifier.imported.name;
|
|
114
|
+
const roundedVariant = toRoundedIconName(importedName);
|
|
115
|
+
if (!roundedVariant) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// An alias keeps the local binding independent of the imported name, so
|
|
119
|
+
// swapping the imported name is a self-contained edit. Without one, a
|
|
120
|
+
// fix would rename the binding and would have to rewrite every
|
|
121
|
+
// reference to it (shorthand properties, export specifiers, JSX) — so
|
|
122
|
+
// that form is reported without a fix rather than risking a corrupting
|
|
123
|
+
// edit.
|
|
124
|
+
const isAliased = specifier.local.name !== importedName;
|
|
125
|
+
context.report({
|
|
126
|
+
node: specifier,
|
|
127
|
+
messageId: 'enforceRoundedVariant',
|
|
128
|
+
fix: isAliased
|
|
129
|
+
? (fixer) => fixer.replaceText(specifier.imported, roundedVariant)
|
|
130
|
+
: null,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
};
|
|
55
134
|
return {
|
|
56
135
|
ImportDeclaration(node) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// Skip if the icon name is not a string (shouldn't happen)
|
|
69
|
-
if (!iconName) {
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
// Map a non-Rounded variant import to its base icon so the Rounded
|
|
73
|
-
// suggestion/fix targets a name that actually exists.
|
|
74
|
-
const baseIconName = toBaseIconName(iconName);
|
|
75
|
-
// Brand icons have no Rounded variant — demanding one is a false
|
|
76
|
-
// positive and the fix would point at a non-existent module.
|
|
77
|
-
if (ICONS_WITHOUT_ROUNDED.has(baseIconName)) {
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
// Create the rounded variant name
|
|
81
|
-
const roundedVariant = `${baseIconName}Rounded`;
|
|
82
|
-
context.report({
|
|
83
|
-
node,
|
|
84
|
-
messageId: 'enforceRoundedVariant',
|
|
85
|
-
fix: (fixer) => {
|
|
86
|
-
// Replace the import path with the rounded variant
|
|
87
|
-
return fixer.replaceText(node.source, `'@mui/icons-material/${roundedVariant}'`);
|
|
88
|
-
},
|
|
89
|
-
});
|
|
136
|
+
if (node.source.type !== utils_1.AST_NODE_TYPES.Literal ||
|
|
137
|
+
typeof node.source.value !== 'string') {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const source = node.source.value;
|
|
141
|
+
if (source.startsWith(MUI_ICONS_DEEP_PREFIX)) {
|
|
142
|
+
checkDeepImport(node, source);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (source === MUI_ICONS_BARREL) {
|
|
146
|
+
checkBarrelImport(node);
|
|
90
147
|
}
|
|
91
148
|
},
|
|
92
149
|
};
|
|
@@ -115,14 +115,23 @@ exports.enforceObjectLiteralAsConst = (0, createRule_1.createRule)({
|
|
|
115
115
|
node,
|
|
116
116
|
messageId: 'enforceAsConst',
|
|
117
117
|
fix(fixer) {
|
|
118
|
-
|
|
119
|
-
//
|
|
118
|
+
// A literal already carrying an explicit assertion states a contract
|
|
119
|
+
// the fix cannot preserve: `as const` produces a readonly,
|
|
120
|
+
// literal-typed shape that is structurally different from the
|
|
121
|
+
// asserted type, so rewriting `as SomeType` into `as const` silently
|
|
122
|
+
// changes what the function returns and can break the signature the
|
|
123
|
+
// assertion was written to satisfy. The diagnostic still holds — the
|
|
124
|
+
// author may well want `as const` — but choosing between the two
|
|
125
|
+
// types needs a human, so decline to fix (#1503).
|
|
126
|
+
//
|
|
127
|
+
// Any `TSAsExpression` reaching here is a non-`const` assertion; the
|
|
128
|
+
// `as const` case returns before the report. The angle-bracket form
|
|
129
|
+
// (`<SomeType>{...}`, a `TSTypeAssertion`) needs no branch here
|
|
130
|
+
// because it is never detected above — tsc rejects testing for it.
|
|
120
131
|
if (argument.type === 'TSAsExpression') {
|
|
121
|
-
|
|
122
|
-
const expressionText = sourceCode.getText(argument.expression);
|
|
123
|
-
return fixer.replaceText(argument, `${expressionText} as const`);
|
|
132
|
+
return null;
|
|
124
133
|
}
|
|
125
|
-
return fixer.replaceText(argument, `${
|
|
134
|
+
return fixer.replaceText(argument, `${sourceCode.getText(argument)} as const`);
|
|
126
135
|
},
|
|
127
136
|
});
|
|
128
137
|
},
|
|
@@ -61,7 +61,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
61
61
|
guardFunctions: {
|
|
62
62
|
type: 'array',
|
|
63
63
|
items: { type: 'string' },
|
|
64
|
-
description: '
|
|
64
|
+
description: 'Type guard function names; the first is the one suggestions call and import',
|
|
65
65
|
},
|
|
66
66
|
excludeFiles: {
|
|
67
67
|
type: 'array',
|
|
@@ -70,23 +70,32 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
70
70
|
},
|
|
71
71
|
guardImportSource: {
|
|
72
72
|
type: 'string',
|
|
73
|
-
description: 'Module specifier the suggestion imports
|
|
73
|
+
description: 'Module specifier the suggestion imports the guard from',
|
|
74
74
|
},
|
|
75
75
|
},
|
|
76
76
|
additionalProperties: false,
|
|
77
77
|
},
|
|
78
78
|
],
|
|
79
79
|
messages: {
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// `guard` is the configured canonical guard name, so a consumer who
|
|
81
|
+
// renames it is not told to call a function their config says does not
|
|
82
|
+
// exist. Every report and suggestion supplies it.
|
|
83
|
+
noFalsyCheck: "Do not use boolean coercion on FirestoreSnapshotState<T>. All string states ('idle', 'loading', 'not-found') are truthy, so '{{expression}}' does not behave as intended. Use {{guard}}(state) to narrow to T, or compare explicitly (e.g., state === 'loading').",
|
|
84
|
+
noRawTypeof: "Do not use '{{expression}}' to narrow FirestoreSnapshotState<T> to data. Use {{guard}}(state) instead to maintain the abstraction boundary.",
|
|
82
85
|
},
|
|
83
86
|
},
|
|
84
87
|
defaultOptions: [{}],
|
|
85
88
|
create(context, [options]) {
|
|
86
89
|
const snapshotHooks = new Set(options?.snapshotHooks ?? DEFAULT_SNAPSHOT_HOOKS);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
/**
|
|
91
|
+
* The name every suggestion calls, resolves in scope, and imports.
|
|
92
|
+
* `guardFunctions` may list several recognized guards; the first usable one
|
|
93
|
+
* is canonical. A list that names nothing callable — empty, or holding only
|
|
94
|
+
* blanks — falls back to the default rather than leaving the suggestion to
|
|
95
|
+
* emit `undefined(state)` or `(state)`.
|
|
96
|
+
*/
|
|
97
|
+
const guardName = options?.guardFunctions?.find((name) => name.trim().length > 0) ??
|
|
98
|
+
DEFAULT_GUARD_FUNCTIONS[0];
|
|
90
99
|
const excludeFiles = options?.excludeFiles ?? [
|
|
91
100
|
'src/types/FirestoreSnapshotState.ts',
|
|
92
101
|
];
|
|
@@ -163,7 +172,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
163
172
|
* import, or would collide with an unrelated binding of the same name.
|
|
164
173
|
*/
|
|
165
174
|
function resolveGuardBinding(scope) {
|
|
166
|
-
const variable = utils_1.ASTUtils.findVariable(scope,
|
|
175
|
+
const variable = utils_1.ASTUtils.findVariable(scope, guardName);
|
|
167
176
|
if (!variable) {
|
|
168
177
|
return 'missing';
|
|
169
178
|
}
|
|
@@ -214,14 +223,14 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
214
223
|
if (reusable) {
|
|
215
224
|
const namedSpecifiers = reusable.specifiers.filter(isValueImportSpecifier);
|
|
216
225
|
const lastSpecifier = namedSpecifiers[namedSpecifiers.length - 1];
|
|
217
|
-
return fixer.insertTextAfter(lastSpecifier, `, ${
|
|
226
|
+
return fixer.insertTextAfter(lastSpecifier, `, ${guardName}`);
|
|
218
227
|
}
|
|
219
228
|
// A namespace or type-only import of the module cannot take a named value
|
|
220
229
|
// specifier, but its path is proof of how this file reaches the module.
|
|
221
230
|
const source = guardDeclarations.length
|
|
222
231
|
? String(guardDeclarations[0].source.value)
|
|
223
232
|
: guardImportSource;
|
|
224
|
-
const importText = `import { ${
|
|
233
|
+
const importText = `import { ${guardName} } from '${source}';\n`;
|
|
225
234
|
const [firstImport] = declarations;
|
|
226
235
|
if (firstImport) {
|
|
227
236
|
return fixer.insertTextBefore(firstImport, importText);
|
|
@@ -233,7 +242,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
233
242
|
}
|
|
234
243
|
/**
|
|
235
244
|
* Builds the single suggestion shared by every report: swap the flagged
|
|
236
|
-
* expression for its guard-based equivalent and bring
|
|
245
|
+
* expression for its guard-based equivalent and bring the guard into
|
|
237
246
|
* scope. Declines (no suggestion) when the name is already taken by
|
|
238
247
|
* something that is not the guard.
|
|
239
248
|
*/
|
|
@@ -241,7 +250,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
241
250
|
return [
|
|
242
251
|
{
|
|
243
252
|
messageId,
|
|
244
|
-
data: { expression: replacement },
|
|
253
|
+
data: { expression: replacement, guard: guardName },
|
|
245
254
|
fix(fixer) {
|
|
246
255
|
const binding = resolveGuardBinding(scope);
|
|
247
256
|
if (binding === 'conflict') {
|
|
@@ -306,8 +315,8 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
306
315
|
context.report({
|
|
307
316
|
node,
|
|
308
317
|
messageId: 'noRawTypeof',
|
|
309
|
-
data: { expression: getText(node) },
|
|
310
|
-
suggest: guardSuggestion('noRawTypeof', node, `${
|
|
318
|
+
data: { expression: getText(node), guard: guardName },
|
|
319
|
+
suggest: guardSuggestion('noRawTypeof', node, `${guardName}(${operand.name})`, context.getScope()),
|
|
311
320
|
});
|
|
312
321
|
}
|
|
313
322
|
// typeof state === 'string' — allowed (narrows to non-data states)
|
|
@@ -327,7 +336,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
327
336
|
context.report({
|
|
328
337
|
node,
|
|
329
338
|
messageId: 'noFalsyCheck',
|
|
330
|
-
data: { expression },
|
|
339
|
+
data: { expression, guard: guardName },
|
|
331
340
|
suggest: guardSuggestion('noFalsyCheck', fixNode, replacement, context.getScope()),
|
|
332
341
|
});
|
|
333
342
|
}
|
|
@@ -372,7 +381,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
372
381
|
// stay negated or the suggestion would reverse the control flow.
|
|
373
382
|
if (argument.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
374
383
|
isSnapshotVar(argument)) {
|
|
375
|
-
reportFalsyCheck(node, `!${argument.name}`, `!${
|
|
384
|
+
reportFalsyCheck(node, `!${argument.name}`, `!${guardName}(${argument.name})`);
|
|
376
385
|
}
|
|
377
386
|
// !!state — the argument is another `!` whose argument is the snapshot var.
|
|
378
387
|
// The double negation is a truthiness coercion, so the guard is positive.
|
|
@@ -381,7 +390,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
381
390
|
argument.argument.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
382
391
|
isSnapshotVar(argument.argument)) {
|
|
383
392
|
const varName = argument.argument.name;
|
|
384
|
-
reportFalsyCheck(node, `!!${varName}`, `${
|
|
393
|
+
reportFalsyCheck(node, `!!${varName}`, `${guardName}(${varName})`);
|
|
385
394
|
}
|
|
386
395
|
},
|
|
387
396
|
// IfStatement: if (state) { ... } or if (!state) { ... }
|
|
@@ -389,14 +398,14 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
389
398
|
IfStatement(node) {
|
|
390
399
|
const test = node.test;
|
|
391
400
|
if (test.type === utils_1.AST_NODE_TYPES.Identifier && isSnapshotVar(test)) {
|
|
392
|
-
reportFalsyCheck(test, test.name, `${
|
|
401
|
+
reportFalsyCheck(test, test.name, `${guardName}(${test.name})`);
|
|
393
402
|
}
|
|
394
403
|
},
|
|
395
404
|
// ConditionalExpression: state ? a : b
|
|
396
405
|
ConditionalExpression(node) {
|
|
397
406
|
const test = node.test;
|
|
398
407
|
if (test.type === utils_1.AST_NODE_TYPES.Identifier && isSnapshotVar(test)) {
|
|
399
|
-
reportFalsyCheck(test, test.name, `${
|
|
408
|
+
reportFalsyCheck(test, test.name, `${guardName}(${test.name})`);
|
|
400
409
|
}
|
|
401
410
|
},
|
|
402
411
|
// LogicalExpression: state && expr, state || expr
|
|
@@ -408,13 +417,13 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
408
417
|
if (node.operator === '&&') {
|
|
409
418
|
// `state && expr` guards expr, so swapping the operand for the
|
|
410
419
|
// guard keeps both the polarity and the narrowing of `state`.
|
|
411
|
-
reportFalsyCheck(left, left.name, `${
|
|
420
|
+
reportFalsyCheck(left, left.name, `${guardName}(${left.name})`);
|
|
412
421
|
return;
|
|
413
422
|
}
|
|
414
423
|
// `state || fallback` evaluates to the state itself when it is
|
|
415
424
|
// usable, so a bare operand swap would yield `true` instead of the
|
|
416
425
|
// data. Only the conditional form preserves that value.
|
|
417
|
-
const guarded = `${
|
|
426
|
+
const guarded = `${guardName}(${left.name}) ? ${left.name} : ${getText(node.right)}`;
|
|
418
427
|
reportFalsyCheck(left, left.name, needsParentheses(node) ? `(${guarded})` : guarded, node);
|
|
419
428
|
}
|
|
420
429
|
},
|
|
@@ -432,7 +441,7 @@ exports.enforceSnapshotStateNarrowing = (0, createRule_1.createRule)({
|
|
|
432
441
|
node.arguments[0].type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
433
442
|
isSnapshotVar(node.arguments[0])) {
|
|
434
443
|
const varName = node.arguments[0].name;
|
|
435
|
-
reportFalsyCheck(node, `Boolean(${varName})`, `${
|
|
444
|
+
reportFalsyCheck(node, `Boolean(${varName})`, `${guardName}(${varName})`);
|
|
436
445
|
}
|
|
437
446
|
},
|
|
438
447
|
};
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,40 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.46",
|
|
4
|
+
"date": "2026-07-31T07:08:55.415Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-snapshot-state-narrowing",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1505
|
|
11
|
+
],
|
|
12
|
+
"summary": "make the guardFunctions option actually select the emitted guard name (closes #1505)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.45",
|
|
18
|
+
"date": "2026-07-31T06:37:56.216Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "enforce-mui-rounded-icons",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1502
|
|
25
|
+
],
|
|
26
|
+
"summary": "examine barrel imports, not just deep ones (closes #1502)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "enforce-object-literal-as-const",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
1503
|
|
33
|
+
],
|
|
34
|
+
"summary": "stop the autofix destroying an existing type assertion (closes #1503)"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
},
|
|
2
38
|
{
|
|
3
39
|
"version": "1.20.44",
|
|
4
40
|
"date": "2026-07-31T05:47:19.150Z",
|