@blumintinc/eslint-plugin-blumint 1.21.17 → 1.21.18
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
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
/**
|
|
3
|
+
* The emptiness call the fixer emits, and where its binding comes from.
|
|
4
|
+
*
|
|
5
|
+
* Recognition and EMISSION are separate settings on purpose. `emptyCheckFunctions`
|
|
6
|
+
* is an allowlist of spellings the rule accepts and carries no import path, so
|
|
7
|
+
* reusing it to emit would write a bare call to an identifier the file does not
|
|
8
|
+
* import, and — since it defaults to `isEmpty` — would silently change what every
|
|
9
|
+
* existing consumer's `--fix` produces (#2360).
|
|
10
|
+
*/
|
|
11
|
+
type EmptyCheckFix = {
|
|
12
|
+
name: string;
|
|
13
|
+
importPath?: string;
|
|
14
|
+
};
|
|
2
15
|
type Options = [
|
|
3
16
|
{
|
|
4
17
|
objectNamePattern?: string[];
|
|
5
18
|
ignoreInLoops?: boolean;
|
|
6
19
|
emptyCheckFunctions?: string[];
|
|
20
|
+
emptyCheckFix?: EmptyCheckFix;
|
|
7
21
|
printWidth?: number;
|
|
8
22
|
}
|
|
9
23
|
];
|
|
@@ -28,6 +28,7 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
28
28
|
const ts = __importStar(require("typescript"));
|
|
29
29
|
const createRule_1 = require("../utils/createRule");
|
|
30
30
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
31
|
+
const importInsertion_1 = require("../utils/importInsertion");
|
|
31
32
|
const DEFAULT_OBJECT_SUFFIXES = [
|
|
32
33
|
'Config',
|
|
33
34
|
'Configs',
|
|
@@ -79,6 +80,20 @@ const NON_OBJECT_LIKE_NAMES = [
|
|
|
79
80
|
'array',
|
|
80
81
|
'arr',
|
|
81
82
|
];
|
|
83
|
+
/**
|
|
84
|
+
* The emptiness operand the fixer writes beside the falsiness check.
|
|
85
|
+
*
|
|
86
|
+
* `Object.keys(...).length === 0` needs nothing in scope, so it stays the
|
|
87
|
+
* default and every consumer that has not opted in keeps byte-identical output.
|
|
88
|
+
* A consumer whose config BANS the `Object` accessors names a helper instead,
|
|
89
|
+
* without which `--fix` has no fixed point: the rewrite it just wrote is
|
|
90
|
+
* rejected by the same run that produced it (#2360).
|
|
91
|
+
*/
|
|
92
|
+
function buildEmptyCheckText(identifierText, emptyCheckFix) {
|
|
93
|
+
return emptyCheckFix
|
|
94
|
+
? `${emptyCheckFix.name}(${identifierText})`
|
|
95
|
+
: `Object.keys(${identifierText}).length === 0`;
|
|
96
|
+
}
|
|
82
97
|
function hasBooleanPrefixBoundary(name) {
|
|
83
98
|
const lower = name.toLowerCase();
|
|
84
99
|
return BOOLEAN_PREFIXES.some((prefix) => {
|
|
@@ -1163,7 +1178,7 @@ function getDeclarationStatement(declaration) {
|
|
|
1163
1178
|
* width was measured (#2095).
|
|
1164
1179
|
*/
|
|
1165
1180
|
function planWidenedFix(input) {
|
|
1166
|
-
const { sourceCode, target, identifierText, needsParentheses } = input;
|
|
1181
|
+
const { sourceCode, target, identifierText, emptyCheckText, needsParentheses, } = input;
|
|
1167
1182
|
const source = sourceCode.getText();
|
|
1168
1183
|
const root = getLayoutRoot(target);
|
|
1169
1184
|
const parent = root.parent;
|
|
@@ -1175,7 +1190,7 @@ function planWidenedFix(input) {
|
|
|
1175
1190
|
operator: '||',
|
|
1176
1191
|
parts: [
|
|
1177
1192
|
{ kind: 'leaf', text: `${target.operator}${identifierText}` },
|
|
1178
|
-
{ kind: 'leaf', text:
|
|
1193
|
+
{ kind: 'leaf', text: emptyCheckText },
|
|
1179
1194
|
],
|
|
1180
1195
|
};
|
|
1181
1196
|
const docContext = {
|
|
@@ -1514,6 +1529,150 @@ function planReturnFix(root, parent, flat, doc, { sourceCode, printWidth }, sour
|
|
|
1514
1529
|
}
|
|
1515
1530
|
return { range: [root.range[0], root.range[1]], text: lines.join('\n') };
|
|
1516
1531
|
}
|
|
1532
|
+
/** Every name a binding pattern introduces. */
|
|
1533
|
+
function collectPatternNames(pattern, names) {
|
|
1534
|
+
switch (pattern.type) {
|
|
1535
|
+
case utils_1.AST_NODE_TYPES.Identifier:
|
|
1536
|
+
names.add(pattern.name);
|
|
1537
|
+
return;
|
|
1538
|
+
case utils_1.AST_NODE_TYPES.ObjectPattern:
|
|
1539
|
+
for (const property of pattern.properties) {
|
|
1540
|
+
collectPatternNames(property.type === utils_1.AST_NODE_TYPES.RestElement
|
|
1541
|
+
? property.argument
|
|
1542
|
+
: property.value, names);
|
|
1543
|
+
}
|
|
1544
|
+
return;
|
|
1545
|
+
case utils_1.AST_NODE_TYPES.ArrayPattern:
|
|
1546
|
+
for (const element of pattern.elements) {
|
|
1547
|
+
if (element) {
|
|
1548
|
+
collectPatternNames(element, names);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
return;
|
|
1552
|
+
case utils_1.AST_NODE_TYPES.AssignmentPattern:
|
|
1553
|
+
collectPatternNames(pattern.left, names);
|
|
1554
|
+
return;
|
|
1555
|
+
case utils_1.AST_NODE_TYPES.RestElement:
|
|
1556
|
+
collectPatternNames(pattern.argument, names);
|
|
1557
|
+
return;
|
|
1558
|
+
default:
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* The names already declared at the module's top level.
|
|
1563
|
+
*
|
|
1564
|
+
* An import is only safe to insert when the name is free: a second binding of a
|
|
1565
|
+
* name the file already declares — imported from anywhere, or declared locally —
|
|
1566
|
+
* is a redeclaration error, which is a worse outcome than the lint warning the
|
|
1567
|
+
* insertion was meant to avoid.
|
|
1568
|
+
*/
|
|
1569
|
+
function moduleBindingNames(program) {
|
|
1570
|
+
const names = new Set();
|
|
1571
|
+
for (const statement of program.body) {
|
|
1572
|
+
const node = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
1573
|
+
statement.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
|
|
1574
|
+
? statement.declaration
|
|
1575
|
+
: statement;
|
|
1576
|
+
if (!node) {
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
if (node.type === utils_1.AST_NODE_TYPES.ImportDeclaration) {
|
|
1580
|
+
for (const specifier of node.specifiers) {
|
|
1581
|
+
names.add(specifier.local.name);
|
|
1582
|
+
}
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
if (node.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
|
|
1586
|
+
for (const declarator of node.declarations) {
|
|
1587
|
+
collectPatternNames(declarator.id, names);
|
|
1588
|
+
}
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
if ((node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
1592
|
+
node.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
|
|
1593
|
+
node.type === utils_1.AST_NODE_TYPES.TSEnumDeclaration ||
|
|
1594
|
+
node.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration ||
|
|
1595
|
+
node.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
|
|
1596
|
+
node.type === utils_1.AST_NODE_TYPES.TSModuleDeclaration ||
|
|
1597
|
+
node.type === utils_1.AST_NODE_TYPES.TSDeclareFunction ||
|
|
1598
|
+
node.type === utils_1.AST_NODE_TYPES.TSImportEqualsDeclaration) &&
|
|
1599
|
+
node.id?.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
1600
|
+
names.add(node.id.name);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return names;
|
|
1604
|
+
}
|
|
1605
|
+
/** A module specifier written as source text, with its quotes escaped. */
|
|
1606
|
+
function quoteModuleSpecifier(importPath) {
|
|
1607
|
+
return `'${importPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* The edit that brings the emitted helper into scope, or null when nothing is
|
|
1611
|
+
* needed.
|
|
1612
|
+
*
|
|
1613
|
+
* A helper call the file cannot resolve trades a lint warning for an undefined
|
|
1614
|
+
* identifier, so emission and import travel together in ONE fixer. Four
|
|
1615
|
+
* outcomes are distinguished, because they are four different mistakes:
|
|
1616
|
+
*
|
|
1617
|
+
* * the name is already bound — insert nothing, whatever path it came from,
|
|
1618
|
+
* since a second binding would not compile;
|
|
1619
|
+
* * a value import from the same path already has a named-specifier list — join
|
|
1620
|
+
* it, so the file keeps one declaration per module;
|
|
1621
|
+
* * a file with imports gains a declaration after the LAST of them, which is
|
|
1622
|
+
* past every prologue by construction;
|
|
1623
|
+
* * a file with none defers to `importInsertion`, whose anchor owns the
|
|
1624
|
+
* placement a raw offset gets wrong: a `'use client'` demoted out of the
|
|
1625
|
+
* prologue, a `#!` pushed off line 1, or a line-binding suppression comment
|
|
1626
|
+
* severed from the line it covers.
|
|
1627
|
+
*/
|
|
1628
|
+
function planImportInsertion(sourceCode, emptyCheckFix) {
|
|
1629
|
+
const { name, importPath } = emptyCheckFix;
|
|
1630
|
+
if (!importPath) {
|
|
1631
|
+
return null;
|
|
1632
|
+
}
|
|
1633
|
+
const program = sourceCode.ast;
|
|
1634
|
+
if (moduleBindingNames(program).has(name)) {
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
const imports = program.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
|
|
1638
|
+
for (const declaration of imports) {
|
|
1639
|
+
/** A type-only import carries no value binding for the call to resolve. */
|
|
1640
|
+
if (declaration.importKind === 'type' ||
|
|
1641
|
+
declaration.source.value !== importPath) {
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
const specifiers = declaration.specifiers.filter((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
|
|
1645
|
+
const last = specifiers[specifiers.length - 1];
|
|
1646
|
+
if (!last) {
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* A specifier list Prettier has already broken keeps one name per row, so
|
|
1651
|
+
* joining it inline would emit a layout the next format run undoes.
|
|
1652
|
+
*/
|
|
1653
|
+
const indent = declaration.loc.start.line === declaration.loc.end.line
|
|
1654
|
+
? null
|
|
1655
|
+
: getLineIndent(last, sourceCode);
|
|
1656
|
+
const text = indent === null ? `, ${name}` : `,\n${indent}${name}`;
|
|
1657
|
+
return {
|
|
1658
|
+
offset: last.range[1],
|
|
1659
|
+
apply: (fixer) => fixer.insertTextAfter(last, text),
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
const statement = `import { ${name} } from ${quoteModuleSpecifier(importPath)};`;
|
|
1663
|
+
const lastImport = imports[imports.length - 1];
|
|
1664
|
+
if (lastImport) {
|
|
1665
|
+
return {
|
|
1666
|
+
offset: lastImport.range[1],
|
|
1667
|
+
apply: (fixer) => fixer.insertTextAfter(lastImport, `\n${statement}`),
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
|
|
1671
|
+
return {
|
|
1672
|
+
offset: anchor.kind === 'before' ? anchor.target.range[0] : anchor.index,
|
|
1673
|
+
apply: (fixer) => (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, `${statement}\n`),
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1517
1676
|
exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
1518
1677
|
name: 'enforce-empty-object-check',
|
|
1519
1678
|
meta: {
|
|
@@ -1538,6 +1697,20 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1538
1697
|
type: 'array',
|
|
1539
1698
|
items: { type: 'string' },
|
|
1540
1699
|
},
|
|
1700
|
+
emptyCheckFix: {
|
|
1701
|
+
type: 'object',
|
|
1702
|
+
properties: {
|
|
1703
|
+
/**
|
|
1704
|
+
* A bare identifier, so the emitted call is the shape the
|
|
1705
|
+
* recognition arm accepts — anything else would leave `--fix`
|
|
1706
|
+
* rewriting text it reports again on the next pass.
|
|
1707
|
+
*/
|
|
1708
|
+
name: { type: 'string', pattern: '^[A-Za-z_$][A-Za-z0-9_$]*$' },
|
|
1709
|
+
importPath: { type: 'string', minLength: 1 },
|
|
1710
|
+
},
|
|
1711
|
+
required: ['name'],
|
|
1712
|
+
additionalProperties: false,
|
|
1713
|
+
},
|
|
1541
1714
|
printWidth: {
|
|
1542
1715
|
type: 'number',
|
|
1543
1716
|
minimum: 1,
|
|
@@ -1556,7 +1729,7 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1556
1729
|
const parserServices = sourceCode.parserServices;
|
|
1557
1730
|
const checker = parserServices?.program?.getTypeChecker();
|
|
1558
1731
|
const options = context.options[0] ?? {};
|
|
1559
|
-
const { objectNamePattern = [], ignoreInLoops = false, emptyCheckFunctions = [], } = options;
|
|
1732
|
+
const { objectNamePattern = [], ignoreInLoops = false, emptyCheckFunctions = [], emptyCheckFix, } = options;
|
|
1560
1733
|
const printWidth = typeof options.printWidth === 'number' && options.printWidth > 0
|
|
1561
1734
|
? options.printWidth
|
|
1562
1735
|
: DEFAULT_PRINT_WIDTH;
|
|
@@ -1564,9 +1737,16 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1564
1737
|
...DEFAULT_OBJECT_SUFFIXES,
|
|
1565
1738
|
...objectNamePattern,
|
|
1566
1739
|
]);
|
|
1740
|
+
/**
|
|
1741
|
+
* The emitted helper counts as a satisfying check even when it is absent
|
|
1742
|
+
* from `emptyCheckFunctions`. Without this the fixer would report its own
|
|
1743
|
+
* output, so `--fix` would rewrite the same guard on every pass — the
|
|
1744
|
+
* fixed point this option exists to restore (#2360).
|
|
1745
|
+
*/
|
|
1567
1746
|
const emptyCheckFunctionsSet = new Set([
|
|
1568
1747
|
...DEFAULT_EMPTY_CHECK_FUNCTIONS,
|
|
1569
1748
|
...emptyCheckFunctions,
|
|
1749
|
+
...(emptyCheckFix ? [emptyCheckFix.name] : []),
|
|
1570
1750
|
]);
|
|
1571
1751
|
const processedExpressions = new WeakSet();
|
|
1572
1752
|
const processedNegations = new WeakSet();
|
|
@@ -1883,6 +2063,31 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1883
2063
|
}
|
|
1884
2064
|
return isObjectLikeName(identifier.name, patternSet);
|
|
1885
2065
|
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Whether a binding between the report site and the module scope would
|
|
2068
|
+
* capture the emitted helper call.
|
|
2069
|
+
*
|
|
2070
|
+
* A bare call resolves wherever the CALL sits, so an inner binding of the
|
|
2071
|
+
* same name silently takes it over — the rewrite compiles, reads
|
|
2072
|
+
* correctly, and calls something else entirely (#1455, #1456 are this
|
|
2073
|
+
* shape). No spelling of the call escapes that, so the fix is declined
|
|
2074
|
+
* and the report stands on its own; the message already says what to
|
|
2075
|
+
* write.
|
|
2076
|
+
*
|
|
2077
|
+
* The unconfigured emission is untouched by this: `Object` is a global
|
|
2078
|
+
* rather than a module binding, and its output is a compatibility
|
|
2079
|
+
* contract (#2360).
|
|
2080
|
+
*/
|
|
2081
|
+
function emissionIsShadowed(node, name) {
|
|
2082
|
+
let scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
|
|
2083
|
+
while (scope) {
|
|
2084
|
+
if (scope.variables.some((variable) => variable.name === name)) {
|
|
2085
|
+
return scope.type !== 'module' && scope.type !== 'global';
|
|
2086
|
+
}
|
|
2087
|
+
scope = scope.upper;
|
|
2088
|
+
}
|
|
2089
|
+
return false;
|
|
2090
|
+
}
|
|
1886
2091
|
function reportNegation(node, identifier) {
|
|
1887
2092
|
if (processedNegations.has(node)) {
|
|
1888
2093
|
return;
|
|
@@ -1906,8 +2111,12 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1906
2111
|
name: identifier.name,
|
|
1907
2112
|
},
|
|
1908
2113
|
fix(fixer) {
|
|
2114
|
+
if (emptyCheckFix && emissionIsShadowed(node, emptyCheckFix.name)) {
|
|
2115
|
+
return null;
|
|
2116
|
+
}
|
|
1909
2117
|
const identifierText = sourceCode.getText(identifier);
|
|
1910
|
-
const
|
|
2118
|
+
const emptyCheckText = buildEmptyCheckText(identifierText, emptyCheckFix);
|
|
2119
|
+
const guard = `${node.operator}${identifierText} || ${emptyCheckText}`;
|
|
1911
2120
|
const needsParentheses = replacementNeedsParentheses(node, sourceCode);
|
|
1912
2121
|
const replacement = needsParentheses ? `(${guard})` : guard;
|
|
1913
2122
|
/**
|
|
@@ -1923,13 +2132,27 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1923
2132
|
sourceCode,
|
|
1924
2133
|
target: node,
|
|
1925
2134
|
identifierText,
|
|
2135
|
+
emptyCheckText,
|
|
1926
2136
|
needsParentheses,
|
|
1927
2137
|
printWidth,
|
|
1928
2138
|
});
|
|
1929
|
-
|
|
1930
|
-
|
|
2139
|
+
const guardEdit = widened ?? {
|
|
2140
|
+
range: node.range,
|
|
2141
|
+
text: replacement,
|
|
2142
|
+
};
|
|
2143
|
+
const importFix = emptyCheckFix
|
|
2144
|
+
? planImportInsertion(sourceCode, emptyCheckFix)
|
|
2145
|
+
: null;
|
|
2146
|
+
if (!importFix) {
|
|
2147
|
+
return fixer.replaceTextRange(guardEdit.range, guardEdit.text);
|
|
1931
2148
|
}
|
|
1932
|
-
|
|
2149
|
+
const guardFix = {
|
|
2150
|
+
offset: guardEdit.range[0],
|
|
2151
|
+
apply: (target) => target.replaceTextRange(guardEdit.range, guardEdit.text),
|
|
2152
|
+
};
|
|
2153
|
+
return [importFix, guardFix]
|
|
2154
|
+
.sort((left, right) => left.offset - right.offset)
|
|
2155
|
+
.map((edit) => edit.apply(fixer));
|
|
1933
2156
|
},
|
|
1934
2157
|
});
|
|
1935
2158
|
}
|
|
@@ -352,7 +352,17 @@ function typeNodeExcludesProperty(node, propertyName, resolveAlias, seen = new S
|
|
|
352
352
|
if (node.typeParameters?.params) {
|
|
353
353
|
const argumentsAreTheType = closedLiteralCounts &&
|
|
354
354
|
PROPERTY_PRESERVING_WRAPPERS.has(wrapperNameOf(node.typeName) ?? '');
|
|
355
|
-
|
|
355
|
+
if (node.typeParameters.params.some((param) => typeNodeExcludesProperty(param, propertyName, resolveAlias, seen, argumentsAreTheType))) {
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
// Arguments that prove nothing are not proof that the referenced alias
|
|
359
|
+
// proves nothing, so a reference carrying them still falls through to its
|
|
360
|
+
// own body. Returning here made `type P<T> = Omit<X, 'children'>` report
|
|
361
|
+
// when referenced as `P<string>` while the byte-identical unparameterized
|
|
362
|
+
// spelling passed. Type arguments are deliberately NOT substituted into
|
|
363
|
+
// the body: an unbound parameter there proves nothing, which is the
|
|
364
|
+
// conservative answer, and an undeclared wrapper still resolves to
|
|
365
|
+
// nothing and still reports.
|
|
356
366
|
}
|
|
357
367
|
// Keyed by position as well as by name: the same alias proves different
|
|
358
368
|
// things in a props position than inside an arbitrary generic, so a visit
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.21.18",
|
|
4
|
+
"date": "2026-09-07T22:15:12.507Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-empty-object-check",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
2360
|
|
11
|
+
],
|
|
12
|
+
"summary": "emit a configurable empty-check helper (closes #2360)"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "prevent-children-clobber",
|
|
16
|
+
"changeType": "fix",
|
|
17
|
+
"issues": [
|
|
18
|
+
2361
|
|
19
|
+
],
|
|
20
|
+
"summary": "resolve a parameterized props alias (closes #2361)"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
},
|
|
2
24
|
{
|
|
3
25
|
"version": "1.21.17",
|
|
4
26
|
"date": "2026-09-07T12:35:50.668Z",
|