@blumintinc/eslint-plugin-blumint 1.20.127 → 1.20.129
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-assert-safe-object-key.js +62 -11
- package/lib/rules/enforce-querykey-ts.js +10 -0
- package/lib/rules/no-circular-references.js +33 -6
- package/lib/rules/no-hungarian.js +161 -2
- package/lib/rules/no-misleading-boolean-prefixes.js +7 -0
- package/lib/rules/no-passthrough-getters.js +176 -0
- package/lib/rules/no-uuidv4-base62-as-key.js +51 -25
- package/lib/rules/prefer-global-router-state-key.js +72 -14
- package/package.json +1 -1
- package/release-manifest.json +77 -0
package/lib/index.js
CHANGED
|
@@ -129,6 +129,10 @@ function isInsideMockFactory(node) {
|
|
|
129
129
|
*
|
|
130
130
|
* The peel repeats because the wrappers nest: `(x as any)!` is a non-null
|
|
131
131
|
* assertion over a type assertion.
|
|
132
|
+
*
|
|
133
|
+
* Everything peeled here erases before the code runs, which is why the peel is
|
|
134
|
+
* unconditional. An optional chain does not, so it is handled apart from these
|
|
135
|
+
* — see `unwrapOptionalChain`.
|
|
132
136
|
*/
|
|
133
137
|
function unwrapKeyExpression(node) {
|
|
134
138
|
let current = node;
|
|
@@ -148,6 +152,43 @@ function unwrapKeyExpression(node) {
|
|
|
148
152
|
}
|
|
149
153
|
}
|
|
150
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Reads through an optional chain to the member access or call it holds.
|
|
157
|
+
* `source?.key` parses as a `ChainExpression` wrapping the member expression,
|
|
158
|
+
* so a classification that matches a bare `MemberExpression` — or a numeric
|
|
159
|
+
* proof that matches `.length` — sees the wrapper and recognizes nothing.
|
|
160
|
+
*
|
|
161
|
+
* Kept apart from `unwrapKeyExpression` rather than folded into it because the
|
|
162
|
+
* two make different claims. Those wrappers are gone before the code runs; `?.`
|
|
163
|
+
* survives and short-circuits, so it is read through only where the question is
|
|
164
|
+
* "what value names this property", never where the question is what the
|
|
165
|
+
* expression does. That value is what the chain evaluates to, `undefined`
|
|
166
|
+
* included — and the chain guards a nullish RECEIVER, not a hostile KEY:
|
|
167
|
+
* `"__proto__"` is a perfectly non-nullish string, so `store[req.body?.key]`
|
|
168
|
+
* reaches the prototype surface exactly as `store[req.body.key]` does.
|
|
169
|
+
*/
|
|
170
|
+
function unwrapOptionalChain(node) {
|
|
171
|
+
return node.type === utils_1.AST_NODE_TYPES.ChainExpression ? node.expression : node;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The key expression stripped of every wrapper standing between it and the
|
|
175
|
+
* value that names the property: the compile-time assertions and the `await`
|
|
176
|
+
* that `unwrapKeyExpression` peels, plus an optional chain.
|
|
177
|
+
*
|
|
178
|
+
* The peel repeats because the two kinds nest in either order — `source?.key as
|
|
179
|
+
* string` is an assertion over a chain, `(source as Raw)?.key` a chain over an
|
|
180
|
+
* assertion.
|
|
181
|
+
*/
|
|
182
|
+
function unwrapWrittenKey(node) {
|
|
183
|
+
let current = node;
|
|
184
|
+
for (;;) {
|
|
185
|
+
const peeled = unwrapOptionalChain(unwrapKeyExpression(current));
|
|
186
|
+
if (peeled === current) {
|
|
187
|
+
return current;
|
|
188
|
+
}
|
|
189
|
+
current = peeled;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
151
192
|
/** Names that read as a positional sequence rather than a keyed record. */
|
|
152
193
|
const ARRAY_LIKE_NAME = /^(array|arr|items|elements|list|collection|data)s?$/i;
|
|
153
194
|
/**
|
|
@@ -534,7 +575,8 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
534
575
|
},
|
|
535
576
|
});
|
|
536
577
|
/**
|
|
537
|
-
* Reports a key whose written form may carry assertion or await wrappers
|
|
578
|
+
* Reports a key whose written form may carry assertion or await wrappers or
|
|
579
|
+
* an optional chain.
|
|
538
580
|
*
|
|
539
581
|
* The report and the fix sit on the outermost written node, so the wrapper
|
|
540
582
|
* the author put there survives the rewrite: `m[assertSafe(k as string)]`
|
|
@@ -542,7 +584,10 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
542
584
|
* own. `assertSafe` is identity-typed (`<T extends PropertyKey>(key: T): T`),
|
|
543
585
|
* so wrapping the asserted expression preserves the key's type, and wrapping
|
|
544
586
|
* an `await` keeps the validation on the resolved key rather than moving it
|
|
545
|
-
* onto the promise.
|
|
587
|
+
* onto the promise. Wrapping the whole chain is what keeps the short-circuit
|
|
588
|
+
* intact: `m[assertSafe(source?.key)]` evaluates `source?.key` once, in the
|
|
589
|
+
* position the author wrote it, and hands assertSafe what it produces — the
|
|
590
|
+
* rewrite adds a validation, it does not move a dereference.
|
|
546
591
|
*
|
|
547
592
|
* A key written without a wrapper keeps the narrower argument the fix has
|
|
548
593
|
* always emitted: `String(id)` and `` `${id}` `` collapse to `id`, whose
|
|
@@ -563,8 +608,11 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
563
608
|
if (!variable)
|
|
564
609
|
return false;
|
|
565
610
|
return variable.defs.some((def) => {
|
|
566
|
-
|
|
567
|
-
|
|
611
|
+
// `assertSafe?.(rawKey)` produces the very same validated key as
|
|
612
|
+
// `assertSafe(rawKey)` — the chain guards only a nullish callee — so
|
|
613
|
+
// the exemption reads through it rather than re-reporting the binding.
|
|
614
|
+
const init = def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator && def.node.init
|
|
615
|
+
? unwrapOptionalChain(def.node.init)
|
|
568
616
|
: null;
|
|
569
617
|
return (!!init &&
|
|
570
618
|
init.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
@@ -584,8 +632,11 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
584
632
|
// An assertion or an await around an operand leaves its run-time value
|
|
585
633
|
// alone, so the proof reads through to what the wrapper holds. The
|
|
586
634
|
// annotation on the binding underneath is what proves the key numeric —
|
|
587
|
-
// an assertion asserts and proves nothing on its own.
|
|
588
|
-
|
|
635
|
+
// an assertion asserts and proves nothing on its own. An optional chain
|
|
636
|
+
// is read through as well: `xs?.length` is the same `.length` proof, and
|
|
637
|
+
// its short-circuit yields `undefined`, which stringifies to "undefined"
|
|
638
|
+
// and so still names no field of the prototype surface.
|
|
639
|
+
const target = unwrapWrittenKey(node);
|
|
589
640
|
switch (target.type) {
|
|
590
641
|
case utils_1.AST_NODE_TYPES.Literal:
|
|
591
642
|
return typeof target.value === 'number';
|
|
@@ -662,7 +713,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
662
713
|
Property(node) {
|
|
663
714
|
if (node.computed && node.key) {
|
|
664
715
|
const written = node.key;
|
|
665
|
-
const key =
|
|
716
|
+
const key = unwrapWrittenKey(written);
|
|
666
717
|
// Check for String(id) pattern
|
|
667
718
|
if (key.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
668
719
|
key.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
@@ -687,7 +738,7 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
687
738
|
BinaryExpression(node) {
|
|
688
739
|
if (node.operator === 'in') {
|
|
689
740
|
const written = node.left;
|
|
690
|
-
const left =
|
|
741
|
+
const left = unwrapWrittenKey(written);
|
|
691
742
|
// Check for String(id) pattern
|
|
692
743
|
if (left.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
693
744
|
left.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
@@ -712,9 +763,9 @@ exports.enforceAssertSafeObjectKey = (0, createRule_1.createRule)({
|
|
|
712
763
|
if (node.computed) {
|
|
713
764
|
const written = node.property;
|
|
714
765
|
// The written key may sit under assertion or await wrappers that erase
|
|
715
|
-
// at run time; what they hold is what
|
|
716
|
-
// what the branches below classify.
|
|
717
|
-
const property =
|
|
766
|
+
// at run time, or under an optional chain; what they hold is what
|
|
767
|
+
// names the property, so that is what the branches below classify.
|
|
768
|
+
const property = unwrapWrittenKey(written);
|
|
718
769
|
// Skip if already using assertSafe
|
|
719
770
|
if (property.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
720
771
|
property.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
@@ -377,6 +377,16 @@ exports.enforceQueryKeyTs = (0, createRule_1.createRule)({
|
|
|
377
377
|
* Check if a node represents a valid query key usage
|
|
378
378
|
*/
|
|
379
379
|
function isValidQueryKeyUsage(node) {
|
|
380
|
+
// `config?.getQueryKey()` parses as a `ChainExpression` wrapping the call,
|
|
381
|
+
// a type this switch does not name — so the optional spelling alone fell
|
|
382
|
+
// through to `return false` and bypassed the carve-outs below, reporting a
|
|
383
|
+
// key the plain spelling is allowed to build (#1832). Optionality is
|
|
384
|
+
// orthogonal to what this function asks: the question is where the key
|
|
385
|
+
// comes from, and a short-circuit changes only whether the same source is
|
|
386
|
+
// evaluated, never which source it is.
|
|
387
|
+
if (node.type === utils_1.AST_NODE_TYPES.ChainExpression) {
|
|
388
|
+
return isValidQueryKeyUsage(node.expression);
|
|
389
|
+
}
|
|
380
390
|
if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
381
391
|
const importInfo = queryKeyImports.get(node.name);
|
|
382
392
|
if (importInfo && isQueryKeysSource(importInfo.source)) {
|
|
@@ -37,6 +37,28 @@ exports.noCircularReferences = (0, createRule_1.createRule)({
|
|
|
37
37
|
? current
|
|
38
38
|
: null;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The expression a runtime-transparent wrapper stands in for.
|
|
42
|
+
*
|
|
43
|
+
* `a?.b` parses as a `ChainExpression` around the member read, and
|
|
44
|
+
* resolution has to see through it: the optional link records that the
|
|
45
|
+
* access short-circuits, not that it names something else. Whenever the
|
|
46
|
+
* receiver is non-nullish — and an object literal bound to a `const` always
|
|
47
|
+
* is — `cfg?.node` evaluates to exactly `cfg.node`, so it aliases the same
|
|
48
|
+
* object and belongs to the same cycle. Stopping at the wrapper drops the
|
|
49
|
+
* alias, and a real cycle goes unreported.
|
|
50
|
+
*
|
|
51
|
+
* Local rather than folded into `ASTHelpers.unwrapTSAssertions`, because
|
|
52
|
+
* that helper answers "which assertions restate this expression" for five
|
|
53
|
+
* other rules, and a chain is a different question for each of them.
|
|
54
|
+
*/
|
|
55
|
+
function unwrapTransparent(node) {
|
|
56
|
+
let current = ASTHelpers_1.ASTHelpers.unwrapTSAssertions(node);
|
|
57
|
+
while (current.type === utils_1.AST_NODE_TYPES.ChainExpression) {
|
|
58
|
+
current = ASTHelpers_1.ASTHelpers.unwrapTSAssertions(current.expression);
|
|
59
|
+
}
|
|
60
|
+
return current;
|
|
61
|
+
}
|
|
40
62
|
function isIdentifier(node) {
|
|
41
63
|
return node.type === utils_1.AST_NODE_TYPES.Identifier;
|
|
42
64
|
}
|
|
@@ -66,7 +88,7 @@ exports.noCircularReferences = (0, createRule_1.createRule)({
|
|
|
66
88
|
return null;
|
|
67
89
|
}
|
|
68
90
|
function getReferencedObject(node, visitedVariables = new Set(), visitedNodes = new Set()) {
|
|
69
|
-
const current =
|
|
91
|
+
const current = unwrapTransparent(node);
|
|
70
92
|
if (visitedNodes.has(current))
|
|
71
93
|
return null;
|
|
72
94
|
visitedNodes.add(current);
|
|
@@ -129,14 +151,19 @@ exports.noCircularReferences = (0, createRule_1.createRule)({
|
|
|
129
151
|
}
|
|
130
152
|
}
|
|
131
153
|
if (propValue) {
|
|
132
|
-
|
|
154
|
+
// The stored value keeps whatever notation it was written with, so
|
|
155
|
+
// the dispatch below asks its question of the expression that
|
|
156
|
+
// notation stands for — `{ b: level1?.a }` reaches here as a
|
|
157
|
+
// ChainExpression and would otherwise match no arm at all.
|
|
158
|
+
const resolvedValue = unwrapTransparent(propValue);
|
|
159
|
+
const unwrappedValue = getUnwrappedObjectOrArray(resolvedValue);
|
|
133
160
|
if (unwrappedValue)
|
|
134
161
|
return unwrappedValue;
|
|
135
|
-
if (isIdentifier(
|
|
136
|
-
|
|
137
|
-
return getReferencedObject(
|
|
162
|
+
if (isIdentifier(resolvedValue) ||
|
|
163
|
+
resolvedValue.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
164
|
+
return getReferencedObject(resolvedValue, visitedVariables, visitedNodes);
|
|
138
165
|
}
|
|
139
|
-
if (isFunction(
|
|
166
|
+
if (isFunction(resolvedValue) || isPrimitive(resolvedValue)) {
|
|
140
167
|
return null;
|
|
141
168
|
}
|
|
142
169
|
}
|
|
@@ -113,6 +113,48 @@ const DOMAIN_NUMBER_HEAD_NOUNS = new Set([
|
|
|
113
113
|
'channel',
|
|
114
114
|
'badge',
|
|
115
115
|
]);
|
|
116
|
+
// Domain head nouns that legitimately precede a "Symbol" suffix. In
|
|
117
|
+
// <domain>Symbol the trailing "Symbol" is the HEAD NOUN of the domain concept —
|
|
118
|
+
// the printed GLYPH that writes a currency/ticker/unit/element ("$", "BTC",
|
|
119
|
+
// "kg", "Fe") — not the JS `symbol` primitive bolted onto the name. ISO 4217 and
|
|
120
|
+
// CLDR literally call that glyph a *currency symbol*, and the value is a string,
|
|
121
|
+
// so there is no type marker to strip: removing it yields a wrong name
|
|
122
|
+
// (`currency` denotes the currency itself, not the character it is written
|
|
123
|
+
// with). Same reasoning as the <entity>Number carve-out (#1277), applied to the
|
|
124
|
+
// glyph sense of "symbol" (#1835).
|
|
125
|
+
//
|
|
126
|
+
// Nouns whose <noun>Symbol really does name a JS `symbol` — id, key, cache,
|
|
127
|
+
// brand, tag, marker, meta, registry, slot, field, instance — are intentionally
|
|
128
|
+
// ABSENT, so idSymbol / cacheSymbol / brandSymbol stay flagged, as do all
|
|
129
|
+
// PREFIX uses (symbolKey, symbolValue). The carve-out is additionally vetoed
|
|
130
|
+
// whenever the declaration syntactically proves a `symbol` value (see
|
|
131
|
+
// isSymbolTypedDeclaration).
|
|
132
|
+
const DOMAIN_SYMBOL_HEAD_NOUNS = new Set([
|
|
133
|
+
// Finance / markets: the glyph or ticker a traded thing is written with.
|
|
134
|
+
'currency',
|
|
135
|
+
'ticker',
|
|
136
|
+
'token',
|
|
137
|
+
'coin',
|
|
138
|
+
'asset',
|
|
139
|
+
'stock',
|
|
140
|
+
'share',
|
|
141
|
+
'market',
|
|
142
|
+
'commodity',
|
|
143
|
+
// Measurement and science: "kg", "°", "Fe".
|
|
144
|
+
'unit',
|
|
145
|
+
'degree',
|
|
146
|
+
'element',
|
|
147
|
+
'chemical',
|
|
148
|
+
// Typography / notation / character sets: printed operator, punctuation and
|
|
149
|
+
// phonetic glyphs.
|
|
150
|
+
'math',
|
|
151
|
+
'operator',
|
|
152
|
+
'punctuation',
|
|
153
|
+
'phonetic',
|
|
154
|
+
'musical',
|
|
155
|
+
'unicode',
|
|
156
|
+
'ascii',
|
|
157
|
+
]);
|
|
116
158
|
// Common built-in JavaScript prototype methods
|
|
117
159
|
const BUILT_IN_METHODS = new Set([
|
|
118
160
|
// String methods
|
|
@@ -343,6 +385,97 @@ function isDomainNumberCompound(name) {
|
|
|
343
385
|
const lastSegment = segments[segments.length - 1];
|
|
344
386
|
return (!!lastSegment && DOMAIN_NUMBER_HEAD_NOUNS.has(lastSegment.toLowerCase()));
|
|
345
387
|
}
|
|
388
|
+
// Is `name` a domain compound of the form <domain>Symbol, where the word
|
|
389
|
+
// directly before the trailing "Symbol" names a thing that is WRITTEN with a
|
|
390
|
+
// glyph (currencySymbol, tickerSymbol, unitSymbol)? Only the LAST head segment
|
|
391
|
+
// is consulted, so prefixed and accessor variants generalize
|
|
392
|
+
// (getCurrencySymbol, localizedCurrencySymbol pass) while names whose value is a
|
|
393
|
+
// real JS symbol keep firing (idSymbol -> head segment "Id", not a glyph
|
|
394
|
+
// domain).
|
|
395
|
+
function isDomainSymbolCompound(name) {
|
|
396
|
+
if (!name.endsWith('Symbol')) {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
const head = name.slice(0, -'Symbol'.length);
|
|
400
|
+
if (head.length === 0) {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
const segments = splitCamelSegments(head);
|
|
404
|
+
const lastSegment = segments[segments.length - 1];
|
|
405
|
+
return (!!lastSegment && DOMAIN_SYMBOL_HEAD_NOUNS.has(lastSegment.toLowerCase()));
|
|
406
|
+
}
|
|
407
|
+
// Does a type annotation denote the JS `symbol` primitive (`symbol` or the
|
|
408
|
+
// declaration-site form `unique symbol`)?
|
|
409
|
+
function isSymbolTypeAnnotation(node) {
|
|
410
|
+
if (!node) {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
if (node.type === utils_1.AST_NODE_TYPES.TSSymbolKeyword) {
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
return (node.type === utils_1.AST_NODE_TYPES.TSTypeOperator &&
|
|
417
|
+
node.operator === 'unique' &&
|
|
418
|
+
node.typeAnnotation?.type === utils_1.AST_NODE_TYPES.TSSymbolKeyword);
|
|
419
|
+
}
|
|
420
|
+
// Is the initializer a call to the `Symbol` factory (Symbol('x'), Symbol.for)?
|
|
421
|
+
function isSymbolFactoryCall(node) {
|
|
422
|
+
if (!node || node.type !== utils_1.AST_NODE_TYPES.CallExpression) {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
const { callee } = node;
|
|
426
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
427
|
+
return callee.name === 'Symbol';
|
|
428
|
+
}
|
|
429
|
+
return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
430
|
+
callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
431
|
+
callee.object.name === 'Symbol');
|
|
432
|
+
}
|
|
433
|
+
// Does the declaration site PROVE, syntactically, that the named value is a JS
|
|
434
|
+
// `symbol`? Only an explicit `symbol`/`unique symbol` annotation or a `Symbol()`
|
|
435
|
+
// factory initializer are conclusive without type information — an inferred
|
|
436
|
+
// `string` (the currencySymbol getter returns `part.value`) is invisible here,
|
|
437
|
+
// which is precisely why the glyph carve-out is keyed on the head noun rather
|
|
438
|
+
// than on the type. When this holds, the trailing "Symbol" genuinely encodes the
|
|
439
|
+
// value's type and the DOMAIN_SYMBOL_HEAD_NOUNS carve-out is vetoed, so
|
|
440
|
+
// `const currencySymbol: symbol = Symbol('currency')` still reports.
|
|
441
|
+
function isSymbolTypedDeclaration(node) {
|
|
442
|
+
if (isSymbolTypeAnnotation(node.typeAnnotation?.typeAnnotation)) {
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
const parent = node.parent;
|
|
446
|
+
if (!parent) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
switch (parent.type) {
|
|
450
|
+
case utils_1.AST_NODE_TYPES.VariableDeclarator: {
|
|
451
|
+
if (parent.id !== node) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
if (isSymbolFactoryCall(parent.init)) {
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
// A named accessor/factory arrow describes its RETURNED value, so an
|
|
458
|
+
// explicit `(): symbol` return type is the same proof.
|
|
459
|
+
const init = parent.init;
|
|
460
|
+
return (!!init &&
|
|
461
|
+
(init.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
462
|
+
init.type === utils_1.AST_NODE_TYPES.FunctionExpression) &&
|
|
463
|
+
isSymbolTypeAnnotation(init.returnType?.typeAnnotation));
|
|
464
|
+
}
|
|
465
|
+
case utils_1.AST_NODE_TYPES.PropertyDefinition:
|
|
466
|
+
return (parent.key === node &&
|
|
467
|
+
(isSymbolTypeAnnotation(parent.typeAnnotation?.typeAnnotation) ||
|
|
468
|
+
isSymbolFactoryCall(parent.value)));
|
|
469
|
+
case utils_1.AST_NODE_TYPES.MethodDefinition:
|
|
470
|
+
return (parent.key === node &&
|
|
471
|
+
isSymbolTypeAnnotation(parent.value.returnType?.typeAnnotation));
|
|
472
|
+
case utils_1.AST_NODE_TYPES.FunctionDeclaration:
|
|
473
|
+
return (parent.id === node &&
|
|
474
|
+
isSymbolTypeAnnotation(parent.returnType?.typeAnnotation));
|
|
475
|
+
default:
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
346
479
|
// Rebuild a SCREAMING_SNAKE_CASE identifier's segments into a PascalCase compound
|
|
347
480
|
// (["MATCH","NUMBER"] -> "MatchNumber") so the snake-case branch can reuse the
|
|
348
481
|
// camelCase isDomainNumberCompound / DOMAIN_NUMBER_HEAD_NOUNS exemption verbatim,
|
|
@@ -377,7 +510,9 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
377
510
|
// Check if a variable name contains a type marker with proper word boundaries.
|
|
378
511
|
// `isTypeName` is true for PascalCase type declarations (type aliases,
|
|
379
512
|
// interfaces, classes), enabling the semantic-type-concept exemption.
|
|
380
|
-
|
|
513
|
+
// `isSymbolTyped` is true when the declaration syntactically proves a JS
|
|
514
|
+
// `symbol` value, which vetoes the <domain>Symbol glyph exemption.
|
|
515
|
+
function hasTypeMarker(variableName, isTypeName = false, isSymbolTyped = false) {
|
|
381
516
|
// Type names whose type-word denotes a concept/relation (StringToNumber,
|
|
382
517
|
// CapitalizedString, FuncKeys, PromiseOrValue) are not Hungarian — the word
|
|
383
518
|
// is part of the type's meaning, like the allowed compound noun PhoneNumber.
|
|
@@ -475,6 +610,17 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
475
610
|
isDomainNumberCompound(screamingSnakePartsToPascalCase(parts))) {
|
|
476
611
|
return false;
|
|
477
612
|
}
|
|
613
|
+
// A trailing "..._SYMBOL" whose preceding head noun names a thing
|
|
614
|
+
// written with a glyph (CURRENCY_SYMBOL, TICKER_SYMBOL) is a domain
|
|
615
|
+
// compound, not a type tag — same carve-out as camelCase
|
|
616
|
+
// currencySymbol (#1835), routed through the shared PascalCase
|
|
617
|
+
// helper so the two casings cannot diverge (the #1294 asymmetry).
|
|
618
|
+
if (normalizedMarker === 'symbol' &&
|
|
619
|
+
index === lastIndex &&
|
|
620
|
+
!isSymbolTyped &&
|
|
621
|
+
isDomainSymbolCompound(screamingSnakePartsToPascalCase(parts))) {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
478
624
|
return true;
|
|
479
625
|
});
|
|
480
626
|
});
|
|
@@ -536,6 +682,19 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
536
682
|
isDomainNumberCompound(variableName)) {
|
|
537
683
|
return false;
|
|
538
684
|
}
|
|
685
|
+
// A trailing "...Symbol" whose head noun names a thing WRITTEN with a
|
|
686
|
+
// glyph (currencySymbol, tickerSymbol, unitSymbol) is a domain
|
|
687
|
+
// compound: the suffix names WHAT the value is (the glyph OF a
|
|
688
|
+
// currency — CLDR/ISO 4217 vocabulary), and the value is a string, so
|
|
689
|
+
// there is no type to strip (#1835). Scoped to the full-word `Symbol`
|
|
690
|
+
// marker in SUFFIX position only, and vetoed when the declaration
|
|
691
|
+
// proves a real `symbol`, so idSymbol / cacheSymbol / symbolKey and
|
|
692
|
+
// any annotated `: symbol` keep firing.
|
|
693
|
+
if (normalizedMarker === 'symbol' &&
|
|
694
|
+
!isSymbolTyped &&
|
|
695
|
+
isDomainSymbolCompound(variableName)) {
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
539
698
|
return true;
|
|
540
699
|
}
|
|
541
700
|
// Full type-word markers (non-abbreviations: String, Number, Function,
|
|
@@ -611,7 +770,7 @@ exports.noHungarian = (0, createRule_1.createRule)({
|
|
|
611
770
|
if (isExternalOrBuiltIn(node))
|
|
612
771
|
return;
|
|
613
772
|
// Check for type markers
|
|
614
|
-
if (hasTypeMarker(name, isTypeName)) {
|
|
773
|
+
if (hasTypeMarker(name, isTypeName, isSymbolTypedDeclaration(node))) {
|
|
615
774
|
context.report({
|
|
616
775
|
node,
|
|
617
776
|
messageId: 'noHungarian',
|
|
@@ -100,6 +100,13 @@ function isExpressionBooleanLike(expr) {
|
|
|
100
100
|
return isExpressionBooleanLike(expr.expression);
|
|
101
101
|
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
102
102
|
return isExpressionBooleanLike(expr.expression);
|
|
103
|
+
// `a?.b` wraps the access in a ChainExpression, which only records that the
|
|
104
|
+
// access short-circuits — the value still comes from the node beneath, as
|
|
105
|
+
// with the assertion wrappers above (#1829). Nothing here is a carve-out for
|
|
106
|
+
// optional chains: `arr?.length` is `number | undefined`, a worse instance
|
|
107
|
+
// of the misleading prefix than the `number` that `arr.length` reports.
|
|
108
|
+
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
109
|
+
return isExpressionBooleanLike(expr.expression);
|
|
103
110
|
case utils_1.AST_NODE_TYPES.Literal:
|
|
104
111
|
return typeof expr.value === 'boolean' ? true : 'non';
|
|
105
112
|
case utils_1.AST_NODE_TYPES.TemplateLiteral:
|
|
@@ -36,6 +36,15 @@ const COMPLEX_EXPRESSION_TYPES = new Set([
|
|
|
36
36
|
utils_1.AST_NODE_TYPES.ArrayExpression,
|
|
37
37
|
utils_1.AST_NODE_TYPES.ObjectExpression,
|
|
38
38
|
]);
|
|
39
|
+
/**
|
|
40
|
+
* How far a member reaches. An unannotated member is `public`, which is why the
|
|
41
|
+
* ranks are compared rather than the raw modifiers.
|
|
42
|
+
*/
|
|
43
|
+
const VISIBILITY_RANK = {
|
|
44
|
+
private: 0,
|
|
45
|
+
protected: 1,
|
|
46
|
+
public: 2,
|
|
47
|
+
};
|
|
39
48
|
exports.noPassthroughGetters = (0, createRule_1.createRule)({
|
|
40
49
|
create(context) {
|
|
41
50
|
const sourceCode = context.sourceCode;
|
|
@@ -85,6 +94,11 @@ exports.noPassthroughGetters = (0, createRule_1.createRule)({
|
|
|
85
94
|
}
|
|
86
95
|
// Check if the return statement is accessing a property from a constructor parameter
|
|
87
96
|
if (isConstructorParameterPropertyAccess(returnStatement.argument)) {
|
|
97
|
+
// A getter that reaches further than the member it forwards is the
|
|
98
|
+
// only read path its audience has
|
|
99
|
+
if (widensVisibilityOfForwardedRoot(node, returnStatement.argument)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
88
102
|
const getterName = (0, getMethodName_1.getMethodName)(node, sourceCode, {
|
|
89
103
|
computedFallbackToText: false,
|
|
90
104
|
}) || 'getter';
|
|
@@ -102,6 +116,168 @@ exports.noPassthroughGetters = (0, createRule_1.createRule)({
|
|
|
102
116
|
}
|
|
103
117
|
},
|
|
104
118
|
};
|
|
119
|
+
/**
|
|
120
|
+
* Whether the getter is more visible than the member it forwards.
|
|
121
|
+
*
|
|
122
|
+
* The rule's remedy — read the constructor-injected object directly — is
|
|
123
|
+
* only expressible by callers that can see that object. When the getter
|
|
124
|
+
* reaches further than its root (`public get` over `private readonly
|
|
125
|
+
* props`, `protected get` over a base class's `private` field), the getter
|
|
126
|
+
* IS the encapsulation boundary rather than indirection over an accessible
|
|
127
|
+
* field, and no caller outside the root's audience has another read path.
|
|
128
|
+
* TypeScript flatly rejects `this.props.x` in a subclass of a class that
|
|
129
|
+
* declares `props` private, so for that audience the remedy does not exist.
|
|
130
|
+
*
|
|
131
|
+
* Equal visibility keeps reporting: a `private get` over a `private` field
|
|
132
|
+
* aliases state its only callers already reach, which is the indirection
|
|
133
|
+
* the rule targets.
|
|
134
|
+
*/
|
|
135
|
+
function widensVisibilityOfForwardedRoot(node, argument) {
|
|
136
|
+
const root = forwardedRootOf(argument);
|
|
137
|
+
if (!root) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
const rootAccessibility = accessibilityOfRoot(node, root);
|
|
141
|
+
if (!rootAccessibility) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const getterAccessibility = node.accessibility ?? 'public';
|
|
145
|
+
return (VISIBILITY_RANK[getterAccessibility] >
|
|
146
|
+
VISIBILITY_RANK[rootAccessibility]);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The member the getter ultimately reads off `this`, e.g. `props` for
|
|
150
|
+
* `this.props.metadata.ticker`. `#`-prefixed roots are reported separately
|
|
151
|
+
* because they carry no `accessibility` modifier while being maximally
|
|
152
|
+
* private.
|
|
153
|
+
*/
|
|
154
|
+
function forwardedRootOf(argument) {
|
|
155
|
+
let current = argument;
|
|
156
|
+
while (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
157
|
+
if (current.object.type === utils_1.AST_NODE_TYPES.ThisExpression) {
|
|
158
|
+
if (current.property.type === utils_1.AST_NODE_TYPES.PrivateIdentifier) {
|
|
159
|
+
return { name: current.property.name, isEcmaPrivate: true };
|
|
160
|
+
}
|
|
161
|
+
if (!current.computed && current.property.type === 'Identifier') {
|
|
162
|
+
return { name: current.property.name, isEcmaPrivate: false };
|
|
163
|
+
}
|
|
164
|
+
if (current.computed &&
|
|
165
|
+
current.property.type === 'Literal' &&
|
|
166
|
+
typeof current.property.value === 'string') {
|
|
167
|
+
return { name: current.property.value, isEcmaPrivate: false };
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
current = current.object;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
function accessibilityOfRoot(node, root) {
|
|
176
|
+
if (root.isEcmaPrivate) {
|
|
177
|
+
return 'private';
|
|
178
|
+
}
|
|
179
|
+
const declared = declaredAccessibilityIn(node.parent, root.name);
|
|
180
|
+
if (declared) {
|
|
181
|
+
return declared;
|
|
182
|
+
}
|
|
183
|
+
// A root declared by a base class is invisible to a syntactic lookup, and
|
|
184
|
+
// that is exactly where the widest gaps sit (a `public get` forwarding a
|
|
185
|
+
// base class's `protected` field).
|
|
186
|
+
return inheritedAccessibilityOf(node, root.name);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Accessibility of `name` as declared in the class body that owns `node`,
|
|
190
|
+
* covering constructor parameter properties, fields and accessors alike —
|
|
191
|
+
* a forwarded root is spelled any of the three in practice.
|
|
192
|
+
*/
|
|
193
|
+
function declaredAccessibilityIn(classBody, name) {
|
|
194
|
+
if (!classBody || classBody.type !== utils_1.AST_NODE_TYPES.ClassBody) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
for (const member of classBody.body) {
|
|
198
|
+
if (member.type === utils_1.AST_NODE_TYPES.PropertyDefinition ||
|
|
199
|
+
member.type === utils_1.AST_NODE_TYPES.TSAbstractPropertyDefinition) {
|
|
200
|
+
if (member.key.type === utils_1.AST_NODE_TYPES.PrivateIdentifier &&
|
|
201
|
+
member.key.name === name) {
|
|
202
|
+
return 'private';
|
|
203
|
+
}
|
|
204
|
+
if (!member.computed &&
|
|
205
|
+
member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
206
|
+
member.key.name === name) {
|
|
207
|
+
return member.accessibility ?? 'public';
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (member.type !== utils_1.AST_NODE_TYPES.MethodDefinition &&
|
|
212
|
+
member.type !== utils_1.AST_NODE_TYPES.TSAbstractMethodDefinition) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (member.kind === 'constructor') {
|
|
216
|
+
const parameterAccessibility = parameterPropertyAccessibility(member, name);
|
|
217
|
+
if (parameterAccessibility) {
|
|
218
|
+
return parameterAccessibility;
|
|
219
|
+
}
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (member.kind === 'get' &&
|
|
223
|
+
!member.computed &&
|
|
224
|
+
member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
225
|
+
member.key.name === name) {
|
|
226
|
+
return member.accessibility ?? 'public';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
function parameterPropertyAccessibility(constructorNode, name) {
|
|
232
|
+
for (const parameter of constructorNode.value.params) {
|
|
233
|
+
if (parameter.type !== utils_1.AST_NODE_TYPES.TSParameterProperty) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const { parameter: inner } = parameter;
|
|
237
|
+
const identifier = inner.type === utils_1.AST_NODE_TYPES.AssignmentPattern ? inner.left : inner;
|
|
238
|
+
if (identifier.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
239
|
+
identifier.name === name) {
|
|
240
|
+
// `constructor(readonly props: P)` declares a public member.
|
|
241
|
+
return parameter.accessibility ?? 'public';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
function inheritedAccessibilityOf(node, name) {
|
|
247
|
+
const parserServices = sourceCode.parserServices;
|
|
248
|
+
if (!parserServices ||
|
|
249
|
+
!parserServices.program ||
|
|
250
|
+
!parserServices.esTreeNodeToTSNodeMap) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node);
|
|
254
|
+
if (!tsNode ||
|
|
255
|
+
!tsNode.parent ||
|
|
256
|
+
!(ts.isClassDeclaration(tsNode.parent) ||
|
|
257
|
+
ts.isClassExpression(tsNode.parent))) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const checker = parserServices.program.getTypeChecker();
|
|
261
|
+
const classSymbol = checker.getTypeAtLocation(tsNode.parent).getSymbol();
|
|
262
|
+
if (!classSymbol) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const property = checker
|
|
266
|
+
.getDeclaredTypeOfSymbol(classSymbol)
|
|
267
|
+
.getProperty(name);
|
|
268
|
+
const declaration = property?.valueDeclaration ?? property?.declarations?.[0];
|
|
269
|
+
if (!declaration) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const flags = ts.getCombinedModifierFlags(declaration);
|
|
273
|
+
if (flags & ts.ModifierFlags.Private) {
|
|
274
|
+
return 'private';
|
|
275
|
+
}
|
|
276
|
+
if (flags & ts.ModifierFlags.Protected) {
|
|
277
|
+
return 'protected';
|
|
278
|
+
}
|
|
279
|
+
return 'public';
|
|
280
|
+
}
|
|
105
281
|
/**
|
|
106
282
|
* Check if the getter is required by an implemented interface or overrides a base class member
|
|
107
283
|
*/
|
|
@@ -31,6 +31,25 @@ function isUuidv4Base62Module(source) {
|
|
|
31
31
|
const basename = segments[segments.length - 1].replace(MODULE_EXTENSION, '');
|
|
32
32
|
return basename === UUIDV4_BASE62_MODULE;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Reads through an optional chain to the member access or call it holds.
|
|
36
|
+
* `items?.map(...)` and `item?.key` each parse as a `ChainExpression` wrapping
|
|
37
|
+
* the node the rule matches on, so a test for a bare `CallExpression` or
|
|
38
|
+
* `MemberExpression` sees the wrapper and recognizes nothing — the producer is
|
|
39
|
+
* never tracked and the key it feeds is never judged.
|
|
40
|
+
*
|
|
41
|
+
* Reading through it does not weaken the verdict, because the optional link is
|
|
42
|
+
* orthogonal to the defect. On the branch where the receiver is nullish nothing
|
|
43
|
+
* renders, or the key evaluates to `undefined` — which is a worse React key than
|
|
44
|
+
* a fresh UUID, never an acceptable one. On the branch where it is defined the
|
|
45
|
+
* emitted keys are exactly the ones the plain spelling emits: minted by
|
|
46
|
+
* `uuidv4Base62()` inside render, so React cannot reconcile the list. The rule
|
|
47
|
+
* only reports — it rewrites nothing — so the advice it gives ("use a stable
|
|
48
|
+
* identifier from your data") stands unchanged under the short-circuit.
|
|
49
|
+
*/
|
|
50
|
+
function unwrapOptionalChain(node) {
|
|
51
|
+
return node.type === utils_1.AST_NODE_TYPES.ChainExpression ? node.expression : node;
|
|
52
|
+
}
|
|
34
53
|
exports.noUuidv4Base62AsKey = (0, createRule_1.createRule)({
|
|
35
54
|
name: 'no-uuidv4-base62-as-key',
|
|
36
55
|
meta: {
|
|
@@ -96,9 +115,14 @@ exports.noUuidv4Base62AsKey = (0, createRule_1.createRule)({
|
|
|
96
115
|
return false;
|
|
97
116
|
}
|
|
98
117
|
// Helper to check if a function call contains uuidv4Base62() as an argument
|
|
99
|
-
function containsUuidV4Base62Call(
|
|
100
|
-
if (!
|
|
118
|
+
function containsUuidV4Base62Call(candidate) {
|
|
119
|
+
if (!candidate)
|
|
101
120
|
return false;
|
|
121
|
+
// An optional call still evaluates its arguments on the branch that runs,
|
|
122
|
+
// so `formatKey?.(uuidv4Base62())` mints the same per-render UUID as
|
|
123
|
+
// `formatKey(uuidv4Base62())`. Descending through the chain keeps the
|
|
124
|
+
// recursion from stopping short of the arguments.
|
|
125
|
+
const node = unwrapOptionalChain(candidate);
|
|
102
126
|
// Direct call
|
|
103
127
|
if (isUuidV4Base62Call(node))
|
|
104
128
|
return true;
|
|
@@ -146,7 +170,7 @@ exports.noUuidv4Base62AsKey = (0, createRule_1.createRule)({
|
|
|
146
170
|
attr.name.name === 'key' &&
|
|
147
171
|
attr.value &&
|
|
148
172
|
attr.value.type === utils_1.AST_NODE_TYPES.JSXExpressionContainer) {
|
|
149
|
-
const
|
|
173
|
+
const expression = unwrapOptionalChain(attr.value.expression);
|
|
150
174
|
// Direct uuidv4Base62() call in key
|
|
151
175
|
if (containsUuidV4Base62Call(expression)) {
|
|
152
176
|
reportViolation(jsxElement, expression);
|
|
@@ -204,13 +228,14 @@ exports.noUuidv4Base62AsKey = (0, createRule_1.createRule)({
|
|
|
204
228
|
function checkMapForUuidv4Base62Keys(node) {
|
|
205
229
|
if (!node.init)
|
|
206
230
|
return false;
|
|
231
|
+
const init = unwrapOptionalChain(node.init);
|
|
207
232
|
// Check for the pattern: items.map(item => ({ ...item, key: uuidv4Base62() }))
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
233
|
+
if (init.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
234
|
+
init.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
235
|
+
init.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
236
|
+
init.callee.property.name === 'map') {
|
|
212
237
|
// The first argument should be the map callback
|
|
213
|
-
const callback =
|
|
238
|
+
const callback = init.arguments[0];
|
|
214
239
|
if (!callback)
|
|
215
240
|
return false;
|
|
216
241
|
// Handle arrow functions and regular functions
|
|
@@ -293,12 +318,12 @@ exports.noUuidv4Base62AsKey = (0, createRule_1.createRule)({
|
|
|
293
318
|
for (const declarator of node.declarations) {
|
|
294
319
|
if (declarator.id.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
295
320
|
declarator.id.name === 'itemKeys') {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
321
|
+
const init = declarator.init && unwrapOptionalChain(declarator.init);
|
|
322
|
+
if (init &&
|
|
323
|
+
init.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
324
|
+
init.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
325
|
+
init.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
326
|
+
init.callee.property.name === 'map') {
|
|
302
327
|
// The test case pattern detected
|
|
303
328
|
variablesWithUuidv4Base62Keys.add('itemKeys');
|
|
304
329
|
}
|
|
@@ -356,18 +381,19 @@ exports.noUuidv4Base62AsKey = (0, createRule_1.createRule)({
|
|
|
356
381
|
if (returnExpr && returnExpr.type === utils_1.AST_NODE_TYPES.JSXElement) {
|
|
357
382
|
const attributes = returnExpr.openingElement.attributes;
|
|
358
383
|
for (const attr of attributes) {
|
|
359
|
-
if (attr.type
|
|
360
|
-
attr.name.type
|
|
361
|
-
attr.name.name
|
|
362
|
-
attr.value
|
|
363
|
-
attr.value.type
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
384
|
+
if (attr.type !== utils_1.AST_NODE_TYPES.JSXAttribute ||
|
|
385
|
+
attr.name.type !== utils_1.AST_NODE_TYPES.JSXIdentifier ||
|
|
386
|
+
attr.name.name !== 'key' ||
|
|
387
|
+
!attr.value ||
|
|
388
|
+
attr.value.type !== utils_1.AST_NODE_TYPES.JSXExpressionContainer) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const keyExpression = unwrapOptionalChain(attr.value.expression);
|
|
392
|
+
if (keyExpression.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
393
|
+
keyExpression.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
394
|
+
keyExpression.property.name === 'key') {
|
|
369
395
|
// The test case - directly report this element
|
|
370
|
-
reportViolation(returnExpr,
|
|
396
|
+
reportViolation(returnExpr, keyExpression);
|
|
371
397
|
break;
|
|
372
398
|
}
|
|
373
399
|
}
|
|
@@ -53,6 +53,39 @@ function buildQueryKeysSpecifier(sourceFilePath, cwd) {
|
|
|
53
53
|
}
|
|
54
54
|
return ensureRelativeSpecifier(toPosixPath(relativePath));
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The expression a key ultimately comes from, read through the wrappers that
|
|
58
|
+
* restate it without relocating it.
|
|
59
|
+
*
|
|
60
|
+
* A chain records that the access short-circuits and an assertion restates a
|
|
61
|
+
* type; neither changes WHERE the key comes from, which is the only question
|
|
62
|
+
* the report-site dispatch asks. That dispatch enumerates the node types it
|
|
63
|
+
* knows and stays silent on everything else, so a key passed directly as
|
|
64
|
+
* `config?.queryKey` or `config.queryKey as string` matched no arm and the
|
|
65
|
+
* unapproved source went unreported (#1836) — the silent mirror of the
|
|
66
|
+
* over-reporting #1833 removed from the key-source classifier.
|
|
67
|
+
*
|
|
68
|
+
* A `ConditionalExpression` is deliberately NOT resolved here: it holds two
|
|
69
|
+
* branches and therefore no single source, so there is nothing for this to
|
|
70
|
+
* return. Its literal-bearing shapes already report through
|
|
71
|
+
* `containsInvalidStringLiteral`, which judges the whole expression; the
|
|
72
|
+
* branch-by-branch verdict a ternary of unapproved sources would need is a
|
|
73
|
+
* separate policy, and one that would have to answer for the per-source
|
|
74
|
+
* parameter carve-out (#1394) that only the identifier arm applies. The same
|
|
75
|
+
* holds for a logical expression's operands.
|
|
76
|
+
*/
|
|
77
|
+
function unwrapTransparentKeySource(node) {
|
|
78
|
+
switch (node.type) {
|
|
79
|
+
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
80
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
81
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
82
|
+
case utils_1.AST_NODE_TYPES.TSTypeAssertion:
|
|
83
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
84
|
+
return unwrapTransparentKeySource(node.expression);
|
|
85
|
+
default:
|
|
86
|
+
return node;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
56
89
|
/**
|
|
57
90
|
* Rule to enforce the use of centralized router state key constants imported from
|
|
58
91
|
* `src/util/routing/queryKeys.ts` instead of arbitrary string literals when calling
|
|
@@ -203,6 +236,20 @@ exports.preferGlobalRouterStateKey = (0, createRule_1.createRule)({
|
|
|
203
236
|
* Check if a node represents a valid query key usage
|
|
204
237
|
*/
|
|
205
238
|
function isValidQueryKeyUsage(node) {
|
|
239
|
+
// `config?.getQueryKey()` parses as a `ChainExpression` wrapping the call,
|
|
240
|
+
// a type this dispatch does not name — so the optional spelling alone fell
|
|
241
|
+
// past every arm below and reported a key source the plain spelling is
|
|
242
|
+
// allowed to build (#1833). Optionality is orthogonal to the question
|
|
243
|
+
// asked here: a short-circuit changes only whether the same source is
|
|
244
|
+
// evaluated, never which source it is. Resolving to the node underneath
|
|
245
|
+
// rather than accepting the wrapper keeps an unapproved source reported
|
|
246
|
+
// through the chain, which is what stops this from becoming a blanket
|
|
247
|
+
// escape hatch. The sibling `enforce-querykey-ts` carries the same arm
|
|
248
|
+
// (#1832), and both rules ship as `error`, so a source one blesses must
|
|
249
|
+
// not be the other's violation (#1714).
|
|
250
|
+
if (node.type === utils_1.AST_NODE_TYPES.ChainExpression) {
|
|
251
|
+
return isValidQueryKeyUsage(node.expression);
|
|
252
|
+
}
|
|
206
253
|
if (node.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
207
254
|
// Check direct imports
|
|
208
255
|
const importInfo = queryKeyImports.get(node.name);
|
|
@@ -421,8 +468,15 @@ exports.preferGlobalRouterStateKey = (0, createRule_1.createRule)({
|
|
|
421
468
|
prop.key.name === 'key');
|
|
422
469
|
if (keyProperty && keyProperty.value) {
|
|
423
470
|
const keyValue = keyProperty.value;
|
|
424
|
-
|
|
425
|
-
|
|
471
|
+
// Every question below is about the source, so each is asked of
|
|
472
|
+
// the source rather than of the notation wrapping it. The
|
|
473
|
+
// report still names `keyValue`: the whole key expression is
|
|
474
|
+
// what an author replaces with the constant, and naming only
|
|
475
|
+
// the part underneath an `as const` would prescribe an edit
|
|
476
|
+
// that does not compile (TS1355).
|
|
477
|
+
const keySource = unwrapTransparentKeySource(keyValue);
|
|
478
|
+
if (!isValidQueryKeyUsage(keySource)) {
|
|
479
|
+
if (containsInvalidStringLiteral(keySource)) {
|
|
426
480
|
context.report({
|
|
427
481
|
node: keyValue,
|
|
428
482
|
messageId: 'preferGlobalRouterStateKey',
|
|
@@ -430,7 +484,14 @@ exports.preferGlobalRouterStateKey = (0, createRule_1.createRule)({
|
|
|
430
484
|
keyValue: sourceCode.getText(keyValue),
|
|
431
485
|
},
|
|
432
486
|
fix(fixer) {
|
|
433
|
-
// Only a statically known key value can be auto-fixed
|
|
487
|
+
// Only a statically known key value can be auto-fixed,
|
|
488
|
+
// and the value read is the one the key is WRITTEN as,
|
|
489
|
+
// wrapper included. A wrapper carries no static value,
|
|
490
|
+
// so a wrapped literal reports with no fix behind it:
|
|
491
|
+
// substituting the constant underneath the wrapper
|
|
492
|
+
// would leave `QUERY_KEY_X as const`, which TypeScript
|
|
493
|
+
// rejects outright (TS1355), and there is no rewrite
|
|
494
|
+
// that both keeps the assertion and names a constant.
|
|
434
495
|
const staticKey = staticKeyOf(keyValue);
|
|
435
496
|
if (staticKey) {
|
|
436
497
|
const suggestedConstant = generateAutoFix(staticKey.text);
|
|
@@ -573,17 +634,14 @@ exports.preferGlobalRouterStateKey = (0, createRule_1.createRule)({
|
|
|
573
634
|
},
|
|
574
635
|
});
|
|
575
636
|
}
|
|
576
|
-
else if (
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
else if (keyValue.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
637
|
+
else if (
|
|
638
|
+
// A key with no literal in it reports only where it names
|
|
639
|
+
// one source the author can swap out. A parameter is not
|
|
640
|
+
// one: it holds a different value on every call, and the
|
|
641
|
+
// caller — not this file — chooses it (#1394).
|
|
642
|
+
(keySource.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
643
|
+
!isParameterBinding(keySource)) ||
|
|
644
|
+
keySource.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
587
645
|
context.report({
|
|
588
646
|
node: keyValue,
|
|
589
647
|
messageId: 'invalidQueryKeySource',
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,81 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.129",
|
|
4
|
+
"date": "2026-08-07T06:47:26.731Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "no-circular-references",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1838
|
|
11
|
+
],
|
|
12
|
+
"summary": "resolve an alias through an optional chain (closes #1838)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.128",
|
|
18
|
+
"date": "2026-08-07T05:47:06.312Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "enforce-assert-safe-object-key",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
1830
|
|
25
|
+
],
|
|
26
|
+
"summary": "read a written key through an optional chain (closes #1830)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "enforce-querykey-ts",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
1832
|
|
33
|
+
],
|
|
34
|
+
"summary": "resolve a key source through an optional chain (closes #1832)"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "no-hungarian",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
1835
|
|
41
|
+
],
|
|
42
|
+
"summary": "treat Symbol as a domain glyph noun in suffix position (closes #1835)"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "no-misleading-boolean-prefixes",
|
|
46
|
+
"changeType": "fix",
|
|
47
|
+
"issues": [
|
|
48
|
+
1829
|
|
49
|
+
],
|
|
50
|
+
"summary": "classify a boolean-like expression through an optional chain (closes #1829)"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "no-passthrough-getters",
|
|
54
|
+
"changeType": "fix",
|
|
55
|
+
"issues": [
|
|
56
|
+
1834
|
|
57
|
+
],
|
|
58
|
+
"summary": "exempt a getter that widens visibility over its root (closes #1834)"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "no-uuidv4-base62-as-key",
|
|
62
|
+
"changeType": "fix",
|
|
63
|
+
"issues": [
|
|
64
|
+
1831
|
|
65
|
+
],
|
|
66
|
+
"summary": "see the key expression through an optional-chained receiver (closes #1831)"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "prefer-global-router-state-key",
|
|
70
|
+
"changeType": "fix",
|
|
71
|
+
"issues": [
|
|
72
|
+
1833,
|
|
73
|
+
1836
|
|
74
|
+
],
|
|
75
|
+
"summary": "resolve a directly-passed key through transparent wrappers (closes #1836); resolve a key source through an optional chain (closes #1833)"
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
},
|
|
2
79
|
{
|
|
3
80
|
"version": "1.20.127",
|
|
4
81
|
"date": "2026-08-07T02:31:31.437Z",
|