@blumintinc/eslint-plugin-blumint 1.20.160 → 1.20.162
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-react-type-naming.js +94 -3
- package/lib/rules/no-hungarian.js +131 -2
- package/package.json +1 -1
- package/release-manifest.json +28 -0
package/lib/index.js
CHANGED
|
@@ -191,6 +191,68 @@ const isGlobalConstStyleGoverned = (declarator) => {
|
|
|
191
191
|
}
|
|
192
192
|
return (!isDynamicValue(init) && !isBindingAlias(init) && !isJestMockCast(init));
|
|
193
193
|
};
|
|
194
|
+
// `as const` is spelled as a `TSTypeReference` named `const`, so it reaches
|
|
195
|
+
// `getTypeName` like any other type name and has to be excluded by name.
|
|
196
|
+
const isAsConstAssertion = (typeAnnotation) => typeAnnotation.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
|
|
197
|
+
typeAnnotation.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
198
|
+
typeAnnotation.typeName.name === 'const';
|
|
199
|
+
/**
|
|
200
|
+
* The React type an `as`/`<T>` assertion pins on an initializer, or `undefined`.
|
|
201
|
+
*
|
|
202
|
+
* An assertion declares the binding's type exactly as bindingly as an
|
|
203
|
+
* annotation does — `tsc --emitDeclarationOnly` emits the same
|
|
204
|
+
* `export declare const config: FC;` for `const config: FC = {} as FC` and for
|
|
205
|
+
* `const config = {} as FC` — so it is a detection carrier, not decoration.
|
|
206
|
+
* Reading only the annotation made the shipped config's own `--fix` erase this
|
|
207
|
+
* rule's report: `no-redundant-annotation-assertion` deletes the redundant
|
|
208
|
+
* ANNOTATION and keeps the assertion (its fix direction is the type-safe one,
|
|
209
|
+
* since dropping the assertion instead leaves `const config: FC = {}`, TS2322),
|
|
210
|
+
* so a reported violation became an unreportable one (Issue #2029).
|
|
211
|
+
*
|
|
212
|
+
* Only the OUTERMOST assertion applied to the initializer answers. A nested one
|
|
213
|
+
* describes a sub-expression rather than the binding: in `(e as FC)()` the
|
|
214
|
+
* binding holds FC's RETURN value, and in `thing as unknown as FC` the
|
|
215
|
+
* intermediate `unknown` is discarded by the outer `as FC`. Parentheses need no
|
|
216
|
+
* handling because TSESTree does not model them, so `({} as FC)` IS the
|
|
217
|
+
* assertion node; `!` does, and is read through — non-nullability cannot change
|
|
218
|
+
* which React type names the value.
|
|
219
|
+
*
|
|
220
|
+
* `satisfies` is excluded because it leaves the expression's type alone
|
|
221
|
+
* (`{} satisfies FC` is still `{}`), and `as const` names no React type.
|
|
222
|
+
*/
|
|
223
|
+
const assertedTypeOf = (init) => {
|
|
224
|
+
let target = init ?? undefined;
|
|
225
|
+
while (target?.type === utils_1.AST_NODE_TYPES.TSNonNullExpression) {
|
|
226
|
+
target = target.expression;
|
|
227
|
+
}
|
|
228
|
+
if (target?.type !== utils_1.AST_NODE_TYPES.TSAsExpression &&
|
|
229
|
+
target?.type !== utils_1.AST_NODE_TYPES.TSTypeAssertion) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
return isAsConstAssertion(target.typeAnnotation)
|
|
233
|
+
? undefined
|
|
234
|
+
: target.typeAnnotation;
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Reports whether this identifier is the NAME of a JSX element (`<Content />`),
|
|
238
|
+
* as opposed to a value reference inside one (`{Content}`).
|
|
239
|
+
*
|
|
240
|
+
* A JSX element name resolves to a binding only while it starts uppercase: the
|
|
241
|
+
* scope analyzer models `<content />` as an intrinsic host element instead. So
|
|
242
|
+
* lowercasing such a name does not carry the reference across — it unbinds it,
|
|
243
|
+
* leaving the declaration orphaned and an unknown HTML tag rendered in its
|
|
244
|
+
* place. `JSXMemberExpression` is excluded on purpose: a dot always means value
|
|
245
|
+
* access, so `<ns.Thing />` keeps resolving whatever the case of `ns`.
|
|
246
|
+
*/
|
|
247
|
+
const isJsxElementName = (node) => {
|
|
248
|
+
const { parent } = node;
|
|
249
|
+
if (!parent) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
return ((parent.type === utils_1.AST_NODE_TYPES.JSXOpeningElement ||
|
|
253
|
+
parent.type === utils_1.AST_NODE_TYPES.JSXClosingElement) &&
|
|
254
|
+
parent.name === node);
|
|
255
|
+
};
|
|
194
256
|
exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
|
|
195
257
|
name: 'enforce-react-type-naming',
|
|
196
258
|
meta: {
|
|
@@ -296,6 +358,28 @@ exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
|
|
|
296
358
|
newName,
|
|
297
359
|
});
|
|
298
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* Reports whether lowercasing this variable's name would unbind a
|
|
363
|
+
* `<Name />` that resolves to it.
|
|
364
|
+
*
|
|
365
|
+
* The rename is withheld there and the report stands alone, on the same
|
|
366
|
+
* terms as a colliding rename: an incomplete rename is worse than none. The
|
|
367
|
+
* rewritten element would render an intrinsic host tag while the declaration
|
|
368
|
+
* it used to name sits unreferenced, which is dead code the fix itself
|
|
369
|
+
* created — and a consumer building with `noUnusedLocals` gets a red CI out
|
|
370
|
+
* of running `--fix`.
|
|
371
|
+
*
|
|
372
|
+
* Asked of variables only. An unused PARAMETER is an ordinary
|
|
373
|
+
* signature-driven shape rather than dead code, so the parameter rename
|
|
374
|
+
* stays as #1357 pinned it.
|
|
375
|
+
*/
|
|
376
|
+
function renameUnbindsJsxElement(owner, declarationId) {
|
|
377
|
+
const variable = context
|
|
378
|
+
.getDeclaredVariables(owner)
|
|
379
|
+
.find((candidate) => candidate.defs.some((def) => def.name === declarationId));
|
|
380
|
+
return !!variable?.references.some((reference) => reference.identifier !== declarationId &&
|
|
381
|
+
isJsxElementName(reference.identifier));
|
|
382
|
+
}
|
|
299
383
|
/**
|
|
300
384
|
* Check variable declarations for React type naming conventions
|
|
301
385
|
*/
|
|
@@ -313,8 +397,11 @@ exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
|
|
|
313
397
|
if (isGlobalConstStyleGoverned(node))
|
|
314
398
|
return;
|
|
315
399
|
const variableName = id.name;
|
|
316
|
-
//
|
|
317
|
-
|
|
400
|
+
// The declared type, from either carrier: the annotation when it is
|
|
401
|
+
// written, otherwise the assertion that stands in for it (#2029). The
|
|
402
|
+
// annotation wins when both are present — it is what the binding's type
|
|
403
|
+
// resolves to — so `const x: unknown = {} as FC` reads `unknown`.
|
|
404
|
+
const typeAnnotation = id.typeAnnotation?.typeAnnotation ?? assertedTypeOf(node.init);
|
|
318
405
|
const typeName = getTypeName(typeAnnotation);
|
|
319
406
|
if (!typeName)
|
|
320
407
|
return;
|
|
@@ -328,7 +415,9 @@ exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
|
|
|
328
415
|
type: typeName,
|
|
329
416
|
suggestion,
|
|
330
417
|
},
|
|
331
|
-
fix: (fixer) =>
|
|
418
|
+
fix: (fixer) => renameUnbindsJsxElement(node, id)
|
|
419
|
+
? null
|
|
420
|
+
: buildRenameFixes(fixer, node, id, suggestion),
|
|
332
421
|
});
|
|
333
422
|
}
|
|
334
423
|
// Check if it's a ComponentType or FC (should be uppercase)
|
|
@@ -341,6 +430,8 @@ exports.enforceReactTypeNaming = (0, createRule_1.createRule)({
|
|
|
341
430
|
type: typeName,
|
|
342
431
|
suggestion,
|
|
343
432
|
},
|
|
433
|
+
// Uppercasing cannot unbind a `<Name />`: the target starts uppercase,
|
|
434
|
+
// which is exactly what a JSX element name needs to resolve at all.
|
|
344
435
|
fix: (fixer) => buildRenameFixes(fixer, node, id, suggestion),
|
|
345
436
|
});
|
|
346
437
|
}
|
|
@@ -155,6 +155,53 @@ const DOMAIN_SYMBOL_HEAD_NOUNS = new Set([
|
|
|
155
155
|
'unicode',
|
|
156
156
|
'ascii',
|
|
157
157
|
]);
|
|
158
|
+
// Domain head nouns that legitimately precede a "Class" suffix. In
|
|
159
|
+
// <taxonomy>Class the trailing "Class" is the HEAD NOUN of the domain concept —
|
|
160
|
+
// the BUCKET a value falls into — not the JavaScript `class` construct bolted
|
|
161
|
+
// onto the name. "Window size class" is the Material Design 3 / UIKit term for
|
|
162
|
+
// a breakpoint bucket (compact/medium/expanded), a regex *character class* is
|
|
163
|
+
// `[a-z]`, an S3 *storage class* is "STANDARD" — in each case the value is a
|
|
164
|
+
// string or number map, so there is no type marker to strip: removing the
|
|
165
|
+
// suffix yields a wrong name (WINDOW_SIZE names a width, not a bucket). Same
|
|
166
|
+
// reasoning as the <entity>Number (#1277) and <domain>Symbol (#1835)
|
|
167
|
+
// carve-outs, applied to the taxonomy sense of "class" (#2030).
|
|
168
|
+
//
|
|
169
|
+
// Nouns whose <noun>Class reads as a tag on a JS class value — user, config,
|
|
170
|
+
// helper, base, model, controller, wrapper — are intentionally ABSENT, so
|
|
171
|
+
// UserClass / userClass / HELPER_CLASS stay flagged, as do all PREFIX uses
|
|
172
|
+
// (classRegistry, CLASS_MAP). The carve-out is additionally vetoed whenever the
|
|
173
|
+
// declaration syntactically proves a real class (see isClassValuedDeclaration).
|
|
174
|
+
const DOMAIN_CLASS_HEAD_NOUNS = new Set([
|
|
175
|
+
// Layout / responsive design: M3 and UIKit bucket a window's width into a
|
|
176
|
+
// "size class".
|
|
177
|
+
'size',
|
|
178
|
+
// Regex and linguistics: character class ([a-z]), word class (noun/verb).
|
|
179
|
+
'character',
|
|
180
|
+
'word',
|
|
181
|
+
// Mathematics / CS taxonomy: equivalence class, complexity class (P, NP).
|
|
182
|
+
'equivalence',
|
|
183
|
+
'complexity',
|
|
184
|
+
// Systems / infrastructure: C and S3 storage class, QoS traffic/service
|
|
185
|
+
// class, USB device class.
|
|
186
|
+
'storage',
|
|
187
|
+
'traffic',
|
|
188
|
+
'service',
|
|
189
|
+
'device',
|
|
190
|
+
// Finance: asset class, share class.
|
|
191
|
+
'asset',
|
|
192
|
+
'share',
|
|
193
|
+
// Travel: fare/cabin/booking class (airline RBD codes).
|
|
194
|
+
'fare',
|
|
195
|
+
'cabin',
|
|
196
|
+
'booking',
|
|
197
|
+
// Categorization by attribute: weight class (boxing), age class, hazard
|
|
198
|
+
// class (DOT), drug class (pharmacology), vehicle class (DMV).
|
|
199
|
+
'weight',
|
|
200
|
+
'age',
|
|
201
|
+
'hazard',
|
|
202
|
+
'drug',
|
|
203
|
+
'vehicle',
|
|
204
|
+
]);
|
|
158
205
|
// Common built-in JavaScript prototype methods
|
|
159
206
|
const BUILT_IN_METHODS = new Set([
|
|
160
207
|
// String methods
|
|
@@ -404,6 +451,24 @@ function isDomainSymbolCompound(name) {
|
|
|
404
451
|
const lastSegment = segments[segments.length - 1];
|
|
405
452
|
return (!!lastSegment && DOMAIN_SYMBOL_HEAD_NOUNS.has(lastSegment.toLowerCase()));
|
|
406
453
|
}
|
|
454
|
+
// Is `name` a domain compound of the form <taxonomy>Class, where the word
|
|
455
|
+
// directly before the trailing "Class" names a bucketing taxonomy
|
|
456
|
+
// (windowSizeClass, characterClass, storageClass)? Only the LAST head segment
|
|
457
|
+
// is consulted, so prefixed variants generalize (currentWindowSizeClass passes)
|
|
458
|
+
// while names whose value is a real JS class keep firing (userClass -> head
|
|
459
|
+
// segment "user", not a taxonomy).
|
|
460
|
+
function isDomainClassCompound(name) {
|
|
461
|
+
if (!name.endsWith('Class')) {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
const head = name.slice(0, -'Class'.length);
|
|
465
|
+
if (head.length === 0) {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
const segments = splitCamelSegments(head);
|
|
469
|
+
const lastSegment = segments[segments.length - 1];
|
|
470
|
+
return (!!lastSegment && DOMAIN_CLASS_HEAD_NOUNS.has(lastSegment.toLowerCase()));
|
|
471
|
+
}
|
|
407
472
|
// Does a type annotation denote the JS `symbol` primitive (`symbol` or the
|
|
408
473
|
// declaration-site form `unique symbol`)?
|
|
409
474
|
function isSymbolTypeAnnotation(node) {
|
|
@@ -476,6 +541,43 @@ function isSymbolTypedDeclaration(node) {
|
|
|
476
541
|
return false;
|
|
477
542
|
}
|
|
478
543
|
}
|
|
544
|
+
// Does a type annotation denote a class constructor (`new (...) => T`)? Such an
|
|
545
|
+
// annotation is the one syntactic spelling that proves the annotated value is a
|
|
546
|
+
// class without type information.
|
|
547
|
+
function isConstructorTypeAnnotation(node) {
|
|
548
|
+
return !!node && node.type === utils_1.AST_NODE_TYPES.TSConstructorType;
|
|
549
|
+
}
|
|
550
|
+
// Does the declaration site PROVE, syntactically, that the named value is a JS
|
|
551
|
+
// class? Only a `class` expression initializer, a class declaration's own name,
|
|
552
|
+
// or an explicit constructor-type annotation are conclusive without type
|
|
553
|
+
// information — an aliased constructor (`const sizeClass = User`) is invisible
|
|
554
|
+
// here, which is precisely why the taxonomy carve-out is keyed on the head noun
|
|
555
|
+
// rather than on the type. When this holds, the trailing "Class" genuinely
|
|
556
|
+
// encodes the value's type and the DOMAIN_CLASS_HEAD_NOUNS carve-out is vetoed,
|
|
557
|
+
// so `const SizeClass = class {}` still reports. Mirrors
|
|
558
|
+
// isSymbolTypedDeclaration (#1835).
|
|
559
|
+
function isClassValuedDeclaration(node) {
|
|
560
|
+
if (isConstructorTypeAnnotation(node.typeAnnotation?.typeAnnotation)) {
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
const parent = node.parent;
|
|
564
|
+
if (!parent) {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
switch (parent.type) {
|
|
568
|
+
case utils_1.AST_NODE_TYPES.ClassDeclaration:
|
|
569
|
+
case utils_1.AST_NODE_TYPES.ClassExpression:
|
|
570
|
+
return parent.id === node;
|
|
571
|
+
case utils_1.AST_NODE_TYPES.VariableDeclarator:
|
|
572
|
+
return (parent.id === node &&
|
|
573
|
+
parent.init?.type === utils_1.AST_NODE_TYPES.ClassExpression);
|
|
574
|
+
case utils_1.AST_NODE_TYPES.PropertyDefinition:
|
|
575
|
+
return (parent.key === node &&
|
|
576
|
+
parent.value?.type === utils_1.AST_NODE_TYPES.ClassExpression);
|
|
577
|
+
default:
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
479
581
|
// Rebuild a SCREAMING_SNAKE_CASE identifier's segments into a PascalCase compound
|
|
480
582
|
// (["MATCH","NUMBER"] -> "MatchNumber") so the snake-case branch can reuse the
|
|
481
583
|
// camelCase isDomainNumberCompound / DOMAIN_NUMBER_HEAD_NOUNS exemption verbatim,
|
|
@@ -512,7 +614,9 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
512
614
|
// interfaces, classes), enabling the semantic-type-concept exemption.
|
|
513
615
|
// `isSymbolTyped` is true when the declaration syntactically proves a JS
|
|
514
616
|
// `symbol` value, which vetoes the <domain>Symbol glyph exemption.
|
|
515
|
-
|
|
617
|
+
// `isClassValued` is true when the declaration syntactically proves a JS
|
|
618
|
+
// class value, which vetoes the <taxonomy>Class exemption.
|
|
619
|
+
function hasTypeMarker(variableName, isTypeName = false, isSymbolTyped = false, isClassValued = false) {
|
|
516
620
|
// Type names whose type-word denotes a concept/relation (StringToNumber,
|
|
517
621
|
// CapitalizedString, FuncKeys, PromiseOrValue) are not Hungarian — the word
|
|
518
622
|
// is part of the type's meaning, like the allowed compound noun PhoneNumber.
|
|
@@ -621,6 +725,17 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
621
725
|
isDomainSymbolCompound(screamingSnakePartsToPascalCase(parts))) {
|
|
622
726
|
return false;
|
|
623
727
|
}
|
|
728
|
+
// A trailing "..._CLASS" whose preceding head noun names a
|
|
729
|
+
// bucketing taxonomy (WINDOW_SIZE_CLASS, STORAGE_CLASS) is a
|
|
730
|
+
// domain compound, not a type tag — same carve-out as camelCase
|
|
731
|
+
// windowSizeClass (#2030), routed through the shared PascalCase
|
|
732
|
+
// helper so the two casings cannot diverge (the #1294 asymmetry).
|
|
733
|
+
if (normalizedMarker === 'class' &&
|
|
734
|
+
index === lastIndex &&
|
|
735
|
+
!isClassValued &&
|
|
736
|
+
isDomainClassCompound(screamingSnakePartsToPascalCase(parts))) {
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
624
739
|
return true;
|
|
625
740
|
});
|
|
626
741
|
});
|
|
@@ -695,6 +810,20 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
695
810
|
isDomainSymbolCompound(variableName)) {
|
|
696
811
|
return false;
|
|
697
812
|
}
|
|
813
|
+
// A trailing "...Class" whose head noun names a bucketing taxonomy
|
|
814
|
+
// (windowSizeClass, characterClass, storageClass) is a domain
|
|
815
|
+
// compound: the suffix names WHAT the value is (the BUCKET a window
|
|
816
|
+
// width falls into — Material Design 3 / UIKit vocabulary), and
|
|
817
|
+
// stripping it yields a wrong name (windowSize is a width, not a
|
|
818
|
+
// bucket) (#2030). Scoped to the full-word `Class` marker in SUFFIX
|
|
819
|
+
// position only, and vetoed when the declaration proves a real
|
|
820
|
+
// class, so userClass / classRegistry / `const SizeClass = class {}`
|
|
821
|
+
// keep firing.
|
|
822
|
+
if (normalizedMarker === 'class' &&
|
|
823
|
+
!isClassValued &&
|
|
824
|
+
isDomainClassCompound(variableName)) {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
698
827
|
return true;
|
|
699
828
|
}
|
|
700
829
|
// Full type-word markers (non-abbreviations: String, Number, Function,
|
|
@@ -770,7 +899,7 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
770
899
|
if (isExternalOrBuiltIn(node))
|
|
771
900
|
return;
|
|
772
901
|
// Check for type markers
|
|
773
|
-
if (hasTypeMarker(name, isTypeName, isSymbolTypedDeclaration(node))) {
|
|
902
|
+
if (hasTypeMarker(name, isTypeName, isSymbolTypedDeclaration(node), isClassValuedDeclaration(node))) {
|
|
774
903
|
context.report({
|
|
775
904
|
node,
|
|
776
905
|
messageId: 'noHungarian',
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.162",
|
|
4
|
+
"date": "2026-08-17T15:38:50.817Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "no-hungarian",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
2030
|
|
11
|
+
],
|
|
12
|
+
"summary": "treat Class as a taxonomy head noun in suffix position (closes #2030)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.161",
|
|
18
|
+
"date": "2026-08-17T13:22:13.676Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "enforce-react-type-naming",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
2029
|
|
25
|
+
],
|
|
26
|
+
"summary": "read the type from a type assertion (closes #2029)"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
2
30
|
{
|
|
3
31
|
"version": "1.20.160",
|
|
4
32
|
"date": "2026-08-17T02:55:06.538Z",
|