@blumintinc/eslint-plugin-blumint 1.20.70 → 1.20.71
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/no-firestore-object-arrays.js +138 -2
- package/package.json +1 -1
- package/release-manifest.json +14 -0
package/lib/index.js
CHANGED
|
@@ -43,6 +43,42 @@ const isParenthesizedType = (node) => {
|
|
|
43
43
|
return (candidate.type === 'TSParenthesizedType' &&
|
|
44
44
|
candidate.typeAnnotation !== undefined);
|
|
45
45
|
};
|
|
46
|
+
const unwrapParenthesizedTypeNode = (node) => {
|
|
47
|
+
let current = node;
|
|
48
|
+
// Cap iterations for the same reason as unwrapArrayElementType: wrappers are
|
|
49
|
+
// finite, but a future wrapper case must not be able to loop forever.
|
|
50
|
+
for (let i = 0; i < 10; i++) {
|
|
51
|
+
if (isParenthesizedType(current)) {
|
|
52
|
+
current = current.typeAnnotation;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
return current;
|
|
58
|
+
};
|
|
59
|
+
// Assertion wrappers never change the runtime value, so `[...] as const` and
|
|
60
|
+
// `[...] as const satisfies readonly string[]` both still describe an array.
|
|
61
|
+
const EXPRESSION_ASSERTION_TYPES = new Set([
|
|
62
|
+
utils_1.AST_NODE_TYPES.TSAsExpression,
|
|
63
|
+
utils_1.AST_NODE_TYPES.TSNonNullExpression,
|
|
64
|
+
utils_1.AST_NODE_TYPES.TSTypeAssertion,
|
|
65
|
+
'TSSatisfiesExpression',
|
|
66
|
+
]);
|
|
67
|
+
const unwrapExpressionAssertions = (node) => {
|
|
68
|
+
let current = node;
|
|
69
|
+
for (let i = 0; i < 10; i++) {
|
|
70
|
+
if (EXPRESSION_ASSERTION_TYPES.has(current.type)) {
|
|
71
|
+
const inner = current
|
|
72
|
+
.expression;
|
|
73
|
+
if (!inner)
|
|
74
|
+
break;
|
|
75
|
+
current = inner;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
return current;
|
|
81
|
+
};
|
|
46
82
|
const unwrapArrayElementType = (node) => {
|
|
47
83
|
let current = node;
|
|
48
84
|
// Fixpoint loop: peel wrappers in any order until none remain
|
|
@@ -181,6 +217,7 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
|
|
|
181
217
|
const aliasNameToType = new Map();
|
|
182
218
|
const interfaceNames = new Set();
|
|
183
219
|
const enumNames = new Set();
|
|
220
|
+
const constArrayNameToLiteral = new Map();
|
|
184
221
|
const visitNode = (n) => {
|
|
185
222
|
switch (n.type) {
|
|
186
223
|
case utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration: {
|
|
@@ -195,6 +232,22 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
|
|
|
195
232
|
enumNames.add(n.id.name);
|
|
196
233
|
break;
|
|
197
234
|
}
|
|
235
|
+
case utils_1.AST_NODE_TYPES.VariableDeclaration: {
|
|
236
|
+
// Only `const` bindings can back a `(typeof X)[number]` element union
|
|
237
|
+
if (n.kind !== 'const')
|
|
238
|
+
break;
|
|
239
|
+
for (const declarator of n.declarations) {
|
|
240
|
+
if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
241
|
+
!declarator.init) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const init = unwrapExpressionAssertions(declarator.init);
|
|
245
|
+
if (init.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
|
|
246
|
+
constArrayNameToLiteral.set(declarator.id.name, init);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
198
251
|
case utils_1.AST_NODE_TYPES.ExportNamedDeclaration: {
|
|
199
252
|
if (n.declaration)
|
|
200
253
|
visitNode(n.declaration);
|
|
@@ -235,6 +288,85 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
|
|
|
235
288
|
}
|
|
236
289
|
const seenAlias = new Set();
|
|
237
290
|
const visitingAliases = new Set();
|
|
291
|
+
const isPrimitiveLiteralElement = (element, visitedConstArrays) => {
|
|
292
|
+
// Array holes resolve to `undefined`, but the shape is unusual enough
|
|
293
|
+
// that refusing to classify it keeps the narrowing conservative.
|
|
294
|
+
if (!element)
|
|
295
|
+
return false;
|
|
296
|
+
if (element.type === utils_1.AST_NODE_TYPES.SpreadElement) {
|
|
297
|
+
const argument = unwrapExpressionAssertions(element.argument);
|
|
298
|
+
if (argument.type === utils_1.AST_NODE_TYPES.ArrayExpression) {
|
|
299
|
+
return argument.elements.every((nested) => isPrimitiveLiteralElement(nested, visitedConstArrays));
|
|
300
|
+
}
|
|
301
|
+
if (argument.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
302
|
+
return isPrimitiveConstArray(argument.name, visitedConstArrays);
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const expression = unwrapExpressionAssertions(element);
|
|
307
|
+
switch (expression.type) {
|
|
308
|
+
case utils_1.AST_NODE_TYPES.Literal: {
|
|
309
|
+
// A regex literal is an object; its `value` is engine-dependent, so
|
|
310
|
+
// reject it explicitly rather than relying on the typeof check.
|
|
311
|
+
if (expression.regex)
|
|
312
|
+
return false;
|
|
313
|
+
const value = expression.value;
|
|
314
|
+
return (value === null ||
|
|
315
|
+
typeof value === 'string' ||
|
|
316
|
+
typeof value === 'number' ||
|
|
317
|
+
typeof value === 'boolean' ||
|
|
318
|
+
typeof value === 'bigint');
|
|
319
|
+
}
|
|
320
|
+
case utils_1.AST_NODE_TYPES.TemplateLiteral:
|
|
321
|
+
return true; // a template literal always produces a string
|
|
322
|
+
case utils_1.AST_NODE_TYPES.ArrayExpression:
|
|
323
|
+
// Nested primitive arrays mirror the allowance for `string[][]` and
|
|
324
|
+
// tuples of primitives elsewhere in this rule.
|
|
325
|
+
return expression.elements.every((nested) => isPrimitiveLiteralElement(nested, visitedConstArrays));
|
|
326
|
+
case utils_1.AST_NODE_TYPES.UnaryExpression: {
|
|
327
|
+
const unary = expression;
|
|
328
|
+
if (unary.operator !== '-' && unary.operator !== '+')
|
|
329
|
+
return false;
|
|
330
|
+
return isPrimitiveLiteralElement(unary.argument, visitedConstArrays);
|
|
331
|
+
}
|
|
332
|
+
case utils_1.AST_NODE_TYPES.Identifier:
|
|
333
|
+
return expression.name === 'undefined';
|
|
334
|
+
default:
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
const isPrimitiveConstArray = (name, visitedConstArrays) => {
|
|
339
|
+
// A cyclic spread cannot be resolved syntactically; refuse to classify it
|
|
340
|
+
if (visitedConstArrays.has(name))
|
|
341
|
+
return false;
|
|
342
|
+
const arrayLiteral = constArrayNameToLiteral.get(name);
|
|
343
|
+
if (!arrayLiteral)
|
|
344
|
+
return false;
|
|
345
|
+
visitedConstArrays.add(name);
|
|
346
|
+
const result = arrayLiteral.elements.every((element) => isPrimitiveLiteralElement(element, visitedConstArrays));
|
|
347
|
+
visitedConstArrays.delete(name);
|
|
348
|
+
return result;
|
|
349
|
+
};
|
|
350
|
+
/**
|
|
351
|
+
* Recognizes `(typeof VALUES)[number]` where VALUES is a same-file const
|
|
352
|
+
* array of primitive literals. That form denotes the union of those
|
|
353
|
+
* literals, not an object lookup, and it is exactly what the sibling
|
|
354
|
+
* rule prefer-union-from-const-array autofixes toward.
|
|
355
|
+
*/
|
|
356
|
+
const isConstArrayElementUnion = (node) => {
|
|
357
|
+
const indexType = unwrapParenthesizedTypeNode(node.indexType);
|
|
358
|
+
// Only a `number` index yields the element union; `['length']` or any key
|
|
359
|
+
// lookup resolves to something this syntactic check cannot vouch for.
|
|
360
|
+
if (indexType.type !== utils_1.AST_NODE_TYPES.TSNumberKeyword)
|
|
361
|
+
return false;
|
|
362
|
+
const objectType = unwrapParenthesizedTypeNode(node.objectType);
|
|
363
|
+
if (objectType.type !== utils_1.AST_NODE_TYPES.TSTypeQuery)
|
|
364
|
+
return false;
|
|
365
|
+
const exprName = objectType.exprName;
|
|
366
|
+
if (exprName.type !== utils_1.AST_NODE_TYPES.Identifier)
|
|
367
|
+
return false;
|
|
368
|
+
return isPrimitiveConstArray(exprName.name, new Set());
|
|
369
|
+
};
|
|
238
370
|
const isPrimitiveLikeAlias = (name, recursionDepth) => {
|
|
239
371
|
if (seenAlias.has(name))
|
|
240
372
|
return true;
|
|
@@ -286,6 +418,8 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
|
|
|
286
418
|
return false;
|
|
287
419
|
case utils_1.AST_NODE_TYPES.TSLiteralType:
|
|
288
420
|
return true; // string/number/boolean literals
|
|
421
|
+
case utils_1.AST_NODE_TYPES.TSIndexedAccessType:
|
|
422
|
+
return isConstArrayElementUnion(node);
|
|
289
423
|
case utils_1.AST_NODE_TYPES.TSTypeReference: {
|
|
290
424
|
// Allow known primitive-like references and enums or primitive-like aliases
|
|
291
425
|
const ref = node;
|
|
@@ -361,8 +495,10 @@ exports.noFirestoreObjectArrays = (0, createRule_1.createRule)({
|
|
|
361
495
|
case utils_1.AST_NODE_TYPES.TSMappedType:
|
|
362
496
|
return true;
|
|
363
497
|
case utils_1.AST_NODE_TYPES.TSIndexedAccessType:
|
|
364
|
-
//
|
|
365
|
-
|
|
498
|
+
// An indexed access such as `DataShape['user']` is an object lookup,
|
|
499
|
+
// but `(typeof VALUES)[number]` over a const array of primitive
|
|
500
|
+
// literals is a primitive union and must not be flagged.
|
|
501
|
+
return !isConstArrayElementUnion(node);
|
|
366
502
|
case utils_1.AST_NODE_TYPES.TSTypeOperator:
|
|
367
503
|
if (node.operator === 'readonly') {
|
|
368
504
|
return isObjectType(node
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.71",
|
|
4
|
+
"date": "2026-08-02T01:25:22.489Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "no-firestore-object-arrays",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1594
|
|
11
|
+
],
|
|
12
|
+
"summary": "treat (typeof X)[number] over a primitive const array as a primitive union (closes #1594)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"version": "1.20.70",
|
|
4
18
|
"date": "2026-08-01T23:31:02.214Z",
|