@blumintinc/eslint-plugin-blumint 1.21.0 → 1.21.1

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/README.md CHANGED
@@ -241,7 +241,7 @@ full closed loop is documented in agora's `.claude/skills/eslint-autonomy/SKILL.
241
241
  | [prefer-nullish-coalescing-boolean-props](docs/rules/prefer-nullish-coalescing-boolean-props.md) | Prefer nullish coalescing over logical OR, but allow logical OR in boolean contexts | ✅ | | 🔧 | | |
242
242
  | [prefer-params-over-parent-id](docs/rules/prefer-params-over-parent-id.md) | Prefer event.params over ref.parent.id for type-safe Firebase trigger paths. | ✅ | | 🔧 | | |
243
243
  | [prefer-settings-object](docs/rules/prefer-settings-object.md) | Enforce using a settings object for functions with multiple parameters | ✅ | | | | |
244
- | [prefer-spread-over-reassembly](docs/rules/prefer-spread-over-reassembly.md) | Prefer spread syntax over destructure-then-reassemble when all destructured fields are forwarded identically to a single target | ✅ | | 🔧 | | |
244
+ | [prefer-spread-over-reassembly](docs/rules/prefer-spread-over-reassembly.md) | Prefer spread syntax over destructure-then-reassemble when all destructured fields are forwarded identically to a single target | ✅ | | 🔧 | 💡 | |
245
245
  | [prefer-sx-prop-over-system-props](docs/rules/prefer-sx-prop-over-system-props.md) | Enforce using the MUI `sx` prop instead of deprecated system props (e.g. `mt`, `display`, `flexDirection`) on MUI components. | ✅ | | 🔧 | | |
246
246
  | [prefer-type-alias-over-typeof-constant](docs/rules/prefer-type-alias-over-typeof-constant.md) | Prefer named type aliases over `typeof` on same-file global constants; ensure types are declared before constants. | ✅ | | | | |
247
247
  | [prefer-type-over-interface](docs/rules/prefer-type-over-interface.md) | Prefer using type alias over interface | ✅ | | 🔧 | | |
