@blumintinc/eslint-plugin-blumint 1.20.72 → 1.20.74

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
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.72',
226
+ version: '1.20.74',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -7,6 +7,7 @@ exports.enforceBooleanNamingPrefixes = void 0;
7
7
  const utils_1 = require("@typescript-eslint/utils");
8
8
  const pluralize_1 = __importDefault(require("pluralize"));
9
9
  const createRule_1 = require("../utils/createRule");
10
+ const ASTHelpers_1 = require("../utils/ASTHelpers");
10
11
  // Default approved boolean prefixes. Some less common prefixes (e.g., 'are',
11
12
  // 'includes') stay allowed for flexibility even though the user-facing message
12
13
  // highlights only the most common ones. Underscore-prefixed names are also
@@ -226,6 +227,27 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
226
227
  }
227
228
  return false;
228
229
  }
230
+ /**
231
+ * Recognize `Boolean(x)` — the explicit spelling of `!!x` — as producing a
232
+ * primitive boolean.
233
+ *
234
+ * The callee name alone cannot decide this. A local binding, a parameter or
235
+ * an import named `Boolean` shadows the global and may return anything, so
236
+ * the identifier is resolved through the scope chain at the call site: only
237
+ * an unresolved reference, or one reaching a definition-less global, is the
238
+ * built-in. `new Boolean(x)` is deliberately not covered here — a
239
+ * `NewExpression` builds a Boolean wrapper *object*, which is always truthy
240
+ * and never a primitive boolean.
241
+ */
242
+ function isGlobalBooleanCall(callExpression) {
243
+ const { callee } = callExpression;
244
+ if (callee.type !== utils_1.AST_NODE_TYPES.Identifier ||
245
+ callee.name !== 'Boolean') {
246
+ return false;
247
+ }
248
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, callee), 'Boolean');
249
+ return !variable || variable.defs.length === 0;
250
+ }
229
251
  /**
230
252
  * Check if a node is initialized with a boolean value
231
253
  */
@@ -308,6 +330,12 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
308
330
  // Check for function calls that might return boolean
