@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
@@ -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.17',
227
+ version: '1.21.18',
228
228
  },
229
229
  parseOptions: {
230
230
  ecmaVersion: 2020,
@@ -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: `Object.keys(${identifierText}).length === 0` },
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 guard = `${node.operator}${identifierText} || Object.keys(${identifierText}).length === 0`;
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
- if (widened) {
1930
- return fixer.replaceTextRange(widened.range, widened.text);
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
- return fixer.replaceText(node, replacement);
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
- return node.typeParameters.params.some((param) => typeNodeExcludesProperty(param, propertyName, resolveAlias, seen, argumentsAreTheType));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.21.17",
3
+ "version": "1.21.18",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -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",