package/lib/index.js CHANGED
@@ -224,7 +224,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
224
224
  module.exports = {
225
225
  meta: {
226
226
  name: '@blumintinc/eslint-plugin-blumint',
227
- version: '1.21.0',
227
+ version: '1.21.1',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -1,6 +1,7 @@
1
1
  import { TSESLint } from '@typescript-eslint/utils';
2
+ type MessageIds = 'preferSpread' | 'applySpread';
2
3
  type Options = [{
3
4
  minFields?: number;
4
5
  }];
5
- export declare const preferSpreadOverReassembly: TSESLint.RuleModule<"preferSpread", Options, TSESLint.RuleListener>;
6
+ export declare const preferSpreadOverReassembly: TSESLint.RuleModule<MessageIds, Options, TSESLint.RuleListener>;
6
7
  export {};
@@ -433,8 +433,8 @@ function findRelativeTypeImport(program, localName) {
433
433
  return null;
434
434
  }
435
435
  /**
436
- * Enumerates every property name a type node declares, or null when the member
437
- * list cannot be established with certainty.
436
+ * Resolves a type node to the member list it writes down, or null when the
437
+ * member set cannot be established with certainty.
438
438
  *
439
439
  * Only an unambiguous, fully written-out member list qualifies, reached either
440
440
  * directly or through the key-preserving operators in
@@ -446,9 +446,9 @@ function findRelativeTypeImport(program, localName) {
446
446
  * A name the file does not declare is looked up in the relative module that
447
447
  * imports it, once; see {@link importedTypeMemberNames}.
448
448
  */
449
- function memberNamesOf(typeNode, scope, seen = new Set()) {
449
+ function resolveTypeMembers(typeNode, scope, seen = new Set()) {
450
450
  if (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeLiteral) {
451
- return namesOfMembers(typeNode.members);
451
+ return { kind: 'members', members: typeNode.members };
452
452
  }
453
453
  if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
454
454
  typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier) {
@@ -463,13 +463,14 @@ function memberNamesOf(typeNode, scope, seen = new Set()) {
463
463
  scope.isLocallyBound(name)) {
464
464
  return null;
465
465
  }
466
- return memberNamesOf(typeNode.typeParameters.params[0], scope, seen);
466
+ return resolveTypeMembers(typeNode.typeParameters.params[0], scope, seen);
467
467
  }
468
468
  const declaration = findLocalTypeDeclaration(scope.program, typeNode, name);
469
469
  if (!declaration) {
470
470
  // The file does not declare the name, so the only remaining source of a
471
471
  // written-down member list is the module it comes from.
472
- return scope.resolveImportedMembers?.(name) ?? null;
472
+ const names = scope.resolveImportedMembers?.(name);
473
+ return names ? { kind: 'names', names } : null;
473
474
  }
474
475
  // A self-referential alias (`type T = T`) would otherwise recur forever. The
475
476
  // guard is keyed on the DECLARATION rather than the name: one name denotes
@@ -479,24 +480,166 @@ function memberNamesOf(typeNode, scope, seen = new Set()) {
479
480
  return null;
480
481
  }
481
482
  seen.add(declaration);
482
- return memberNamesOfDeclaration(declaration, scope, seen);
483
+ return resolveTypeMembersOfDeclaration(declaration, scope, seen);
483
484
  }
484
485
  /**
485
- * The member names a type alias or interface declares. A generic declaration
486
+ * The members a type alias or interface declares. A generic declaration
486
487
  * describes a different member set per instantiation and an interface with an
487
488
  * `extends` clause inherits members written elsewhere, so neither enumerates.
488
489
  */
489
- function memberNamesOfDeclaration(declaration, scope, seen) {
490
+ function resolveTypeMembersOfDeclaration(declaration, scope, seen) {
490
491
  if (declaration.typeParameters) {
491
492
  return null;
492
493
  }
493
494
  if (declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration) {
494
- return memberNamesOf(declaration.typeAnnotation, scope, seen);
495
+ return resolveTypeMembers(declaration.typeAnnotation, scope, seen);
495
496
  }
496
497
  if (declaration.extends && declaration.extends.length > 0) {
497
498
  return null;
498
499
  }
499
- return namesOfMembers(declaration.body.body);
500
+ return { kind: 'members', members: declaration.body.body };
501
+ }
502
+ /**
503
+ * Enumerates every property name a type node declares, or null when the member
504
+ * list cannot be established with certainty.
505
+ */
506
+ function memberNamesOf(typeNode, scope, seen = new Set()) {
507
+ const resolved = resolveTypeMembers(typeNode, scope, seen);
508
+ if (!resolved) {
509
+ return null;
510
+ }
511
+ return resolved.kind === 'names'
512
+ ? resolved.names
513
+ : namesOfMembers(resolved.members);
514
+ }
515
+ /**
516
+ * The type a chain of non-generic local aliases finally spells, or the node
517
+ * itself when the chain cannot be followed.
518
+ *
519
+ * A catalog names its element and callback shapes through aliases rather than
520
+ * writing them inline, so a reader that only recognises the literal spelling
521
+ * loses the proof on the layout it exists to serve.
522
+ */
523
+ function unwrapLocalTypeAlias(typeNode, scope, seen = new Set()) {
524
+ if (typeNode.type !== utils_1.AST_NODE_TYPES.TSTypeReference ||
525
+ typeNode.typeName.type !== utils_1.AST_NODE_TYPES.Identifier ||
526
+ typeNode.typeParameters) {
527
+ return typeNode;
528
+ }
529
+ const declaration = findLocalTypeDeclaration(scope.program, typeNode, typeNode.typeName.name);
530
+ if (!declaration ||
531
+ declaration.type !== utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
532
+ declaration.typeParameters ||
533
+ seen.has(declaration)) {
534
+ return typeNode;
535
+ }
536
+ seen.add(declaration);
537
+ return unwrapLocalTypeAlias(declaration.typeAnnotation, scope, seen);
538
+ }
539
+ /**
540
+ * The declared type of one named member of a type, or null when the type does
541
+ * not write that member down exactly once.
542
+ *
543
+ * A name carried twice is an overload set or a declaration merge, whose
544
+ * effective signature is not the one written at either site, so it proves
545
+ * nothing about the parameter a callback receives.
546
+ */
547
+ function memberTypeNodeOf(typeNode, scope, memberName) {
548
+ const resolved = resolveTypeMembers(typeNode, scope);
549
+ if (!resolved || resolved.kind !== 'members') {
550
+ return null;
551
+ }
552
+ let matches = 0;
553
+ let annotation = null;
554
+ for (const member of resolved.members) {
555
+ if (member.type !== utils_1.AST_NODE_TYPES.TSPropertySignature || member.computed) {
556
+ continue;
557
+ }
558
+ const key = member.key;
559
+ const name = key.type === utils_1.AST_NODE_TYPES.Identifier
560
+ ? key.name
561
+ : key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string'
562
+ ? key.value
563
+ : null;
564
+ if (name !== memberName) {
565
+ continue;
566
+ }
567
+ matches += 1;
568
+ annotation = member.typeAnnotation?.typeAnnotation ?? null;
569
+ }
570
+ return matches === 1 ? annotation : null;
571
+ }
572
+ /**
573
+ * The declared type of the sole parameter of a function type, or null when the
574
+ * type is not a one-parameter signature that writes its parameter's type down.
575
+ *
576
+ * More than one parameter means the callback under lint is not the one this
577
+ * signature describes, and a rest parameter names no fixed member set at all.
578
+ */
579
+ function soleParameterTypeOf(typeNode, scope) {
580
+ const signature = unwrapLocalTypeAlias(typeNode, scope);
581
+ if (signature.type !== utils_1.AST_NODE_TYPES.TSFunctionType ||
582
+ signature.params.length !== 1) {
583
+ return null;
584
+ }
585
+ const param = signature.params[0];
586
+ if (param.type === utils_1.AST_NODE_TYPES.RestElement ||
587
+ param.type === utils_1.AST_NODE_TYPES.TSParameterProperty) {
588
+ return null;
589
+ }
590
+ return param.typeAnnotation?.typeAnnotation ?? null;
591
+ }
592
+ /**
593
+ * The type node an expression is declared to have by the binding or assertion
594
+ * it is written into.
595
+ *
596
+ * A codebase that centralises its shapes states them once at the binding —
597
+ * `const CASES: readonly CaseEntry[] = [...]` — and leaves every callback
598
+ * inside to be contextually typed by it, so a reader that only consults
599
+ * parameter annotations never sees the type that is actually written (#2298).
600
+ */
601
+ function declaredTypeOfInitializer(node) {
602
+ const parent = node.parent;
603
+ if (!parent) {
604
+ return null;
605
+ }
606
+ if ((parent.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
607
+ parent.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression) &&
608
+ parent.expression === node) {
609
+ // `as const` states immutability rather than a shape, and it is written
610
+ // BELOW the binding's annotation, so the walk continues past it.
611
+ if (isConstAssertionType(parent.typeAnnotation)) {
612
+ return declaredTypeOfInitializer(parent);
613
+ }
614
+ return parent.typeAnnotation;
615
+ }
616
+ if (parent.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
617
+ parent.init === node &&
618
+ parent.id.type === utils_1.AST_NODE_TYPES.Identifier &&
619
+ parent.id.typeAnnotation) {
620
+ return parent.id.typeAnnotation.typeAnnotation;
621
+ }
622
+ return null;
623
+ }
624
+ function isConstAssertionType(typeNode) {
625
+ return (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
626
+ typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
627
+ typeNode.typeName.name === 'const');
628
+ }
629
+ /**
630
+ * The declared type of the entry an object literal spells, looking through the
631
+ * array literal a catalog wraps its entries in.
632
+ */
633
+ function declaredEntryTypeOf(literal, scope) {
634
+ const array = literal.parent;
635
+ if (array?.type === utils_1.AST_NODE_TYPES.ArrayExpression &&
636
+ array.elements.includes(literal)) {
637
+ const declared = declaredTypeOfInitializer(array);
638
+ return declared
639
+ ? arrayElementTypeOf(unwrapLocalTypeAlias(declared, scope))
640
+ : null;
641
+ }
642
+ return declaredTypeOfInitializer(literal);
500
643
  }
501
644
  /**
502
645
  * The member names of a type the file under lint imports from a relative
@@ -555,7 +698,15 @@ function readExportedTypeMembers(filePath, exported) {
555
698
  return siblingBoundNames.has(name);
556
699
  },
557
700
  };
558
- return memberNamesOfDeclaration(declaration, siblingScope, new Set([declaration]));
701
+ const resolved = resolveTypeMembersOfDeclaration(declaration, siblingScope, new Set([declaration]));
702
+ if (!resolved) {
703
+ return null;
704
+ }
705
+ // The sibling's scope carries no import resolver, so the walk inside it can
706
+ // only ever end at member nodes the sibling itself writes down.
707
+ return resolved.kind === 'names'
708
+ ? resolved.names
709
+ : namesOfMembers(resolved.members);
559
710
  }
560
711
  /**
561
712
  * For a JSX element, returns the set of destructured names that are forwarded
@@ -958,6 +1109,7 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
958
1109
  recommended: 'error',
959
1110
  },
960
1111
  fixable: 'code',
1112
+ hasSuggestions: true,
961
1113
  schema: [
962
1114
  {
963
1115
  type: 'object',
@@ -973,6 +1125,8 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
973
1125
  messages: {
974
1126
  preferSpread: 'Prefer spread over destructure-then-reassemble: replace the destructured parameter with a single identifier and use spread syntax on the target. ' +
975
1127
  'This avoids silent bugs when new fields are added to the type.',
1128
+ applySpread: 'Replace the destructured parameter with a single identifier and spread it onto the target. ' +
1129
+ 'Check first that the parameter carries no members beyond the ones destructured, since the spread forwards every member it has.',
976
1130
  },
977
1131
  },
978
1132
  defaultOptions: [{ minFields: DEFAULT_MIN_FIELDS }],
@@ -1137,29 +1291,83 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
1137
1291
  return elementType ? memberNamesOf(elementType, typeScope) : null;
1138
1292
  }
1139
1293
  /**
1140
- * Reports whether the destructured pick is provably a PROPER subset of the
1141
- * source object's own type, in which case spreading the parameter would add
1142
- * the members the author left out and change what the function produces
1143
- * (#1642: a GitHub review payload gained unknown keys).
1294
+ * The member names of the parameter type a typed binding gives the
1295
+ * function through the object literal it is a property of.
1296
+ *
1297
+ * A catalog states its entries' shape once, on the binding
1298
+ * (`const CASES: readonly CaseEntry[] = [...]`), and leaves every callback
1299
+ * inside to be contextually typed by it. Such a parameter narrows exactly
1300
+ * as an annotated one does, so a reader that stops at annotations rewrites
1301
+ * a deliberate pick into a widening spread (#2298).
1302
+ */
1303
+ function contextualBindingMemberNames(fn) {
1304
+ const property = fn.parent;
1305
+ if (!property ||
1306
+ property.type !== utils_1.AST_NODE_TYPES.Property ||
1307
+ property.value !== fn ||
1308
+ property.computed) {
1309
+ return null;
1310
+ }
1311
+ const key = property.key;
1312
+ const propertyName = key.type === utils_1.AST_NODE_TYPES.Identifier
1313
+ ? key.name
1314
+ : key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string'
1315
+ ? key.value
1316
+ : null;
1317
+ if (propertyName === null) {
1318
+ return null;
1319
+ }
1320
+ const literal = property.parent;
1321
+ if (!literal || literal.type !== utils_1.AST_NODE_TYPES.ObjectExpression) {
1322
+ return null;
1323
+ }
1324
+ const entryType = declaredEntryTypeOf(literal, typeScope);
1325
+ if (!entryType) {
1326
+ return null;
1327
+ }
1328
+ const memberType = memberTypeNodeOf(entryType, typeScope, propertyName);
1329
+ if (!memberType) {
1330
+ return null;
1331
+ }
1332
+ const parameterType = soleParameterTypeOf(memberType, typeScope);
1333
+ return parameterType ? memberNamesOf(parameterType, typeScope) : null;
1334
+ }
1335
+ /**
1336
+ * How much the source object's own type proves about the destructured pick.
1144
1337
  *
1145
- * The proof runs in the safe direction only. A member set that matches the
1146
- * pick exactly is exhaustive, so the rewrite is behavior-preserving and the
1147
- * rule still reports; a type it cannot resolve — a union, an index
1148
- * signature, an instantiation of anything but a key-preserving operator, or
1149
- * an import whose module it declines to follow — yields no proof and the
1150
- * rule likewise still reports. Silence is reserved for the case where the
1151
- * widening is demonstrated.
1338
+ * `narrowing` the pick is a PROPER subset of a resolved member set, so
1339
+ * spreading the parameter would reinstate the members the author left out
1340
+ * and change what the function produces (#1642: a GitHub review payload
1341
+ * gained unknown keys). The rule stays silent.
1342
+ *
1343
+ * `exhaustive` the resolved member set is exactly the pick, so the
1344
+ * rewrite is demonstrably behavior-preserving and is applied.
1345
+ *
1346
+ * `unproven` — no member set resolves, or one resolves that does not even
1347
+ * contain the pick. A union, an index signature, an instantiation of
1348
+ * anything but a key-preserving operator, a `Parameters<>` indirection or
1349
+ * an import whose module the walk declines to follow all land here. The
1350
+ * finding still stands, but nothing about the rewrite's safety follows, so
1351
+ * it is offered rather than applied.
1152
1352
  */
1153
- function isProvablyNarrowingPick(fn, param, destructuredNames) {
1353
+ function classifyPick(fn, param, destructuredNames) {
1154
1354
  // An explicit annotation overrides whatever the call site would imply,
1155
- // so the contextual route is consulted only in its absence.
1355
+ // so the contextual routes are consulted only in its absence.
1156
1356
  const memberNames = param.typeAnnotation
1157
1357
  ? memberNamesOf(param.typeAnnotation.typeAnnotation, typeScope)
1158
- : contextualElementMemberNames(fn);
1159
- if (!memberNames || memberNames.size <= destructuredNames.length) {
1160
- return false;
1358
+ : contextualElementMemberNames(fn) ?? contextualBindingMemberNames(fn);
1359
+ if (!memberNames) {
1360
+ return 'unproven';
1361
+ }
1362
+ // A pick naming something the resolved type does not declare means the
1363
+ // resolution describes a different shape than the one destructured, so
1364
+ // its size says nothing about what a spread would forward.
1365
+ if (!destructuredNames.every((name) => memberNames.has(name))) {
1366
+ return 'unproven';
1161
1367
  }
1162
- return destructuredNames.every((name) => memberNames.has(name));
1368
+ return memberNames.size > destructuredNames.length
1369
+ ? 'narrowing'
1370
+ : 'exhaustive';
1163
1371
  }
1164
1372
  function checkFunction(fn) {
1165
1373
  // Must have exactly one parameter that is an ObjectPattern.
@@ -1218,37 +1426,48 @@ exports.preferSpreadOverReassembly = (0, createRule_1.createRule)({
1218
1426
  }
1219
1427
  // The pick may exist precisely because the omitted members must not flow
1220
1428
  // through; spreading would reinstate them.
1221
- if (isProvablyNarrowingPick(fn, param, destructuredNames)) {
1429
+ const classification = classifyPick(fn, param, destructuredNames);
1430
+ if (classification === 'narrowing') {
1222
1431
  return;
1223
1432
  }
1433
+ const applySpread = (fixer) => {
1434
+ const fixes = [];
1435
+ // Choose a fresh name for the props parameter that does not collide
1436
+ // with any binding currently in scope.
1437
+ const propsName = freshPropsName(namesSet);
1438
+ // 1. Replace the destructured parameter with `props` (or fresh name),
1439
+ // keeping any type annotation intact.
1440
+ const paramFix = replacePatternWithName(fixer, sourceCode, param, propsName);
1441
+ if (!paramFix) {
1442
+ return null;
1443
+ }
1444
+ fixes.push(paramFix);
1445
+ // 2. Collapse the forwarded fields into a single spread. Only the
1446
+ // ranges of the fields being collapsed are spliced out; the retained
1447
+ // ones — and the comments attached to them, which may be
1448
+ // `eslint-disable` directives — are left exactly as authored.
1449
+ const targetFix = target.kind === 'jsx'
1450
+ ? buildJsxSpreadFix(fixer, sourceCode, target.openingElement, forwardedNodes, propsName)
1451
+ : buildObjectSpreadFix(fixer, sourceCode, target.expression, forwardedNodes, propsName);
1452
+ if (!targetFix) {
1453
+ return null;
1454
+ }
1455
+ fixes.push(targetFix);
1456
+ return fixes;
1457
+ };
1224
1458
  context.report({
1225
1459
  node: param,
1226
1460
  messageId: 'preferSpread',
1227
- fix(fixer) {
1228
- const fixes = [];
1229
- // Choose a fresh name for the props parameter that does not collide
1230
- // with any binding currently in scope.
1231
- const propsName = freshPropsName(namesSet);
1232
- // 1. Replace the destructured parameter with `props` (or fresh name),
1233
- // keeping any type annotation intact.
1234
- const paramFix = replacePatternWithName(fixer, sourceCode, param, propsName);
1235
- if (!paramFix) {
1236
- return null;
1237
- }
1238
- fixes.push(paramFix);
1239
- // 2. Collapse the forwarded fields into a single spread. Only the
1240
- // ranges of the fields being collapsed are spliced out; the retained
1241
- // ones — and the comments attached to them, which may be
1242
- // `eslint-disable` directives — are left exactly as authored.
1243
- const targetFix = target.kind === 'jsx'
1244
- ? buildJsxSpreadFix(fixer, sourceCode, target.openingElement, forwardedNodes, propsName)
1245
- : buildObjectSpreadFix(fixer, sourceCode, target.expression, forwardedNodes, propsName);
1246
- if (!targetFix) {
1247
- return null;
1248
- }
1249
- fixes.push(targetFix);
1250
- return fixes;
1251
- },
1461
+ // An exhaustive pick is PROVEN to spread to the same member set, so the
1462
+ // rewrite is applied. Everything else is offered rather than applied:
1463
+ // the spread forwards whatever the parameter's real type turns out to
1464
+ // carry, and a widening this reader cannot disprove has silently
1465
+ // changed consumer behaviour five times (#1642, #1643, #1644, #1769,
1466
+ // #2298). A suggestion costs the author one keystroke; a wrong
1467
+ // `--fix` costs a behaviour change nobody reviewed.
1468
+ ...(classification === 'exhaustive'
1469
+ ? { fix: applySpread }
1470
+ : { suggest: [{ messageId: 'applySpread', fix: applySpread }] }),
1252
1471
  });
1253
1472
  }
1254
1473
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.0",
3
+ "version": "1.21.1",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "1.21.1",
4
+ "date": "2026-09-02T22:30:34.104Z",
5
+ "rules": [
6
+ {
7
+ "name": "prefer-spread-over-reassembly",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2298
11
+ ],
12
+ "summary": "apply the spread only where the pick is proven exhaustive (closes #2298)"
13
+ }
14
+ ]
15
+ },
2
16
  {
3
17
  "version": "1.21.0",
4
18
  "date": "2026-09-02T18:04:18.331Z",