@blumintinc/eslint-plugin-blumint 1.21.14 → 1.21.16

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
@@ -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.14',
227
+ version: '1.21.16',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -235,6 +235,234 @@ function isObjectLikeType(type, checker) {
235
235
  }
236
236
  return 'object';
237
237
  }
238
+ /**
239
+ * Lib wrappers that hand back the shape they are given.
240
+ *
241
+ * `Readonly<T>` only adds modifiers and `Partial<T>` only removes
242
+ * required-ness, so a pinned `T` stays pinned through either, which is what
243
+ * makes `Readonly<Record<string, string>>` readable as the dictionary it is.
244
+ * The unwrapping applies only when the INNER type is itself pinned, so
245
+ * `Readonly<NextResponse>` stays unread and the class instance it names keeps
246
+ * its decline.
247
+ */
248
+ const SHAPE_PRESERVING_TYPE_WRAPPERS = new Set(['Readonly', 'Partial']);
249
+ /** Type keywords that contribute no shape to a union. */
250
+ const NULLABLE_TYPE_KEYWORDS = new Set([
251
+ utils_1.AST_NODE_TYPES.TSNullKeyword,
252
+ utils_1.AST_NODE_TYPES.TSUndefinedKeyword,
253
+ utils_1.AST_NODE_TYPES.TSVoidKeyword,
254
+ ]);
255
+ /** The statements a node holds directly, for a same-file declaration lookup. */
256
+ function statementsOf(node) {
257
+ switch (node.type) {
258
+ case utils_1.AST_NODE_TYPES.Program:
259
+ case utils_1.AST_NODE_TYPES.BlockStatement:
260
+ case utils_1.AST_NODE_TYPES.TSModuleBlock:
261
+ return node.body;
262
+ default:
263
+ return null;
264
+ }
265
+ }
266
+ /**
267
+ * The type a SAME-FILE alias stands for, walking outwards from the reference so
268
+ * that an inner declaration answers ahead of an outer one.
269
+ *
270
+ * Same-file is the whole boundary: an alias imported from a module this program
271
+ * did not load is not found here and stays unpinned, which is the line #2344
272
+ * drew. Following the import instead would need exactly the cross-file
273
+ * resolution whose absence puts this code on the fall-through in the first
274
+ * place.
275
+ *
276
+ * A GENERIC alias is unpinned for a related reason: its body is written against
277
+ * parameters this lookup does not substitute, and each parameter SHADOWS any
278
+ * same-file alias of the same name. Reading `type Wrapper<Cfg> = Readonly<Cfg>`
279
+ * against a sibling `type Cfg = Record<string, string>` calls
280
+ * `Wrapper<NextResponse>` a dictionary and reinstates the inverted guard.
281
+ */
282
+ function resolveTypeAlias(reference, name) {
283
+ let current = reference;
284
+ while (current) {
285
+ const statements = statementsOf(current);
286
+ if (statements) {
287
+ for (const statement of statements) {
288
+ const declaration = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration &&
289
+ statement.declaration
290
+ ? statement.declaration
291
+ : statement;
292
+ if (declaration.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration &&
293
+ declaration.id.name === name) {
294
+ return declaration.typeParameters ? null : declaration.typeAnnotation;
295
+ }
296
+ }
297
+ }
298
+ current = current.parent;
299
+ }
300
+ return null;
301
+ }
302
+ /**
303
+ * The shape an object type literal writes out.
304
+ *
305
+ * The members answer the same question `isObjectLikeType` asks of a resolved
306
+ * type: a required property makes `Object.keys()` non-empty for every valid
307
+ * value, and a call or construct signature marks behaviour rather than data, so
308
+ * either one leaves the literal unpinned.
309
+ */
310
+ function pinnedShapeOfMembers(members) {
311
+ let shape = 'optional';
312
+ for (const member of members) {
313
+ switch (member.type) {
314
+ case utils_1.AST_NODE_TYPES.TSIndexSignature:
315
+ shape = 'index';
316
+ break;
317
+ case utils_1.AST_NODE_TYPES.TSPropertySignature:
318
+ case utils_1.AST_NODE_TYPES.TSMethodSignature:
319
+ if (!member.optional) {
320
+ return null;
321
+ }
322
+ break;
323
+ default:
324
+ return null;
325
+ }
326
+ }
327
+ return shape;
328
+ }
329
+ /**
330
+ * Whether a declared type the checker cannot resolve nonetheless SPELLS a
331
+ * dictionary, and with which shape.
332
+ *
333
+ * A union answers as soon as one member spells one, mirroring
334
+ * `isObjectLikeType`, where any object-like member makes the whole union
335
+ * object-like. An intersection is the opposite and needs every member pinned:
336
+ * `Record<string, string> & SomeClass` carries the class's required members, so
337
+ * a complete program calls it `non-object`, and reading it as a dictionary
338
+ * would recreate the inverted guard the surrounding branch exists to prevent.
339
+ *
340
+ * `expanding` holds the aliases on the current expansion PATH rather than every
341
+ * alias seen, so a self-referential alias terminates while an alias named twice
342
+ * in sibling positions is still read at each of them.
343
+ */
344
+ function pinnedShapeOf(node, expanding = new Set()) {
345
+ switch (node.type) {
346
+ case utils_1.AST_NODE_TYPES.TSTypeLiteral:
347
+ return pinnedShapeOfMembers(node.members);
348
+ case utils_1.AST_NODE_TYPES.TSUnionType: {
349
+ const shapes = node.types
350
+ .filter((member) => !NULLABLE_TYPE_KEYWORDS.has(member.type))
351
+ .map((member) => pinnedShapeOf(member, expanding));
352
+ if (!shapes.some((shape) => shape !== null)) {
353
+ return null;
354
+ }
355
+ return shapes.every((shape) => shape === 'index') ? 'index' : 'optional';
356
+ }
357
+ case utils_1.AST_NODE_TYPES.TSIntersectionType: {
358
+ const shapes = node.types.map((member) => pinnedShapeOf(member, expanding));
359
+ if (shapes.some((shape) => shape === null)) {
360
+ return null;
361
+ }
362
+ return shapes.every((shape) => shape === 'index') ? 'index' : 'optional';
363
+ }
364
+ case utils_1.AST_NODE_TYPES.TSTypeReference: {
365
+ if (node.typeName.type !== utils_1.AST_NODE_TYPES.Identifier) {
366
+ return null;
367
+ }
368
+ const { name } = node.typeName;
369
+ /**
370
+ * A same-file alias is read ahead of the lib names, so a file declaring
371
+ * its own `Record` or `Readonly` is answered by what it wrote.
372
+ */
373
+ const alias = resolveTypeAlias(node, name);
374
+ if (alias) {
375
+ if (expanding.has(alias)) {
376
+ return null;
377
+ }
378
+ expanding.add(alias);
379
+ const aliased = pinnedShapeOf(alias, expanding);
380
+ expanding.delete(alias);
381
+ return aliased;
382
+ }
383
+ if (name === 'Record') {
384
+ return 'index';
385
+ }
386
+ const args = node.typeParameters?.params ?? [];
387
+ if (args.length !== 1) {
388
+ return null;
389
+ }
390
+ if (SHAPE_PRESERVING_TYPE_WRAPPERS.has(name)) {
391
+ return pinnedShapeOf(args[0], expanding);
392
+ }
393
+ /**
394
+ * `Required<T>` makes every named member required, so it preserves only a
395
+ * type whose keys come from an index signature. `Required<{ a?: string }>`
396
+ * carries a required property and is `non-object` to a complete program.
397
+ */
398
+ if (name === 'Required') {
399
+ return pinnedShapeOf(args[0], expanding) === 'index' ? 'index' : null;
400
+ }
401
+ return null;
402
+ }
403
+ default:
404
+ return null;
405
+ }
406
+ }
407
+ /** Wrappers whose awaited value is the single argument they carry. */
408
+ const AWAITED_TYPE_WRAPPERS = new Set(['Promise', 'PromiseLike']);
409
+ /**
410
+ * The value an `await` on this declared type produces.
411
+ *
412
+ * Without it an `async` declaration states its shape one wrapper out of reach:
413
+ * `Promise<Record<string, string>>` is not itself a dictionary, so the awaited
414
+ * dictionary would go unread and the guard the rule exists to add would be
415
+ * dropped. A declared type that is not a promise passes through unchanged,
416
+ * because awaiting a plain value yields that same value.
417
+ */
418
+ function unwrapAwaitedType(node) {
419
+ if (node.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
420
+ node.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
421
+ AWAITED_TYPE_WRAPPERS.has(node.typeName.name) &&
422
+ node.typeParameters?.params.length === 1) {
423
+ return unwrapAwaitedType(node.typeParameters.params[0]);
424
+ }
425
+ return node;
426
+ }
427
+ /**
428
+ * Whether an assertion is `as const`, which pins mutability rather than shape.
429
+ *
430
+ * `const` is not a type the source names, so treating it as one would answer
431
+ * `load() as const` with "the source says nothing about the shape" when the
432
+ * asserted expression may state it perfectly well.
433
+ */
434
+ function isConstAssertion(node) {
435
+ return (node.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
436
+ node.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
437
+ node.typeName.name === 'const');
438
+ }
439
+ /**
440
+ * The return type a class writes on the named member, when it writes one.
441
+ *
442
+ * Only the class's OWN body is read: a return type inherited through `extends`
443
+ * lives in whichever declaration that clause resolves to, and reaching it is
444
+ * the cross-file resolution whose absence puts this code on the fall-through.
445
+ */
446
+ function returnTypeOfMethod(declaration, name) {
447
+ for (const member of declaration.body.body) {
448
+ if (member.type !== utils_1.AST_NODE_TYPES.MethodDefinition &&
449
+ member.type !== utils_1.AST_NODE_TYPES.PropertyDefinition) {
450
+ continue;
451
+ }
452
+ if (member.computed ||
453
+ member.key.type !== utils_1.AST_NODE_TYPES.Identifier ||
454
+ member.key.name !== name) {
455
+ continue;
456
+ }
457
+ const value = member.value;
458
+ if (value &&
459
+ (value.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
460
+ value.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression)) {
461
+ return value.returnType?.typeAnnotation ?? null;
462
+ }
463
+ }
464
+ return null;
465
+ }
238
466
  /**
239
467
  * Reads through an optional chain to the member access or call it holds.
240
468
  * `Object?.keys?.(payload)?.length` parses as a single `ChainExpression`
@@ -1404,6 +1632,197 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
1404
1632
  }
1405
1633
  return false;
1406
1634
  }
1635
+ /**
1636
+ * The type the DECLARATION behind this value writes, if it writes one.
1637
+ *
1638
+ * An import is answered ahead of this by `tracesToImport`; what remains
1639
+ * is every place the file states the type of the value a guard tests. A
1640
+ * binding annotation is one of them and not the only one — an annotated
1641
+ * function return, a class method's return and a type assertion state it
1642
+ * just as directly — and reading only the binding left the naming
1643
+ * heuristic deciding alone at six other declaration sites, which is how
1644
+ * `const response = build()` off a `Readonly<NextResponse>` return kept
1645
+ * being rewritten into a guard that holds for every valid value (#2346).
1646
+ *
1647
+ * The annotation has to sit on the BINDING rather than around it: the one
1648
+ * on `const { config }: Props = load()` describes the container, and
1649
+ * reading it as a verdict on a single property would need exactly the
1650
+ * resolution that failed, so a destructured binding keeps the heuristic.
1651
+ *
1652
+ * `expanding` holds the bindings on the current expansion PATH, so a
1653
+ * binding initialized from itself terminates while a binding named twice
1654
+ * in sibling positions is still read at each of them.
1655
+ */
1656
+ function declaredTypeOf(identifier, expanding = new Set()) {
1657
+ const variable = variableFor(identifier);
1658
+ if (!variable || expanding.has(variable)) {
1659
+ return null;
1660
+ }
1661
+ for (const def of variable.defs) {
1662
+ if (def.type !== 'Parameter' && def.type !== 'Variable') {
1663
+ continue;
1664
+ }
1665
+ if (def.name.typeAnnotation) {
1666
+ return def.name.typeAnnotation.typeAnnotation;
1667
+ }
1668
+ if (def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
1669
+ def.node.id.type === utils_1.AST_NODE_TYPES.Identifier &&
1670
+ def.node.init) {
1671
+ expanding.add(variable);
1672
+ const initialized = declaredTypeOfExpression(def.node.init, expanding);
1673
+ expanding.delete(variable);
1674
+ if (initialized) {
1675
+ return initialized;
1676
+ }
1677
+ }
1678
+ }
1679
+ return null;
1680
+ }
1681
+ /**
1682
+ * The type an initializer states for the value it produces.
1683
+ *
1684
+ * `satisfies` is deliberately absent: it checks an expression against a
1685
+ * type without changing the type, so the value keeps whatever the
1686
+ * expression already carried and a complete program reaches the heuristic
1687
+ * there too. A spelling this switch does not recognize answers `null`,
1688
+ * which leaves the guard exactly where it sat before the declaration
1689
+ * sites were read at all.
1690
+ */
1691
+ function declaredTypeOfExpression(node, expanding) {
1692
+ switch (node.type) {
1693
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
1694
+ case utils_1.AST_NODE_TYPES.TSTypeAssertion:
1695
+ return isConstAssertion(node.typeAnnotation)
1696
+ ? declaredTypeOfExpression(node.expression, expanding)
1697
+ : node.typeAnnotation;
1698
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
1699
+ case utils_1.AST_NODE_TYPES.ChainExpression:
1700
+ return declaredTypeOfExpression(node.expression, expanding);
1701
+ case utils_1.AST_NODE_TYPES.AwaitExpression: {
1702
+ const awaited = declaredTypeOfExpression(node.argument, expanding);
1703
+ return awaited ? unwrapAwaitedType(awaited) : null;
1704
+ }
1705
+ case utils_1.AST_NODE_TYPES.CallExpression:
1706
+ return declaredReturnTypeOf(node.callee, expanding);
1707
+ case utils_1.AST_NODE_TYPES.Identifier:
1708
+ return declaredTypeOf(node, expanding);
1709
+ default:
1710
+ return null;
1711
+ }
1712
+ }
1713
+ /**
1714
+ * The return type the callee's SAME-FILE declaration writes.
1715
+ *
1716
+ * A callee resolving into a module this program did not load is answered
1717
+ * by `tracesToImport` before this runs, so reaching here means the
1718
+ * declaration is one this file can be read for — the same boundary the
1719
+ * same-file alias lookup draws.
1720
+ */
1721
+ function declaredReturnTypeOf(callee, expanding) {
1722
+ if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
1723
+ const variable = variableFor(callee);
1724
+ if (!variable) {
1725
+ return null;
1726
+ }
1727
+ for (const def of variable.defs) {
1728
+ if (def.type === 'FunctionName') {
1729
+ return def.node.returnType?.typeAnnotation ?? null;
1730
+ }
1731
+ if (def.type === 'Variable' &&
1732
+ def.node.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
1733
+ (def.node.init?.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
1734
+ def.node.init?.type === utils_1.AST_NODE_TYPES.FunctionExpression)) {
1735
+ return def.node.init.returnType?.typeAnnotation ?? null;
1736
+ }
1737
+ }
1738
+ return null;
1739
+ }
1740
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
1741
+ !callee.computed &&
1742
+ callee.property.type === utils_1.AST_NODE_TYPES.Identifier) {
1743
+ const declaration = classDeclarationOf(callee.object, expanding);
1744
+ return declaration
1745
+ ? returnTypeOfMethod(declaration, callee.property.name)
1746
+ : null;
1747
+ }
1748
+ return null;
1749
+ }
1750
+ /**
1751
+ * The same-file class an expression is an instance of, or names.
1752
+ *
1753
+ * A receiver whose class is not written in this file yields `null`, so
1754
+ * the method's return type stays unread rather than being guessed from a
1755
+ * same-named method on an unrelated class.
1756
+ */
1757
+ function classDeclarationOf(node, expanding) {
1758
+ if (node.type !== utils_1.AST_NODE_TYPES.Identifier) {
1759
+ return null;
1760
+ }
1761
+ const variable = variableFor(node);
1762
+ if (!variable || expanding.has(variable)) {
1763
+ return null;
1764
+ }
1765
+ for (const def of variable.defs) {
1766
+ if (def.type === 'ClassName') {
1767
+ return def.node;
1768
+ }
1769
+ if (def.type !== 'Variable' ||
1770
+ def.node.type !== utils_1.AST_NODE_TYPES.VariableDeclarator ||
1771
+ !def.node.init) {
1772
+ continue;
1773
+ }
1774
+ const { init } = def.node;
1775
+ if (init.type === utils_1.AST_NODE_TYPES.ClassExpression) {
1776
+ return init;
1777
+ }
1778
+ if (init.type === utils_1.AST_NODE_TYPES.NewExpression) {
1779
+ expanding.add(variable);
1780
+ const declaration = classDeclarationOf(init.callee, expanding);
1781
+ expanding.delete(variable);
1782
+ if (declaration) {
1783
+ return declaration;
1784
+ }
1785
+ }
1786
+ }
1787
+ return null;
1788
+ }
1789
+ /**
1790
+ * Whether a declared type declares NOTHING.
1791
+ *
1792
+ * An explicit `any` is the checker reporting what the source told it, not
1793
+ * a resolution failure, so it leaves the value in the same position as an
1794
+ * unannotated one and the naming heuristic keeps answering. An
1795
+ * intersection carrying `any` reduces to `any`, so it says as little as a
1796
+ * union carrying one. `unknown` is excluded from this reading on purpose:
1797
+ * `Object.keys` rejects an `unknown` operand, so the fix the heuristic
1798
+ * would attach there does not typecheck.
1799
+ */
1800
+ function declaresNothing(node) {
1801
+ if (node.type === utils_1.AST_NODE_TYPES.TSUnionType ||
1802
+ node.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
1803
+ return node.types.some(declaresNothing);
1804
+ }
1805
+ return node.type === utils_1.AST_NODE_TYPES.TSAnyKeyword;
1806
+ }
1807
+ /**
1808
+ * Whether a declared type the checker could not resolve nonetheless
1809
+ * SPELLS an index-signature dictionary.
1810
+ *
1811
+ * `Record<K, V>` is an index signature whichever way `K` and `V` resolve,
1812
+ * so the value is a plain data map rather than a class instance and
1813
+ * `Object.keys` measures its emptiness correctly. Reading that from the
1814
+ * source keeps the verdict a complete program gives — `object` — reachable
1815
+ * without one, and the SPELLING is not what carries the guarantee: a
1816
+ * shape-preserving wrapper, a union member, an intersection whose every
1817
+ * member is pinned and a same-file alias each state the same index
1818
+ * signature, and reading only the bare reference left ten such shapes
1819
+ * silent that a complete program reports (#2345). A reference the source
1820
+ * does not pin this way, `Readonly<NextResponse>` among them, carries no
1821
+ * such guarantee.
1822
+ */
1823
+ function spellsDictionary(node) {
1824
+ return pinnedShapeOf(node) !== null;
1825
+ }
1407
1826
  function isLikelyObject(identifier) {
1408
1827
  if (checker && parserServices?.esTreeNodeToTSNodeMap) {
1409
1828
  try {
@@ -1424,13 +1843,39 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
1424
1843
  * absence of evidence became evidence the value can be `{}`.
1425
1844
  *
1426
1845
  * The heuristic is still the right answer for a value with no
1427
- * declaration to resolve — removing it outright silences 52 of this
1428
- * rule's own 92 fixtures — so only the unresolved-IMPORT case is
1429
- * carved out (#2252).
1846
+ * declaration to resolve — removing it outright silences 56 of this
1847
+ * rule's own 108 fixtures — so the fall-through is carved out only
1848
+ * where evidence EXISTS and went unread: a value from an unresolved
1849
+ * IMPORT (#2252), and a binding that DECLARES a type the checker
1850
+ * could not resolve (#2344). An unresolvable annotation means the
1851
+ * checker could not look, not that the value is loosely typed:
1852
+ * `response: Readonly<NextResponse>` read off its name alone
1853
+ * reported a class instance, whose state lives behind prototype
1854
+ * accessors, so the prescribed `Object.keys` clause holds for every
1855
+ * valid value and inverts the guard it was meant to harden.
1856
+ *
1857
+ * The carve-out costs reach, and the cost is measured rather than
1858
+ * assumed: across the consumer's 14,059 tracked sources it drops 8
1859
+ * reports, and re-running each under a real `ts.Program` — the
1860
+ * verdict this arm exists to preserve — agrees with 6 of them. The
1861
+ * remaining 2 annotate an all-optional imported type
1862
+ * (`Record<string, string[]>` behind a local alias, and an
1863
+ * `algoliasearch-helper` parameter bag), which a resolved program
1864
+ * calls `object` and reports. Nothing at the annotation site
1865
+ * separates those from `Readonly<NextResponse>`: both are bare
1866
+ * references into a module this program did not load, so recovering
1867
+ * them needs the cross-file resolution whose absence defines this
1868
+ * branch. Two silent true positives is the accepted price of six
1869
+ * guard inversions, because a fix that rewrites correct code costs
1870
+ * more than one the rule declines to make.
1430
1871
  */
1431
1872
  if (tracesToImport(identifier)) {
1432
1873
  return false;
1433
1874
  }
1875
+ const declared = declaredTypeOf(identifier);
1876
+ if (declared && !declaresNothing(declared)) {
1877
+ return spellsDictionary(declared);
1878
+ }
1434
1879
  }
1435
1880
  catch {
1436
1881
  // TypeScript parser services can throw when AST-to-TS node mapping fails; fall back to naming heuristic so linting does not crash.