309
331
  if (node.init.type === utils_1.AST_NODE_TYPES.CallExpression &&
310
332
  node.init.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
333
+ // A coercion through the global `Boolean` is as definitive as `!!x`,
334
+ // and its callee carries no approved prefix for the name heuristic
335
+ // below to recognize.
336
+ if (isGlobalBooleanCall(node.init)) {
337
+ return true;
338
+ }
311
339
  const calleeName = node.init.callee.name;
312
340
  const lowerCallee = calleeName.toLowerCase();
313
341
  // For assert*-style utilities, only treat as boolean if we can confirm boolean return type
@@ -3,6 +3,26 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const utils_1 = require("@typescript-eslint/utils");
4
4
  const createRule_1 = require("../utils/createRule");
5
5
  const isUpperSnakeCase = (str) => /^[A-Z][A-Z0-9_]*$/.test(str);
6
+ /**
7
+ * Converts an identifier to UPPER_SNAKE_CASE by splitting on case *boundaries*.
8
+ *
9
+ * Idempotence is a correctness requirement, not a nicety: `--fix` re-lints its
10
+ * own output up to ten times per file, and a sibling rule can rewrite the same
11
+ * identifier in between (`enforce-react-type-naming` lowercases it), so a
12
+ * converter that re-separates what it already separated compounds every pass
13
+ * and writes an ever-growing, corrupted identifier into source (Issue #1605).
14
+ * Splitting on boundaries also keeps acronym runs intact, so `HTTPServer` reads
15
+ * as `HTTP_SERVER` rather than `H_T_T_P_SERVER`.
16
+ *
17
+ * The leading underscore is dropped because `_PRIVATE_THING` fails
18
+ * `isUpperSnakeCase`, which would leave the rule demanding a rename it can
19
+ * never satisfy.
20
+ */
21
+ const toUpperSnakeCase = (name) => name
22
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
23
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
24
+ .toUpperCase()
25
+ .replace(/^_/, '');
6
26
  // Jest mock handles produced by an `as` cast to a `jest.Mock*` type are
7
27
  // stateful test doubles that are reassigned/mutated through
8
28
  // `.mockImplementation()`, `.mockReturnValue()`, etc. They are not immutable
@@ -328,10 +348,7 @@ exports.default = (0, createRule_1.createRule)({
328
348
  // the `mockedX` idiom is intentional. The exemption gates only this
329
349
  // rename check — the `as const` logic above is untouched.
330
350
  if (!isUpperSnakeCase(name) && !isJestMockCast(init)) {
331
- const newName = name
332
- .replace(/([A-Z])/g, '_$1')
333
- .toUpperCase()
334
- .replace(/^_/, '');
351
+ const newName = toUpperSnakeCase(name);
335
352
  const idNode = declaration.id;
336
353
  context.report({
337
354
  node: declaration,
@@ -701,6 +701,85 @@ function buildHookImportFix(fixer, program, hookName) {
701
701
  ? fixer.insertTextBefore(anchor, statement)
702
702
  : fixer.insertTextAfterRange([0, 0], statement);
703
703
  }
704
+ /**
705
+ * Scope kinds whose bindings are established once per module evaluation:
706
+ * globals, imports and module-level declarations. Such a value is identical on
707
+ * every render, so it can never belong in a dependency array.
708
+ */
709
+ const MODULE_LEVEL_SCOPE_TYPES = new Set(['global', 'module']);
710
+ /**
711
+ * True when `inner` lies entirely inside `outer`'s source range.
712
+ */
713
+ function isRangeWithin(inner, outer) {
714
+ return inner[0] >= outer[0] && inner[1] <= outer[1];
715
+ }
716
+ /**
717
+ * True when a reference appears purely in type position (an annotation or a
718
+ * `satisfies`/`as` target inside the literal). Types erase at compile time, so
719
+ * such a name never becomes a dependency however it resolves. The flags are read
720
+ * defensively: an analyzer that omits them leaves the reference classified as a
721
+ * value, which keeps the suggestion — the conservative direction.
722
+ */
723
+ function isTypeOnlyReference(reference) {
724
+ const flags = reference;
725
+ return flags.isTypeReference === true && flags.isValueReference === false;
726
+ }
727
+ /**
728
+ * True when a reference names a value that can differ between renders, i.e. one
729
+ * bound in a scope INSIDE the module and OUTSIDE the literal: a prop, a local, a
730
+ * destructured value, another hook's result.
731
+ *
732
+ * Everything else is unusable as a dependency. An unresolved name is a global.
733
+ * A module- or global-scoped binding is fixed for the module's lifetime. A
734
+ * binding whose own scope sits inside the literal — an inline function's
735
+ * parameters, its locals, its `arguments` — is not closed over at all.
736
+ */
737
+ function isRenderScopeReference(reference, literalRange) {
738
+ const variable = reference.resolved;
739
+ if (!variable) {
740
+ return false;
741
+ }
742
+ if (MODULE_LEVEL_SCOPE_TYPES.has(variable.scope.type)) {
743
+ return false;
744
+ }
745
+ return !isRangeWithin(variable.scope.block.range, literalRange);
746
+ }
747
+ /**
748
+ * True when the literal reads at least one value a dependency array could hold.
749
+ *
750
+ * Answered from RESOLVED scope references rather than identifier names, so
751
+ * shadowing, destructuring and imports are all accounted for exactly as the
752
+ * scope analyzer sees them.
753
+ *
754
+ * The walk starts at the literal's own scope and descends into every scope
755
+ * nested inside it — a callback buried in an object property closes over the
756
+ * component's scope just as a property value would — while the range filter
757
+ * keeps the literal's siblings out of the answer.
758
+ */
759
+ function closesOverRenderScopeValue(node, scope) {
760
+ const literalRange = node.range;
761
+ const pending = [scope];
762
+ while (pending.length > 0) {
763
+ const current = pending.pop();
764
+ for (const reference of current.references) {
765
+ if (!isRangeWithin(reference.identifier.range, literalRange)) {
766
+ continue;
767
+ }
768
+ if (isTypeOnlyReference(reference)) {
769
+ continue;
770
+ }
771
+ if (isRenderScopeReference(reference, literalRange)) {
772
+ return true;
773
+ }
774
+ }
775
+ for (const child of current.childScopes) {
776
+ if (isRangeWithin(child.block.range, literalRange)) {
777
+ pending.push(child);
778
+ }
779
+ }
780
+ }
781
+ return false;
782
+ }
704
783
  /**
705
784
  * Builds memoization suggestions with dependency placeholders for developers.
706
785
  * @param node Literal node to wrap.
@@ -725,6 +804,19 @@ function buildMemoSuggestions(node, descriptor, sourceCode, context) {
725
804
  memoHook: descriptor.memoHook,
726
805
  },
727
806
  fix(fixer) {
807
+ // The wrap writes an EMPTY dependency array for the author to fill in.
808
+ // A literal that closes over nothing has nothing to fill it with, and
809
+ // `enforce-global-constants` forbids precisely that shape — a useMemo
810
+ // over an object literal with empty deps — while offering no fixer of
811
+ // its own. Accepting the suggestion would therefore trade this report
812
+ // for a permanent, non-autofixable one. Hoisting is the correct branch
813
+ // when nothing is closed over, and the report's own message already
814
+ // prescribes it, so decline rather than emit a state the author cannot
815
+ // complete (the #1417 principle, applied here as in the shadowed-hook
816
+ // guard below).
817
+ if (!closesOverRenderScopeValue(node, ASTHelpers_1.ASTHelpers.getScope(context, node))) {
818
+ return null;
819
+ }
728
820
  // The wrapper is only correct if the hook name resolves to React's
729
821
  // hook. A shadowing local/parameter would silently call that value
730
822
  // instead, and an import of the same name from another module would
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.72",
3
+ "version": "1.20.74",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "1.20.74",
4
+ "date": "2026-08-02T06:07:00.009Z",
5
+ "rules": [
6
+ {
7
+ "name": "global-const-style",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1605
11
+ ],
12
+ "summary": "split the rename on case boundaries so it is idempotent (closes #1605)"
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ "version": "1.20.73",
18
+ "date": "2026-08-02T05:14:39.301Z",
19
+ "rules": [
20
+ {
21
+ "name": "enforce-boolean-naming-prefixes",
22
+ "changeType": "fix",
23
+ "issues": [
24
+ 1602
25
+ ],
26
+ "summary": "recognise a direct Boolean(...) initializer (closes #1602)"
27
+ },
28
+ {
29
+ "name": "react-memoize-literals",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1600
33
+ ],
34
+ "summary": "decline the memo suggestion when the literal closes over nothing (closes #1600)"
35
+ }
36
+ ]
37
+ },
2
38
  {
3
39
  "version": "1.20.72",
4
40
  "date": "2026-08-02T03:33:22.821Z",