@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 CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.127',
226
+ version: '1.20.129',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -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
- const init = def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator
567
- ? def.node.init
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
- const target = unwrapKeyExpression(node);
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 = unwrapKeyExpression(written);
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 = unwrapKeyExpression(written);
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 names the property, so that is
716
- // what the branches below classify.
717
- const property = unwrapKeyExpression(written);
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 = ASTHelpers_1.ASTHelpers.unwrapTSAssertions(node);
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
- const unwrappedValue = getUnwrappedObjectOrArray(propValue);
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(propValue) ||
136
- propValue.type === utils_1.AST_NODE_TYPES.MemberExpression) {
137
- return getReferencedObject(propValue, visitedVariables, visitedNodes);
162
+ if (isIdentifier(resolvedValue) ||
163
+ resolvedValue.type === utils_1.AST_NODE_TYPES.MemberExpression) {
164
+ return getReferencedObject(resolvedValue, visitedVariables, visitedNodes);
138
165
  }
139
- if (isFunction(propValue) || isPrimitive(propValue)) {
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
- function hasTypeMarker(variableName, isTypeName = false) {
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(node) {
100
- if (!node)
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 { expression } = attr.value;
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 (node.init.type === utils_1.AST_NODE_TYPES.CallExpression &&
209
- node.init.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
210
- node.init.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
211
- node.init.callee.property.name === 'map') {
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 = node.init.arguments[0];
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
- if (declarator.init &&
297
- declarator.init.type === utils_1.AST_NODE_TYPES.CallExpression &&
298
- declarator.init.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
299
- declarator.init.callee.property.type ===
300
- utils_1.AST_NODE_TYPES.Identifier &&
301
- declarator.init.callee.property.name === 'map') {
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 === utils_1.AST_NODE_TYPES.JSXAttribute &&
360
- attr.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier &&
361
- attr.name.name === 'key' &&
362
- attr.value &&
363
- attr.value.type === utils_1.AST_NODE_TYPES.JSXExpressionContainer &&
364
- attr.value.expression.type ===
365
- utils_1.AST_NODE_TYPES.MemberExpression &&
366
- attr.value.expression.property.type ===
367
- utils_1.AST_NODE_TYPES.Identifier &&
368
- attr.value.expression.property.name === 'key') {
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, attr.value.expression);
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
- if (!isValidQueryKeyUsage(keyValue)) {
425
- if (containsInvalidStringLiteral(keyValue)) {
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 (keyValue.type === utils_1.AST_NODE_TYPES.Identifier &&
577
- !isParameterBinding(keyValue)) {
578
- context.report({
579
- node: keyValue,
580
- messageId: 'invalidQueryKeySource',
581
- data: {
582
- variableName: keyValue.name,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.127",
3
+ "version": "1.20.129",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -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",