@blumintinc/eslint-plugin-blumint 1.20.105 → 1.20.107

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.105',
226
+ version: '1.20.107',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -1,2 +1,3 @@
1
- declare const _default: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"callbackPropPrefix" | "callbackFunctionPrefix", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ declare const _default: TSESLint.RuleModule<"callbackPropPrefix" | "callbackFunctionPrefix", [], TSESLint.RuleListener>;
2
3
  export = _default;
@@ -39,6 +39,183 @@ const ts = __importStar(require("typescript"));
39
39
  function hasHandlePrefix(name) {
40
40
  return /^handle[A-Z]/.test(name);
41
41
  }
42
+ function stripHandlePrefix(name) {
43
+ return name.slice(6).charAt(0).toLowerCase() + name.slice(7);
44
+ }
45
+ // Stripping the prefix can land the rename squarely on a keyword:
46
+ // `handleDelete` -> `delete`, `handleNew` -> `new`, `handleReturn` -> `return`,
47
+ // `handleTrue` -> `true`. None of those is a legal binding name, so a fix that
48
+ // emits one turns a working file into a parse error — `const delete = fn` and
49
+ // `const { delete } = api` are both SyntaxErrors (Bug #1719). The set covers the
50
+ // ES reserved words, the strict-mode/module reserved words (the linted codebase
51
+ // is entirely ES modules, where `await` and `implements` et al. are reserved
52
+ // too) and the three keyword literals. The guard is applied at every emission
53
+ // site, including member names where a keyword happens to be legal
54
+ // (`class C { delete() {} }`): the rule cannot see whether that member is later
55
+ // destructured into a binding, and a fixer that is safe only sometimes is not
56
+ // safe.
57
+ const RESERVED_WORDS = new Set([
58
+ 'arguments',
59
+ 'await',
60
+ 'break',
61
+ 'case',
62
+ 'catch',
63
+ 'class',
64
+ 'const',
65
+ 'continue',
66
+ 'debugger',
67
+ 'default',
68
+ 'delete',
69
+ 'do',
70
+ 'else',
71
+ 'enum',
72
+ 'eval',
73
+ 'export',
74
+ 'extends',
75
+ 'false',
76
+ 'finally',
77
+ 'for',
78
+ 'function',
79
+ 'if',
80
+ 'implements',
81
+ 'import',
82
+ 'in',
83
+ 'instanceof',
84
+ 'interface',
85
+ 'let',
86
+ 'new',
87
+ 'null',
88
+ 'package',
89
+ 'private',
90
+ 'protected',
91
+ 'public',
92
+ 'return',
93
+ 'static',
94
+ 'super',
95
+ 'switch',
96
+ 'this',
97
+ 'throw',
98
+ 'true',
99
+ 'try',
100
+ 'typeof',
101
+ 'var',
102
+ 'void',
103
+ 'while',
104
+ 'with',
105
+ 'yield',
106
+ ]);
107
+ function isEmittableName(name) {
108
+ return name.length > 0 && !RESERVED_WORDS.has(name);
109
+ }
110
+ function isExportedDeclaration(node) {
111
+ let current = node;
112
+ while (current) {
113
+ if (current.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
114
+ current.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration) {
115
+ return true;
116
+ }
117
+ current = current.parent;
118
+ }
119
+ return false;
120
+ }
121
+ // An object literal that is exported or returned is a value other modules read
122
+ // by member name (`api.handleOpenThread`, `const { handleOpenThread } = useX()`).
123
+ // Renaming a member of it edits one end of a contract whose readers live in
124
+ // files a single-file fixer cannot even see, so the violation is reported
125
+ // without a fix — the same reasoning that withholds the JSX prop rename.
126
+ function isApiSurfaceValue(node) {
127
+ let child = node;
128
+ let current = node.parent;
129
+ while (current) {
130
+ switch (current.type) {
131
+ case utils_1.AST_NODE_TYPES.ExportNamedDeclaration:
132
+ case utils_1.AST_NODE_TYPES.ExportDefaultDeclaration:
133
+ case utils_1.AST_NODE_TYPES.ReturnStatement:
134
+ return true;
135
+ case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
136
+ // A concise body is a return with no `return` keyword.
137
+ return current.body === child;
138
+ case utils_1.AST_NODE_TYPES.BlockStatement:
139
+ case utils_1.AST_NODE_TYPES.ClassBody:
140
+ case utils_1.AST_NODE_TYPES.FunctionDeclaration:
141
+ case utils_1.AST_NODE_TYPES.FunctionExpression:
142
+ case utils_1.AST_NODE_TYPES.Program:
143
+ return false;
144
+ default:
145
+ child = current;
146
+ current = current.parent;
147
+ }
148
+ }
149
+ return false;
150
+ }
151
+ /** The member names already declared alongside `node`, keyed by identifier. */
152
+ function siblingMemberNames(node) {
153
+ const names = new Set();
154
+ const record = (member) => {
155
+ const key = member.key;
156
+ if (!member.computed &&
157
+ key?.type === utils_1.AST_NODE_TYPES.Identifier) {
158
+ names.add(key.name);
159
+ }
160
+ };
161
+ if (node?.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
162
+ node.properties.forEach(record);
163
+ }
164
+ else if (node?.type === utils_1.AST_NODE_TYPES.ClassBody) {
165
+ node.body.forEach(record);
166
+ }
167
+ return names;
168
+ }
169
+ /**
170
+ * Every member name the file reads by name — `obj.handleClick`,
171
+ * `obj['handleClick']`, `const { handleClick } = obj`.
172
+ *
173
+ * Renaming an object literal's key has to move every one of those reads with
174
+ * it, and a fixer scoped to the literal moves none of them: `const o = { click:
175
+ * fn }; o.handleClick()` type-checks as a missing property and throws at
176
+ * runtime. The presence of any reader therefore withholds the rewrite. The walk
177
+ * is over the whole program because a reader may appear anywhere, including
178
+ * before the literal.
179
+ */
180
+ function collectMemberReads(program, visitorKeys) {
181
+ const names = new Set();
182
+ const stack = [program];
183
+ const push = (value) => {
184
+ if (Array.isArray(value)) {
185
+ value.forEach(push);
186
+ }
187
+ else if (value && typeof value === 'object' && 'type' in value) {
188
+ stack.push(value);
189
+ }
190
+ };
191
+ while (stack.length > 0) {
192
+ const node = stack.pop();
193
+ if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
194
+ if (!node.computed && node.property.type === utils_1.AST_NODE_TYPES.Identifier) {
195
+ names.add(node.property.name);
196
+ }
197
+ else if (node.property.type === utils_1.AST_NODE_TYPES.Literal &&
198
+ typeof node.property.value === 'string') {
199
+ names.add(node.property.value);
200
+ }
201
+ }
202
+ // Read directly off the pattern rather than through `parent`, which is not
203
+ // guaranteed to be assigned on nodes the traversal has not reached.
204
+ if (node.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
205
+ for (const property of node.properties) {
206
+ if (property.type === utils_1.AST_NODE_TYPES.Property &&
207
+ !property.computed &&
208
+ property.key.type === utils_1.AST_NODE_TYPES.Identifier) {
209
+ names.add(property.key.name);
210
+ }
211
+ }
212
+ }
213
+ for (const key of visitorKeys[node.type] ?? []) {
214
+ push(node[key]);
215
+ }
216
+ }
217
+ return names;
218
+ }
42
219
  module.exports = (0, createRule_1.createRule)({
43
220
  name: 'consistent-callback-naming',
44
221
  meta: {
@@ -200,6 +377,101 @@ module.exports = (0, createRule_1.createRule)({
200
377
  // that includes a void-returning signature keeps the handler semantics.
201
378
  return signatures.every((signature) => !returnsVoidLike(checker.getReturnTypeOfSignature(signature)));
202
379
  }
380
+ // Built once per file, and only when an object literal member is actually a
381
+ // rename candidate.
382
+ let memberReads;
383
+ function isReadByName(name) {
384
+ const sourceCode = context.getSourceCode();
385
+ memberReads ??= collectMemberReads(sourceCode.ast, sourceCode.visitorKeys);
386
+ return memberReads.has(name);
387
+ }
388
+ /**
389
+ * The variable a pattern identifier binds. `getDeclaredVariables` is
390
+ * authoritative — it is asked of the declaring ancestor (the
391
+ * `VariableDeclaration`, the function owning a destructured parameter, the
392
+ * `CatchClause`) rather than reconstructed by crawling scopes by name,
393
+ * which cannot tell two same-named bindings apart.
394
+ */
395
+ function findPatternVariable(id) {
396
+ let current = id.parent;
397
+ while (current) {
398
+ const match = context
399
+ .getDeclaredVariables(current)
400
+ .find((variable) => variable.identifiers.includes(id));
401
+ if (match) {
402
+ return match;
403
+ }
404
+ current = current.parent;
405
+ }
406
+ return undefined;
407
+ }
408
+ // Renaming a binding that leaves the module — `export const { a: handleX }`,
409
+ // or `export { handleX }` — breaks importers the fixer cannot edit.
410
+ function isExportedBinding(variable) {
411
+ const namedByExportSpecifier = (id) => id.parent?.type === utils_1.AST_NODE_TYPES.ExportSpecifier;
412
+ return (variable.references.some((ref) => namedByExportSpecifier(ref.identifier)) ||
413
+ variable.identifiers.some(namedByExportSpecifier) ||
414
+ variable.defs.some((def) => isExportedDeclaration(def.node)));
415
+ }
416
+ // A rename that collides with a name already visible where the binding (or
417
+ // any of its references) lives silently re-points those references at the
418
+ // other declaration.
419
+ function isNameTaken(variable, newName) {
420
+ let scope = variable.scope;
421
+ while (scope) {
422
+ if (scope.set.has(newName)) {
423
+ return true;
424
+ }
425
+ scope = scope.upper;
426
+ }
427
+ return variable.scope.childScopes.some((child) => child.set.has(newName));
428
+ }
429
+ /**
430
+ * A `Property` inside an `ObjectPattern`. Its key names a property of the
431
+ * object being destructured — someone else's API (`const { handleDelete: fn }
432
+ * = useMessage('handleDelete')` reads Stream Chat's own member) — so
433
+ * rewriting the key changes WHICH property is read, strands every reader of
434
+ * the old name, and can emit a keyword that is not a legal binding
435
+ * (Bug #1719). The key is therefore never reported and never rewritten. The
436
+ * only name the file owns here is the local binding, so that is what the
437
+ * report targets when it too carries the prefix.
438
+ */
439
+ function reportDestructuredBinding(node) {
440
+ // A shorthand binding is a single token that is simultaneously the
441
+ // foreign property name and the local name: there is no name the file
442
+ // chose independently, and no in-place edit can change one without the
443
+ // other. Left alone entirely rather than reported with no remedy.
444
+ if (node.shorthand || node.value.type !== utils_1.AST_NODE_TYPES.Identifier) {
445
+ return;
446
+ }
447
+ const binding = node.value;
448
+ if (!hasHandlePrefix(binding.name)) {
449
+ return;
450
+ }
451
+ const newName = stripHandlePrefix(binding.name);
452
+ const variable = findPatternVariable(binding);
453
+ const canFix = isEmittableName(newName) &&
454
+ !!variable &&
455
+ !isExportedBinding(variable) &&
456
+ !isNameTaken(variable, newName);
457
+ context.report({
458
+ node: binding,
459
+ messageId: 'callbackFunctionPrefix',
460
+ data: { functionName: binding.name },
461
+ fix(fixer) {
462
+ if (!canFix || !variable) {
463
+ return null;
464
+ }
465
+ // Every occurrence in one edit: a rename that reaches the declaration
466
+ // but not its readers is worse than no rename at all.
467
+ const targets = new Set([binding]);
468
+ for (const ref of variable.references) {
469
+ targets.add(ref.identifier);
470
+ }
471
+ return [...targets].map((id) => fixer.replaceText(id, newName));
472
+ },
473
+ });
474
+ }
203
475
  return {
204
476
  // Check JSX attributes for callback props
205
477
  JSXAttribute(node) {
@@ -341,8 +613,12 @@ module.exports = (0, createRule_1.createRule)({
341
613
  data: { functionName },
342
614
  fix(fixer) {
343
615
  // Remove 'handle' prefix and convert first character to lowercase
344
- const newName = functionName.slice(6).charAt(0).toLowerCase() +
345
- functionName.slice(7);
616
+ const newName = stripHandlePrefix(functionName);
617
+ // `const handleDelete = fn` would become `const delete = fn`,
618
+ // which does not parse (Bug #1719).
619
+ if (!isEmittableName(newName)) {
620
+ return null;
621
+ }
346
622
  // Fix the declaration and all references
347
623
  const fixes = [];
348
624
  fixes.push(fixer.replaceText(node.id, newName));
@@ -358,30 +634,50 @@ module.exports = (0, createRule_1.createRule)({
358
634
  },
359
635
  // Check class methods and object methods
360
636
  'MethodDefinition, Property'(node) {
361
- if (node.key.type === 'Identifier' &&
362
- node.key.name &&
363
- hasHandlePrefix(node.key.name)) {
364
- const name = node.key.name;
365
- // Skip autofixing for class parameters and getters
366
- if (node.type === 'MethodDefinition' && node.kind === 'get') {
367
- context.report({
368
- node: node.key,
369
- messageId: 'callbackFunctionPrefix',
370
- data: { functionName: name },
371
- });
372
- return;
373
- }
637
+ const key = node.key;
638
+ if (key.type !== utils_1.AST_NODE_TYPES.Identifier ||
639
+ !key.name ||
640
+ !hasHandlePrefix(key.name)) {
641
+ return;
642
+ }
643
+ const name = key.name;
644
+ // Skip autofixing for class parameters and getters
645
+ if (node.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
646
+ node.kind === 'get') {
374
647
  context.report({
375
- node: node.key,
648
+ node: key,
376
649
  messageId: 'callbackFunctionPrefix',
377
650
  data: { functionName: name },
378
- fix(fixer) {
379
- // Remove 'handle' prefix and convert first character to lowercase
380
- const newName = name.slice(6).charAt(0).toLowerCase() + name.slice(7);
381
- return fixer.replaceText(node.key, newName);
382
- },
383
651
  });
652
+ return;
653
+ }
654
+ const isProperty = node.type === utils_1.AST_NODE_TYPES.Property;
655
+ if (isProperty && node.parent?.type === utils_1.AST_NODE_TYPES.ObjectPattern) {
656
+ reportDestructuredBinding(node);
657
+ return;
384
658
  }
659
+ const newName = stripHandlePrefix(name);
660
+ // A shorthand property's key and value are the same token, so replacing
661
+ // the key also replaces the value: `{ handleClick }` becomes
662
+ // `{ click }`, which both renames the member and re-points it at a
663
+ // binding that need not exist (Bug #1719).
664
+ const canFix = isEmittableName(newName) &&
665
+ // A sibling already holding the target name turns the rename into a
666
+ // duplicate member: `{ click: a, handleClick: b }` would collapse to
667
+ // two `click` keys, silently discarding the first.
668
+ !siblingMemberNames(node.parent).has(newName) &&
669
+ !(isProperty && node.shorthand) &&
670
+ !(isProperty &&
671
+ node.parent &&
672
+ (isApiSurfaceValue(node.parent) || isReadByName(name)));
673
+ context.report({
674
+ node: key,
675
+ messageId: 'callbackFunctionPrefix',
676
+ data: { functionName: name },
677
+ fix(fixer) {
678
+ return canFix ? fixer.replaceText(key, newName) : null;
679
+ },
680
+ });
385
681
  },
386
682
  // Check constructor parameters
387
683
  TSParameterProperty(node) {
@@ -122,6 +122,53 @@ function isArrayOrPrimitive(checker, esTreeNode, nodeMap) {
122
122
  return false;
123
123
  }
124
124
  }
125
+ /**
126
+ * Whether a member access reads a method — a function declared as a member of
127
+ * a class or interface, which lives on the prototype.
128
+ *
129
+ * why: such a reference is one shared value across every instance of the type
130
+ * (`new Set().has === new Set().has`, `f1.call === f2.call`). Narrowing a
131
+ * dependency from `set` to `set.has` therefore pins a constant: the hook never
132
+ * invalidates again and serves a stale value forever, so the whole object has
133
+ * to stay the dependency. This is the checker-driven generalisation of the
134
+ * `ARRAY_METHODS`/`STRING_METHODS` name lists, which recognise the identical
135
+ * hazard for two built-ins only; `Map`, `Set`, `Promise`, `Date`, `Intl.*` and
136
+ * every user-defined class come for free.
137
+ *
138
+ * The discriminator is how the member is *declared*, not merely "the type is
139
+ * callable". A function-valued data property (`{ getName?: () => string }`, or
140
+ * a class field holding an arrow function) is per-instance state: it genuinely
141
+ * changes when the object carrying it is rebuilt, so narrowing to it is correct
142
+ * and stays allowed.
143
+ *
144
+ * The question is asked of the symbol's flags rather than its declarations'
145
+ * `SyntaxKind`, because a rule must survive a version skew between the
146
+ * TypeScript this package resolves and the one the consumer's parser built the
147
+ * program with. `SyntaxKind` is renumbered whenever a kind is inserted —
148
+ * `MethodSignature` is 170 under 5.0 and 174 under 5.9 — so a `ts.isMethodX`
149
+ * guard imported here silently answers `false` for every node of a consumer on
150
+ * a different minor, making the carve-out a no-op in exactly the place it
151
+ * matters. `SymbolFlags` is an append-only bit set (`Method` has been 8192
152
+ * throughout), so the flag test holds across versions.
153
+ */
154
+ function isMethodMember(checker, esTreeNode, nodeMap) {
155
+ try {
156
+ const tsNode = nodeMap.get(esTreeNode);
157
+ if (!tsNode)
158
+ return false;
159
+ // why: an unresolved member (an `any` receiver, a missing type) yields no
160
+ // symbol, so the check stays inert rather than guessing — matching the
161
+ // conservative stance `isArrayOrPrimitive` takes on Any/Unknown.
162
+ const symbol = checker.getSymbolAtLocation(tsNode);
163
+ if (!symbol)
164
+ return false;
165
+ return (symbol.flags & typescript_1.SymbolFlags.Method) !== 0;
166
+ }
167
+ catch (error) {
168
+ // A type-checker failure must not change what the rule reports.
169
+ return false;
170
+ }
171
+ }
125
172
  function renderPathSegments(baseName, segments) {
126
173
  let path = baseName;
127
174
  for (const segment of segments) {
@@ -196,7 +243,7 @@ function callsCorrespondingSetter(hookBody, dependencyName) {
196
243
  }
197
244
  return visit(hookBody);
198
245
  }
199
- function getObjectUsagesInHook(hookBody, objectName) {
246
+ function getObjectUsagesInHook(hookBody, objectName, typeInfo) {
200
247
  const usages = new Map(); // Track usage and its position
201
248
  // why: derived dependency paths (first-optional intermediate, array base)
202
249
  // must be re-rendered from structured links — string surgery on the
@@ -310,10 +357,17 @@ function getObjectUsagesInHook(hookBody, objectName) {
310
357
  if (memberExpr.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
311
358
  return null;
312
359
  }
313
- // Check for array/string methods - these indicate usage of the entire array/string
314
- if (memberExpr.property.name &&
360
+ // Check for a member that cannot serve as a narrowed dependency: a
361
+ // built-in array/string method by name, or — when type information is
362
+ // available — any method of a class or interface. Both denote usage of
363
+ // the entire receiver, because the member itself is a prototype-shared
364
+ // reference rather than per-instance state.
365
+ const isBuiltInWholeObjectMethod = !!memberExpr.property.name &&
315
366
  (ARRAY_METHODS.has(memberExpr.property.name) ||
316
- STRING_METHODS.has(memberExpr.property.name))) {
367
+ STRING_METHODS.has(memberExpr.property.name));
368
+ if (isBuiltInWholeObjectMethod ||
369
+ (typeInfo !== undefined &&
370
+ isMethodMember(typeInfo.checker, memberExpr, typeInfo.nodeMap))) {
317
371
  const methodTarget = unwrapExpression(memberExpr.object);
318
372
  if (methodTarget.type === utils_1.AST_NODE_TYPES.MemberExpression) {
319
373
  // Method call on a property (e.g., userData.items.map(...) or
@@ -661,6 +715,22 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
661
715
  // In a real environment, we would want to enforce this
662
716
  // throw new Error('You have to enable the `project` setting in parser options to use this rule');
663
717
  }
718
+ // why: building the checker is the expensive half of a typed lint, so the
719
+ // handles are resolved once per file and shared by every type-driven check
720
+ // rather than re-fetched per dependency.
721
+ let typeInfo;
722
+ function getTypeInfo() {
723
+ if (!hasFullTypeChecking || !parserServices) {
724
+ return undefined;
725
+ }
726
+ if (!typeInfo) {
727
+ typeInfo = {
728
+ checker: parserServices.program.getTypeChecker(),
729
+ nodeMap: parserServices.esTreeNodeToTSNodeMap,
730
+ };
731
+ }
732
+ return typeInfo;
733
+ }
664
734
  const sourceCode = context.getSourceCode();
665
735
  // why: scanning every comment once per file rather than once per hook call
666
736
  // keeps the check off the hot path of files with many hooks.
@@ -736,16 +806,15 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
736
806
  if (unwrappedElement.type === utils_1.AST_NODE_TYPES.Identifier) {
737
807
  const objectName = unwrappedElement.name;
738
808
  // Skip type checking if we don't have TypeScript services
739
- if (hasFullTypeChecking && parserServices) {
740
- const checker = parserServices.program.getTypeChecker();
741
- const nodeMap = parserServices.esTreeNodeToTSNodeMap;
809
+ const dependencyTypeInfo = getTypeInfo();
810
+ if (dependencyTypeInfo) {
742
811
  // Skip if the dependency is an array or primitive type
743
- if (isArrayOrPrimitive(checker, unwrappedElement, nodeMap)) {
812
+ if (isArrayOrPrimitive(dependencyTypeInfo.checker, unwrappedElement, dependencyTypeInfo.nodeMap)) {
744
813
  return;
745
814
  }
746
815
  }
747
816
  // For testing without TypeScript services, we'll assume all identifiers are objects
748
- const result = getObjectUsagesInHook(callbackBody, objectName);
817
+ const result = getObjectUsagesInHook(callbackBody, objectName, dependencyTypeInfo);
749
818
  // If the object is not used at all, suggest removing it
750
819
  if (result.notUsed) {
751
820
  // why: deleting an entry from an array the author maintains by
@@ -474,6 +474,142 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
474
474
  }
475
475
  return names;
476
476
  }
477
+ /**
478
+ * Records the binding a single assignment target writes to.
479
+ *
480
+ * A member write records its ROOT object (`obj.a.b = 1` yields `obj`),
481
+ * because the state it mutates is reachable through that binding, and a
482
+ * later await naming the object observes the mutation. Optional chains and
483
+ * TS wrappers (`obj!.x = 1`) are unwrapped so the root is still found.
484
+ * Destructuring targets recurse to their leaf identifiers, since
485
+ * `({ a } = source)` and `[a] = source` write `a` just as `a = source.a`
486
+ * does.
487
+ */
488
+ function collectAssignmentTarget(target, targets) {
489
+ switch (target.type) {
490
+ case utils_1.AST_NODE_TYPES.Identifier:
491
+ targets.push(target);
492
+ break;
493
+ case utils_1.AST_NODE_TYPES.MemberExpression: {
494
+ let root = target;
495
+ for (;;) {
496
+ if (root.type === utils_1.AST_NODE_TYPES.MemberExpression) {
497
+ root = root.object;
498
+ }
499
+ else if (root.type === utils_1.AST_NODE_TYPES.ChainExpression ||
500
+ root.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
501
+ root.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
502
+ root = root.expression;
503
+ }
504
+ else {
505
+ break;
506
+ }
507
+ }
508
+ if (root.type === utils_1.AST_NODE_TYPES.Identifier) {
509
+ targets.push(root);
510
+ }
511
+ break;
512
+ }
513
+ case utils_1.AST_NODE_TYPES.ObjectPattern:
514
+ for (const property of target.properties) {
515
+ if (property.type === utils_1.AST_NODE_TYPES.Property) {
516
+ collectAssignmentTarget(property.value, targets);
517
+ }
518
+ else {
519
+ collectAssignmentTarget(property.argument, targets);
520
+ }
521
+ }
522
+ break;
523
+ case utils_1.AST_NODE_TYPES.ArrayPattern:
524
+ for (const element of target.elements) {
525
+ if (element) {
526
+ collectAssignmentTarget(element, targets);
527
+ }
528
+ }
529
+ break;
530
+ case utils_1.AST_NODE_TYPES.RestElement:
531
+ collectAssignmentTarget(target.argument, targets);
532
+ break;
533
+ case utils_1.AST_NODE_TYPES.AssignmentPattern:
534
+ collectAssignmentTarget(target.left, targets);
535
+ break;
536
+ }
537
+ }
538
+ /**
539
+ * Reports whether an assignment target resolves to a binding DECLARED
540
+ * inside the given expression.
541
+ *
542
+ * Such a binding is a fresh local: `async () => { let tmp; tmp = 1; }`
543
+ * publishes nothing to the enclosing scope, so a later await mentioning
544
+ * `tmp` is reading some other binding entirely. An unresolved name (an
545
+ * implicit global) counts as external, which keeps the barrier in place for
546
+ * the case the analysis cannot see.
547
+ */
548
+ function isDeclaredWithin(identifier, root) {
549
+ const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, identifier), identifier.name);
550
+ if (!variable || variable.defs.length === 0) {
551
+ return false;
552
+ }
553
+ return variable.defs.every((definition) => definition.name.range[0] >= root.range[0] &&
554
+ definition.name.range[1] <= root.range[1]);
555
+ }
556
+ /**
557
+ * Collects the identifier names an awaited expression WRITES.
558
+ *
559
+ * The traversal deliberately crosses function boundaries, which is the
560
+ * opposite of what containsSuspendingAwait needs: the write that matters
561
+ * lives inside the callback handed to the awaited call. `await
562
+ * db.runTransaction(async (tx) => { mutator = new Mutator(tx); })` publishes
563
+ * `mutator` to the enclosing scope by the time it settles, so the callback
564
+ * body is part of what that statement does, not a separate deferred unit.
565
+ *
566
+ * A `VariableDeclarator` id is deliberately NOT a write: `const x = ...`
567
+ * inside a callback creates a fresh local binding rather than publishing a
568
+ * value to an outer one, so it cannot be what a later await reads.
569
+ */
570
+ function getAssignedNames(node) {
571
+ const targets = [];
572
+ const visit = (current) => {
573
+ if (current.type === utils_1.AST_NODE_TYPES.AssignmentExpression) {
574
+ collectAssignmentTarget(current.left, targets);
575
+ }
576
+ else if (current.type === utils_1.AST_NODE_TYPES.UpdateExpression) {
577
+ collectAssignmentTarget(current.argument, targets);
578
+ }
579
+ else if ((current.type === utils_1.AST_NODE_TYPES.ForOfStatement ||
580
+ current.type === utils_1.AST_NODE_TYPES.ForInStatement) &&
581
+ current.left.type !== utils_1.AST_NODE_TYPES.VariableDeclaration) {
582
+ // `for (captured of items)` assigns an existing binding on every
583
+ // iteration; only the declaration form introduces a fresh local.
584
+ collectAssignmentTarget(current.left, targets);
585
+ }
586
+ for (const key in current) {
587
+ if (key === 'parent' || key === 'range' || key === 'loc')
588
+ continue;
589
+ const child = current[key];
590
+ if (!child || typeof child !== 'object')
591
+ continue;
592
+ if (Array.isArray(child)) {
593
+ for (const item of child) {
594
+ if (item && typeof item === 'object' && 'type' in item) {
595
+ visit(item);
596
+ }
597
+ }
598
+ }
599
+ else if ('type' in child) {
600
+ visit(child);
601
+ }
602
+ }
603
+ };
604
+ visit(node);
605
+ const names = new Set();
606
+ for (const target of targets) {
607
+ if (!isDeclaredWithin(target, node)) {
608
+ names.add(target.name);
609
+ }
610
+ }
611
+ return names;
612
+ }
477
613
  /**
478
614
  * Checks if there are dependencies between await expressions
479
615
  */
@@ -671,6 +807,37 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
671
807
  return true;
672
808
  }
673
809
  }
810
+ // 10. Closure-write barrier: read after write. An awaited call whose
811
+ // callback ASSIGNS an outer binding carries a data dependency that no
812
+ // value flowing out of the await expresses, so `variableNames` -- which
813
+ // holds only the names the run's own statements DECLARE -- cannot see it.
814
+ // `let mutator; await db.runTransaction(async (tx) => { mutator = new
815
+ // Mutator(tx); }); await mutator?.deleteIfEmptied();` is the shape. The
816
+ // rewrite is silently wrong rather than merely eager: array elements
817
+ // evaluate left to right at construction time, so the second element runs
818
+ // while `mutator` is still undefined -- the optional chain short-circuits
819
+ // and the operation NEVER happens, with no error to reveal it. Dropping
820
+ // the `?.` turns the same rewrite into a TypeError instead. Keyed on a
821
+ // write that a LATER await actually reads, so a callback whose effects
822
+ // nothing downstream observes still parallelizes. (#1723)
823
+ const assignedNames = awaitNodes.map((node) => {
824
+ const awaitExpr = getAwaitExpression(node);
825
+ return awaitExpr
826
+ ? getAssignedNames(awaitExpr.argument)
827
+ : new Set();
828
+ });
829
+ for (let i = 1; i < awaitNodes.length; i++) {
830
+ const currentIds = allIdentifiers[i];
831
+ if (currentIds.size === 0)
832
+ continue;
833
+ for (let j = 0; j < i; j++) {
834
+ for (const written of assignedNames[j]) {
835
+ if (currentIds.has(written)) {
836
+ return true;
837
+ }
838
+ }
839
+ }
840
+ }
674
841
  return false;
675
842
  }
676
843
  /**
@@ -291,18 +291,29 @@ exports.parallelizeLoopAwaits = (0, createRule_1.createRule)({
291
291
  return false;
292
292
  }
293
293
  /**
294
- * Collects all variables declared INSIDE the loop body (at the direct
295
- * body scope, not crossing nested function boundaries). These are
296
- * iteration-local variables.
294
+ * Collects all variables declared INSIDE the loop body, including inside
295
+ * callbacks written there. These are iteration-local variables: nothing
296
+ * they hold outlives the iteration that created them.
297
+ *
298
+ * The walk crosses nested function boundaries because the write scan that
299
+ * consults this set crosses them too. A name both declared and assigned
300
+ * inside a callback (`async () => { let tmp; tmp = 1; }`) publishes nothing
301
+ * to the enclosing scope, so if the set stopped at the boundary the write
302
+ * would read as a cross-iteration dependency and silence the loop. (#1724)
297
303
  */
298
304
  function collectLoopLocalVars(body) {
299
305
  const localVars = new Set();
300
306
  function visit(node, isRoot) {
307
+ // A callback's parameters bind afresh on every invocation, so a write
308
+ // through one (`async (page) => { page.total = 1 }`) reaches whatever
309
+ // the caller handed that call rather than state the iterations share.
301
310
  if (!isRoot &&
302
311
  (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
303
312
  node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
304
313
  node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression)) {
305
- return;
314
+ for (const param of node.params) {
315
+ collectBindingNames(param, localVars);
316
+ }
306
317
  }
307
318
  if (node.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
308
319
  for (const declarator of node.declarations) {
@@ -333,14 +344,70 @@ exports.parallelizeLoopAwaits = (0, createRule_1.createRule)({
333
344
  visit(body, true);
334
345
  return localVars;
335
346
  }
347
+ /**
348
+ * Collects the BINDINGS an assignment target writes through, returning
349
+ * false when the target's root is not a plain binding at all.
350
+ *
351
+ * A member write reaches the object its ROOT names: `box.value = 1` writes
352
+ * through `box`, and `value` is a field label that binds nothing — the same
353
+ * distinction drawn for a non-computed property key (#1688). Counting the
354
+ * label as a written name makes every member write look like a write to an
355
+ * outer binding, which is what hides a callback writing through its own
356
+ * parameter (`async (page) => { page.total = 1 }`).
357
+ *
358
+ * A root the analysis cannot name — `this.count += 1` reaches instance
359
+ * state every iteration shares — returns false, and the caller reads that
360
+ * as an outer write. The plugin prefers a missed report to a spurious one.
361
+ */
362
+ function collectAssignmentTargetNames(target, names) {
363
+ switch (target.type) {
364
+ case utils_1.AST_NODE_TYPES.Identifier:
365
+ names.add(target.name);
366
+ return true;
367
+ case utils_1.AST_NODE_TYPES.MemberExpression:
368
+ return collectAssignmentTargetNames(target.object, names);
369
+ case utils_1.AST_NODE_TYPES.ChainExpression:
370
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
371
+ case utils_1.AST_NODE_TYPES.TSAsExpression:
372
+ return collectAssignmentTargetNames(target.expression, names);
373
+ case utils_1.AST_NODE_TYPES.ObjectPattern: {
374
+ let resolved = true;
375
+ for (const property of target.properties) {
376
+ const inner = property.type === utils_1.AST_NODE_TYPES.RestElement
377
+ ? property.argument
378
+ : property.value;
379
+ if (!collectAssignmentTargetNames(inner, names))
380
+ resolved = false;
381
+ }
382
+ return resolved;
383
+ }
384
+ case utils_1.AST_NODE_TYPES.ArrayPattern: {
385
+ let resolved = true;
386
+ for (const element of target.elements) {
387
+ if (element && !collectAssignmentTargetNames(element, names)) {
388
+ resolved = false;
389
+ }
390
+ }
391
+ return resolved;
392
+ }
393
+ case utils_1.AST_NODE_TYPES.RestElement:
394
+ return collectAssignmentTargetNames(target.argument, names);
395
+ case utils_1.AST_NODE_TYPES.AssignmentPattern:
396
+ return collectAssignmentTargetNames(target.left, names);
397
+ default:
398
+ return false;
399
+ }
400
+ }
336
401
  /**
337
402
  * Detects cross-iteration state patterns that require sequential
338
403
  * execution:
339
404
  *
340
405
  * 1. Accumulator: a variable declared OUTSIDE the loop body (i.e., not
341
- * in localVars) is ASSIGNED inside the loop body. Examples: `total +=
342
- * value`, `cursor = page.nextCursor`, `previousResult = result`. This
343
- * catches running totals, pagination cursors, and chained results.
406
+ * in localVars) is ASSIGNED inside the loop body, whether directly or
407
+ * from inside a callback the body hands to the awaited call. Examples:
408
+ * `total += value`, `cursor = page.nextCursor`, `previousResult =
409
+ * result`. This catches running totals, pagination cursors, and chained
410
+ * results.
344
411
  *
345
412
  * 2. Direct cross-await dependency: a variable declared by an await
346
413
  * inside the loop is then read as an argument to another await in the
@@ -348,28 +415,54 @@ exports.parallelizeLoopAwaits = (0, createRule_1.createRule)({
348
415
  */
349
416
  function hasSequentialDependency(body, loopLocalVars) {
350
417
  // Pattern 1: outer variable is written inside the loop body.
351
- // Collect all assignment targets (left-hand sides of assignments and
352
- // compound assignments) in the loop body.
418
+ // Collect every assignment target — the left-hand side of an assignment
419
+ // or compound assignment, and the operand of an increment in a callback.
353
420
  let foundOuterWrite = false;
354
- function findOuterWrites(node, isRoot) {
421
+ /**
422
+ * Reports whether an assignment target reaches a binding the iterations
423
+ * share rather than one the iteration creates.
424
+ */
425
+ function writesOuterBinding(target) {
426
+ const names = new Set();
427
+ if (!collectAssignmentTargetNames(target, names))
428
+ return true;
429
+ for (const name of names) {
430
+ if (!loopLocalVars.has(name))
431
+ return true;
432
+ }
433
+ return false;
434
+ }
435
+ function findOuterWrites(node, isRoot, inNestedFunction) {
355
436
  if (foundOuterWrite)
356
437
  return;
357
- if (!isRoot &&
358
- (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
359
- node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
360
- node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression)) {
438
+ // The walk deliberately enters callbacks. A write handed to the awaited
439
+ // call is the same data dependency as one written beside it:
440
+ // `await run(item, async (page) => { cursor = page.nextCursor })` has
441
+ // settled and published `cursor` — by the time the iteration ends, so
442
+ // parallel iterations would race exactly as they would over
443
+ // `cursor = await run(item, cursor)`. (#1724)
444
+ const isNested = inNestedFunction ||
445
+ (!isRoot &&
446
+ (node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
447
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
448
+ node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression));
449
+ if (node.type === utils_1.AST_NODE_TYPES.AssignmentExpression &&
450
+ writesOuterBinding(node.left)) {
451
+ foundOuterWrite = true;
361
452
  return;
362
453
  }
363
- if (node.type === utils_1.AST_NODE_TYPES.AssignmentExpression) {
364
- // Collect identifiers on the left-hand side
365
- const lhsIds = new Set();
366
- collectIdentifiers(node.left, lhsIds, false);
367
- for (const id of lhsIds) {
368
- if (!loopLocalVars.has(id)) {
369
- foundOuterWrite = true;
370
- return;
371
- }
372
- }
454
+ // An increment counts only inside a callback. At the loop-body level it
455
+ // is the loop's own step counter — `while (i < n) { await f(items[i]);
456
+ // i++; }` walks the iteration space, and the `Promise.all(items.map(
457
+ // ...))` rewrite subsumes it — whereas a callback steps no iteration:
458
+ // `count++` there folds what the awaited work produced into a binding
459
+ // the whole loop shares, carrying the same dependency as the compound
460
+ // assignment it stands in for. (#1724)
461
+ if (isNested &&
462
+ node.type === utils_1.AST_NODE_TYPES.UpdateExpression &&
463
+ writesOuterBinding(node.argument)) {
464
+ foundOuterWrite = true;
465
+ return;
373
466
  }
374
467
  for (const key in node) {
375
468
  if (key === 'parent' ||
@@ -382,17 +475,17 @@ exports.parallelizeLoopAwaits = (0, createRule_1.createRule)({
382
475
  if (Array.isArray(child)) {
383
476
  for (const item of child) {
384
477
  if (item && typeof item === 'object' && 'type' in item) {
385
- findOuterWrites(item, false);
478
+ findOuterWrites(item, false, isNested);
386
479
  }
387
480
  }
388
481
  }
389
482
  else if ('type' in child) {
390
- findOuterWrites(child, false);
483
+ findOuterWrites(child, false, isNested);
391
484
  }
392
485
  }
393
486
  }
394
487
  }
395
- findOuterWrites(body, true);
488
+ findOuterWrites(body, true, false);
396
489
  if (foundOuterWrite)
397
490
  return true;
398
491
  // Pattern 2: a variable declared by an await is used as arg to another await.
@@ -1 +1,2 @@
1
- export declare const preferNullishCoalescingBooleanProps: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"preferNullishCoalescing", [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
1
+ import { TSESLint } from '@typescript-eslint/utils';
2
+ export declare const preferNullishCoalescingBooleanProps: TSESLint.RuleModule<"preferNullishCoalescing", [], TSESLint.RuleListener>;
@@ -489,6 +489,48 @@ function couldBeNullish(node, checker, parserServices) {
489
489
  // For other expressions, conservatively assume they could be nullish
490
490
  return true;
491
491
  }
492
+ /**
493
+ * ECMAScript forbids `??` from sharing an expression with an unparenthesized
494
+ * `&&`/`||`. Source-level parentheses are not part of an ESTree node's range,
495
+ * so rewriting a whole LogicalExpression drops the parens around its operands —
496
+ * exactly the ones the operator swap makes mandatory. Re-adding them around any
497
+ * logical operand is unconditionally safe: the sub-expression was already
498
+ * evaluated as a unit, so redundant parens cannot change semantics.
499
+ */
500
+ function parenthesizeLogical(text, operand) {
501
+ return operand.type === utils_1.AST_NODE_TYPES.LogicalExpression ? `(${text})` : text;
502
+ }
503
+ /**
504
+ * Detects parentheses that wrap the node itself. They live outside the node's
505
+ * range, so a `replaceText` of the node preserves them and the rewrite needs no
506
+ * parens of its own.
507
+ */
508
+ function isParenthesized(node, sourceCode) {
509
+ const before = sourceCode.getTokenBefore(node);
510
+ const after = sourceCode.getTokenAfter(node);
511
+ return (!!before &&
512
+ !!after &&
513
+ before.type === utils_1.AST_TOKEN_TYPES.Punctuator &&
514
+ before.value === '(' &&
515
+ after.type === utils_1.AST_TOKEN_TYPES.Punctuator &&
516
+ after.value === ')');
517
+ }
518
+ /**
519
+ * A partially converted chain (`a ?? b || c`) is a syntax error just like an
520
+ * unparenthesized operand. Only one fix per overlapping range survives a pass,
521
+ * so converting one link of a `||` chain always leaves the sibling links
522
+ * untouched; parenthesizing the rewritten link keeps the emitted program
523
+ * parseable while later passes convert the remaining links.
524
+ */
525
+ function needsSelfParens(node, sourceCode) {
526
+ const { parent } = node;
527
+ if (!parent ||
528
+ parent.type !== utils_1.AST_NODE_TYPES.LogicalExpression ||
529
+ parent.operator === '??') {
530
+ return false;
531
+ }
532
+ return !isParenthesized(node, sourceCode);
533
+ }
492
534
  exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
493
535
  name: 'prefer-nullish-coalescing-boolean-props',
494
536
  meta: {
@@ -540,7 +582,10 @@ exports.preferNullishCoalescingBooleanProps = (0, createRule_1.createRule)({
540
582
  right: rightText,
541
583
  },
542
584
  fix(fixer) {
543
- return fixer.replaceText(node, `${leftText} ?? ${rightText}`);
585
+ const replacement = `${parenthesizeLogical(leftText, node.left)} ?? ${parenthesizeLogical(rightText, node.right)}`;
586
+ return fixer.replaceText(node, needsSelfParens(node, sourceCode)
587
+ ? `(${replacement})`
588
+ : replacement);
544
589
  },
545
590
  });
546
591
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.105",
3
+ "version": "1.20.107",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,56 @@
1
1
  [
2
+ {
3
+ "version": "1.20.107",
4
+ "date": "2026-08-05T05:11:58.347Z",
5
+ "rules": [
6
+ {
7
+ "name": "parallelize-async-operations",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1723
11
+ ],
12
+ "summary": "treat a callback's write to an outer binding as a dependency (closes #1723)"
13
+ },
14
+ {
15
+ "name": "parallelize-loop-awaits",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1724
19
+ ],
20
+ "summary": "see a callback's write to an outer binding (closes #1724)"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.20.106",
26
+ "date": "2026-08-05T01:12:16.076Z",
27
+ "rules": [
28
+ {
29
+ "name": "consistent-callback-naming",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1719
33
+ ],
34
+ "summary": "stop renaming destructuring keys and reserved words (closes #1719)"
35
+ },
36
+ {
37
+ "name": "no-entire-object-hook-deps",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 1721
41
+ ],
42
+ "summary": "keep the whole object when the member is a method (closes #1721)"
43
+ },
44
+ {
45
+ "name": "prefer-nullish-coalescing-boolean-props",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 1720
49
+ ],
50
+ "summary": "keep the parens ?? requires (closes #1720)"
51
+ }
52
+ ]
53
+ },
2
54
  {
3
55
  "version": "1.20.105",
4
56
  "date": "2026-08-04T22:42:11.381Z",