@blumintinc/eslint-plugin-blumint 1.20.183 → 1.20.184

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.183',
226
+ version: '1.20.184',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -3785,6 +3785,11 @@ const FUNCTION_LIKE_TYPES = new Set([
3785
3785
  /**
3786
3786
  * Visits every descendant of `node` that belongs to the same function scope.
3787
3787
  * Nested functions are handed to `visit` but not descended into.
3788
+ *
3789
+ * `node` itself is never handed to `visit`. A caller that passes a statement
3790
+ * body loses nothing by that, but one that can pass an expression — an arrow's
3791
+ * concise body is the expression, not a statement wrapping it — has to answer
3792
+ * for the handed node on its own (#2169).
3788
3793
  */
3789
3794
  function forEachNodeInOwnScope(node, visit) {
3790
3795
  for (const key of Object.keys(node)) {
@@ -3882,15 +3887,29 @@ function rendersEveryReturn(node) {
3882
3887
  });
3883
3888
  return returned.length > 0 && returned.every(isRenderableValue);
3884
3889
  }
3890
+ /**
3891
+ * Whether the function calls a React hook in its own scope.
3892
+ *
3893
+ * An arrow with a concise body has no statement wrapping the expression, so the
3894
+ * hook call can be `node.body` itself rather than a descendant of it. Reading
3895
+ * only descendants makes the exemption depend on how tersely the component is
3896
+ * written: `() => useThing()` reports while `() => { return useThing(); }` and
3897
+ * `() => wrap(useThing())` — strictly more code, identical meaning — do not
3898
+ * (#2169). `rendersEveryReturn` carries the same concise-body arm.
3899
+ */
3885
3900
  function callsReactHook(node) {
3886
3901
  let found = false;
3887
- forEachNodeInOwnScope(node.body, (child) => {
3888
- if (found || child.type !== utils_1.AST_NODE_TYPES.CallExpression) {
3902
+ const recordHookCall = (candidate) => {
3903
+ if (found || candidate.type !== utils_1.AST_NODE_TYPES.CallExpression) {
3889
3904
  return;
3890
3905
  }
3891
- const name = calleeName(child.callee);
3906
+ const name = calleeName(candidate.callee);
3892
3907
  found = !!name && HOOK_CALL.test(name);
3893
- });
3908
+ };
3909
+ if (node.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
3910
+ recordHookCall(node.body);
3911
+ }
3912
+ forEachNodeInOwnScope(node.body, recordHookCall);
3894
3913
  return found;
3895
3914
  }
3896
3915
  /**
@@ -3915,6 +3934,43 @@ function isComponentReference(identifier) {
3915
3934
  }
3916
3935
  return false;
3917
3936
  }
3937
+ /**
3938
+ * The declaration that owns a member's component evidence. A member spelled as
3939
+ * a TypeScript overload set is one name declared several times: the type-only
3940
+ * signatures carry no body and therefore no evidence of what the member
3941
+ * renders, while the implementation carries all of it. Judging each declaration
3942
+ * on its own reports a rename on the signature line of a component whose
3943
+ * implementation line is exempt — for the same member, whose call sites the
3944
+ * rename would break (#2168).
3945
+ *
3946
+ * Resolution is syntactic and same-file: the sibling of the same kind, key and
3947
+ * staticness that declares a body. A signature with no implementation resolves
3948
+ * to itself — an ambient declaration has nothing else to defer to, and answers
3949
+ * on the evidence its own name and annotation carry.
3950
+ */
3951
+ function componentEvidenceOwner(node) {
3952
+ // A declaration that carries a body carries its own evidence and answers for
3953
+ // itself, so only a signature ever looks past the member it is written on.
3954
+ if (node.value.type !== utils_1.AST_NODE_TYPES.TSEmptyBodyFunctionExpression) {
3955
+ return node;
3956
+ }
3957
+ if (node.computed || node.key.type !== utils_1.AST_NODE_TYPES.Identifier) {
3958
+ return node;
3959
+ }
3960
+ const classBody = node.parent;
3961
+ if (classBody?.type !== utils_1.AST_NODE_TYPES.ClassBody) {
3962
+ return node;
3963
+ }
3964
+ const { name } = node.key;
3965
+ const implementation = classBody.body.find((member) => member.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
3966
+ member.kind === node.kind &&
3967
+ member.static === node.static &&
3968
+ !member.computed &&
3969
+ member.key.type === utils_1.AST_NODE_TYPES.Identifier &&
3970
+ member.key.name === name &&
3971
+ member.value.type !== utils_1.AST_NODE_TYPES.TSEmptyBodyFunctionExpression);
3972
+ return implementation ?? node;
3973
+ }
3918
3974
  exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
3919
3975
  name: 'enforce-verb-noun-naming',
3920
3976
  meta: {
@@ -4075,7 +4131,12 @@ exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
4075
4131
  return node.id.name;
4076
4132
  }
4077
4133
  if (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
4078
- node.type === utils_1.AST_NODE_TYPES.FunctionExpression) {
4134
+ node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
4135
+ // A type-only overload signature parses as an empty-bodied function
4136
+ // expression. It holds its name on the member key exactly as the
4137
+ // implementation beside it does, so the name-keyed component evidence
4138
+ // has to reach it the same way (#2168).
4139
+ node.type === utils_1.AST_NODE_TYPES.TSEmptyBodyFunctionExpression) {
4079
4140
  const parent = node.parent;
4080
4141
  if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
4081
4142
  parent.id.type === utils_1.AST_NODE_TYPES.Identifier) {
@@ -4165,11 +4226,20 @@ exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
4165
4226
  function isReactComponent(node) {
4166
4227
  if (node.type !== utils_1.AST_NODE_TYPES.FunctionDeclaration &&
4167
4228
  node.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
4168
- node.type !== utils_1.AST_NODE_TYPES.FunctionExpression) {
4229
+ node.type !== utils_1.AST_NODE_TYPES.FunctionExpression &&
4230
+ // A type-only overload signature declares the same member as the
4231
+ // implementation beside it, so the carve-out has to reach it (#2168).
4232
+ node.type !== utils_1.AST_NODE_TYPES.TSEmptyBodyFunctionExpression) {
4169
4233
  return false;
4170
4234
  }
4235
+ // A signature declares no body, so every piece of evidence read out of
4236
+ // one — what it renders, the hooks it calls — is unavailable rather than
4237
+ // absent. What it renders is settled by the implementation instead.
4238
+ const bodied = node.type === utils_1.AST_NODE_TYPES.TSEmptyBodyFunctionExpression
4239
+ ? undefined
4240
+ : node;
4171
4241
  const functionName = getFunctionName(node);
4172
- const returnsJsx = ASTHelpers_1.ASTHelpers.returnsJSX(node.body, context);
4242
+ const returnsJsx = ASTHelpers_1.ASTHelpers.returnsJSX(bodied?.body, context);
4173
4243
  const hasProps = hasPropsParameter(node);
4174
4244
  const hasReactType = hasReactTypeAnnotation(node);
4175
4245
  const isUnmemoized = !!functionName && functionName.endsWith('Unmemoized');
@@ -4193,9 +4263,10 @@ exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
4193
4263
  // demanding a rename that would break every JSX call site. A component
4194
4264
  // is therefore also recognised by what it renders, by the hooks it calls,
4195
4265
  // and by how the rest of the file uses it.
4196
- if (!isGeneratorFunction(node) &&
4197
- (rendersEveryReturn(node) ||
4198
- callsReactHook(node) ||
4266
+ if (bodied &&
4267
+ !isGeneratorFunction(bodied) &&
4268
+ (rendersEveryReturn(bodied) ||
4269
+ callsReactHook(bodied) ||
4199
4270
  isUsedAsReactComponent(node, functionName))) {
4200
4271
  return true;
4201
4272
  }
@@ -4300,7 +4371,12 @@ exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
4300
4371
  // that is a component answers to it too. A `set` accessor is an
4301
4372
  // assignment target rather than a callable, so it can never be a
4302
4373
  // component and is deliberately left to the naming demand.
4303
- if (node.kind === 'method' && isReactComponent(node.value)) {
4374
+ //
4375
+ // The whole overload set answers with one voice, so a type-only
4376
+ // signature is judged by the implementation that gives the member its
4377
+ // body rather than by the nothing it renders itself.
4378
+ if (node.kind === 'method' &&
4379
+ isReactComponent(componentEvidenceOwner(node).value)) {
4304
4380
  return;
4305
4381
  }
4306
4382
  if (!isVerbPhrase(node.key.name)) {
@@ -542,6 +542,103 @@ function callsCorrespondingSetter(hookBody, dependencyName) {
542
542
  }
543
543
  return visit(hookBody);
544
544
  }
545
+ /**
546
+ * The single property a member expression reads, or null when the key is
547
+ * dynamic.
548
+ *
549
+ * A literal string key is the same read as the dotted spelling — `ref['current']`
550
+ * and `ref.current` reach the identical slot — so both answer with the name.
551
+ */
552
+ function staticPropertyName(node) {
553
+ if (!node.computed) {
554
+ return node.property.type === utils_1.AST_NODE_TYPES.Identifier
555
+ ? node.property.name
556
+ : null;
557
+ }
558
+ return node.property.type === utils_1.AST_NODE_TYPES.Literal &&
559
+ typeof node.property.value === 'string'
560
+ ? node.property.value
561
+ : null;
562
+ }
563
+ /** The one property a React ref object carries. */
564
+ const REF_PROPERTY = 'current';
565
+ /**
566
+ * Whether every read of `objectName` inside the hook body goes through
567
+ * `.current` — the syntactic signature of a React ref object.
568
+ *
569
+ * why: a ref is the one dependency a hook is meant to list whole. React writes
570
+ * `ref.current` during commit, after the render that evaluated the dependency
571
+ * array, so narrowing `[ref]` to `[ref.current]` pins the value the renderer
572
+ * has not written yet and the hook never re-runs when it later does — the
573
+ * mount-time registration effect silently registers nothing. React's own
574
+ * `react-hooks/exhaustive-deps` rejects that narrowed array outright ("Mutable
575
+ * values like 'ref.current' aren't valid dependencies"), so emitting it puts
576
+ * two recommended rules in direct contradiction and `--fix` oscillates between
577
+ * them (#2170).
578
+ *
579
+ * The rule's motivation also lapses here: it warns that a sibling property
580
+ * changing re-runs the hook needlessly, and a ref object has no sibling
581
+ * property. Recognising the ref by its access shape rather than by its type
582
+ * covers a ref arriving through a prop type no program can resolve, which is
583
+ * the shape the `RuleTester` and every untyped consumer actually see.
584
+ *
585
+ * A chain rooted at `.current` (`ref.current.scrollTop`) counts as a ref read:
586
+ * every link past the first is reachable only once the commit has populated the
587
+ * ref, so narrowing there is the same defect one link deeper — and it would
588
+ * additionally throw when the array dereferences a null `current` on the first
589
+ * render.
590
+ */
591
+ function readsObjectOnlyAsRef(hookBody, objectName) {
592
+ const visited = new Set();
593
+ let readsCurrent = false;
594
+ let readsAnythingElse = false;
595
+ function visit(node) {
596
+ if (!node || visited.has(node) || readsAnythingElse)
597
+ return;
598
+ visited.add(node);
599
+ if (node.type === utils_1.AST_NODE_TYPES.Identifier && node.name === objectName) {
600
+ // The wrappers that can sit between the identifier and the access it
601
+ // belongs to — `(ref as RefObject<T>).current`, `ref!.current`,
602
+ // `ref?.current` — are skipped so the read is attributed to its real
603
+ // context, exactly as the usage collector does.
604
+ let wrapperNode = node;
605
+ let effectiveParent = node.parent;
606
+ while (effectiveParent &&
607
+ (effectiveParent.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
608
+ effectiveParent.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
609
+ effectiveParent.type === utils_1.AST_NODE_TYPES.ChainExpression ||
610
+ effectiveParent.type === utils_1.AST_NODE_TYPES.TSNonNullExpression)) {
611
+ wrapperNode = effectiveParent;
612
+ effectiveParent = effectiveParent.parent;
613
+ }
614
+ // `other.objectName` and `{ objectName: value }` name a different slot
615
+ // and a label respectively, so neither is a read of this dependency.
616
+ const isMemberProperty = effectiveParent?.type === utils_1.AST_NODE_TYPES.MemberExpression &&
617
+ effectiveParent.property === wrapperNode &&
618
+ !effectiveParent.computed;
619
+ const isPropertyKey = effectiveParent?.type === utils_1.AST_NODE_TYPES.Property &&
620
+ effectiveParent.key === wrapperNode &&
621
+ !effectiveParent.computed &&
622
+ !effectiveParent.shorthand;
623
+ if (!isMemberProperty && !isPropertyKey) {
624
+ if (effectiveParent?.type === utils_1.AST_NODE_TYPES.MemberExpression &&
625
+ effectiveParent.object === wrapperNode &&
626
+ staticPropertyName(effectiveParent) === REF_PROPERTY) {
627
+ readsCurrent = true;
628
+ }
629
+ else {
630
+ // Any other read — a second property, a bare reference handed to a
631
+ // call, a spread — proves the value is not a ref, so the ordinary
632
+ // narrowing applies.
633
+ readsAnythingElse = true;
634
+ }
635
+ }
636
+ }
637
+ forEachChildNode(node, visit);
638
+ }
639
+ visit(hookBody);
640
+ return readsCurrent && !readsAnythingElse;
641
+ }
545
642
  function getObjectUsagesInHook(hookBody, objectName, typeInfo, bodyIsDeferred = false) {
546
643
  const usages = new Map(); // Track usage and its position
547
644
  // why: derived dependency paths (first-optional intermediate, array base)
@@ -1266,6 +1363,12 @@ exports.noEntireObjectHookDeps = (0, createRule_1.createRule)({
1266
1363
  // If we found specific field usages and the entire object is in deps
1267
1364
  // Skip reporting if needsEntireObject is true (indicates spread operator usage)
1268
1365
  else if (result.usages.size > 0 && !result.needsEntireObject) {
1366
+ // A ref object has no narrowing target: `[ref.current]` reads a
1367
+ // slot React fills after the render that evaluated the array, so
1368
+ // the whole ref is the correct dependency (#2170).
1369
+ if (readsObjectOnlyAsRef(callbackBody, objectName)) {
1370
+ return;
1371
+ }
1269
1372
  const fields = Array.from(result.usages).join(', ');
1270
1373
  context.report({
1271
1374
  node: element,
@@ -129,72 +129,192 @@ function requiredModuleSource(node) {
129
129
  : null;
130
130
  }
131
131
  /**
132
- * Every binding in the file's module scope that denotes something loaded from a
133
- * filesystem module, mapped to the EXPORTED name it denotes when it denotes a
134
- * single operation (`import { writeFile as wf }` -> `wf` denotes `writeFile`),
135
- * and to null when it denotes the module object itself (a namespace, default or
136
- * whole-module `require` binding, whose operation is named at the call site
137
- * instead).
132
+ * Every variable the file declares, in every scope.
138
133
  *
139
- * The exported name is what a bare callee is classified by, so a renamed import
140
- * classifies as the operation it actually calls rather than falling to the
141
- * mutating default on a name the fs surface never had.
134
+ * A filesystem binding is not a property of the module scope: a function-scoped
135
+ * `const { writeFile } = require('node:fs/promises')` reaches the same
136
+ * filesystem as a top-level one, and scanning only `Program.body` finds no
137
+ * binding for it at all. (#2167)
138
+ */
139
+ function allScopeVariables(scopeManager) {
140
+ const globalScope = scopeManager?.globalScope;
141
+ if (!globalScope) {
142
+ return [];
143
+ }
144
+ const variables = [];
145
+ const stack = [globalScope];
146
+ while (stack.length > 0) {
147
+ const scope = stack.pop();
148
+ variables.push(...scope.variables);
149
+ stack.push(...scope.childScopes);
150
+ }
151
+ return variables;
152
+ }
153
+ /**
154
+ * Walks a member chain down to the expression it is rooted at.
155
+ *
156
+ * `create` declares its own `getPathRoot` for the same purpose; this one exists
157
+ * because binding collection runs at module scope, where that closure is out of
158
+ * reach.
159
+ */
160
+ function memberChainRoot(node) {
161
+ let root = unwrapWrappers(node);
162
+ while (root.type === utils_1.AST_NODE_TYPES.MemberExpression) {
163
+ root = unwrapWrappers(root.object);
164
+ }
165
+ return root;
166
+ }
167
+ /**
168
+ * The operation a destructuring leaf reads out of the container it destructures
169
+ * (`const { promises: { writeFile } } = fs` -> `writeFile`), and null when the
170
+ * pattern binds the container itself.
171
+ *
172
+ * The leaf's IMMEDIATE property key is the answer at every depth, which is what
173
+ * lets a nested pattern need no case of its own: that key is the last member a
174
+ * member-expression spelling of the same access would have carried. Reading the
175
+ * OUTER pattern instead is what dropped `{ promises: { writeFile } }`. (#2167)
176
+ *
177
+ * A computed key names an operation the source does not state, so it falls back
178
+ * to the module-object answer, under which a bare callee classifies by its
179
+ * local spelling and takes the mutating default.
180
+ */
181
+ function destructuredOperation(name) {
182
+ // `const { writeFile = fallback } = fsp` binds through an AssignmentPattern,
183
+ // which sits between the leaf and the property that names it.
184
+ const bound = name.parent?.type === utils_1.AST_NODE_TYPES.AssignmentPattern &&
185
+ name.parent.left === name
186
+ ? name.parent
187
+ : name;
188
+ const property = bound.parent;
189
+ if (property?.type !== utils_1.AST_NODE_TYPES.Property ||
190
+ property.value !== bound ||
191
+ property.computed) {
192
+ return null;
193
+ }
194
+ if (property.key.type === utils_1.AST_NODE_TYPES.Identifier) {
195
+ return property.key.name;
196
+ }
197
+ return property.key.type === utils_1.AST_NODE_TYPES.Literal &&
198
+ typeof property.key.value === 'string'
199
+ ? property.key.value
200
+ : null;
201
+ }
202
+ /**
203
+ * Resolves every binding in the file that reaches a filesystem module.
142
204
  *
143
- * Resolution is lexical and same-file by design: `RuleTester` runs with no
144
- * `parserOptions.project`, so a type-aware answer is unavailable exactly where
145
- * the barrier has to be proven.
205
+ * Collection is a FIXPOINT rather than a single pass, because a binding's
206
+ * origin can be another binding: `const fs = require('fs')` followed by
207
+ * `const { rename, writeFile } = fs.promises` roots the second declarator at
208
+ * the first one rather than at a literal `require` call, so a pass that
209
+ * re-derives the origin from syntax alone finds no filesystem there. The
210
+ * spelling is what ESM leaves a consumer with (`import fs from 'node:fs'`),
211
+ * and losing it withdraws the ordering barrier -- the unsafe direction, since
212
+ * it fuses a write with the operation whose precondition that write is -- so
213
+ * the pass repeats until it classifies nothing further. (#2167)
146
214
  */
147
- function collectFsBindings(program) {
215
+ function collectFsBindings(scopeManager) {
216
+ const variables = allScopeVariables(scopeManager);
148
217
  const bindings = new Map();
149
- const recordPattern = (id) => {
150
- if (id.type === utils_1.AST_NODE_TYPES.Identifier) {
151
- bindings.set(id.name, null);
152
- return;
218
+ // Scope analysis resolves each use site to the variable it reads, which is
219
+ // what keeps an inner binding from inheriting an outer one's origin.
220
+ const variableOf = new Map();
221
+ for (const variable of variables) {
222
+ for (const reference of variable.references) {
223
+ variableOf.set(reference.identifier, variable);
153
224
  }
154
- if (id.type !== utils_1.AST_NODE_TYPES.ObjectPattern) {
155
- return;
225
+ }
226
+ const lookup = (identifier) => {
227
+ const variable = variableOf.get(identifier);
228
+ if (!variable || !bindings.has(variable)) {
229
+ return undefined;
156
230
  }
157
- for (const property of id.properties) {
158
- if (property.type !== utils_1.AST_NODE_TYPES.Property ||
159
- property.computed ||
160
- property.key.type !== utils_1.AST_NODE_TYPES.Identifier) {
161
- continue;
162
- }
163
- const local = property.value;
164
- if (local.type === utils_1.AST_NODE_TYPES.Identifier) {
165
- bindings.set(local.name, property.key.name);
166
- }
231
+ return { operation: bindings.get(variable) ?? null };
232
+ };
233
+ const importedBinding = (definition) => {
234
+ if (definition.type !== utils_1.TSESLint.Scope.DefinitionType.ImportBinding) {
235
+ return undefined;
236
+ }
237
+ const { node } = definition;
238
+ if (node.type === utils_1.AST_NODE_TYPES.TSImportEqualsDeclaration) {
239
+ // `import fs = require('fs')` binds the module object through a
240
+ // declaration of its own rather than through a specifier.
241
+ const { moduleReference } = node;
242
+ const source = moduleReference.type === utils_1.AST_NODE_TYPES.TSExternalModuleReference &&
243
+ moduleReference.expression.type === utils_1.AST_NODE_TYPES.Literal &&
244
+ typeof moduleReference.expression.value === 'string'
245
+ ? moduleReference.expression.value
246
+ : null;
247
+ return source !== null && FS_MODULE_SOURCES.has(source)
248
+ ? { operation: null }
249
+ : undefined;
167
250
  }
251
+ const declaration = definition.parent;
252
+ if (declaration?.type !== utils_1.AST_NODE_TYPES.ImportDeclaration ||
253
+ !FS_MODULE_SOURCES.has(declaration.source.value)) {
254
+ return undefined;
255
+ }
256
+ // A namespace or default specifier binds the module OBJECT, whose operation
257
+ // is named at the call site, so it records no exported name.
258
+ return {
259
+ operation: node.type === utils_1.AST_NODE_TYPES.ImportSpecifier
260
+ ? node.imported.name
261
+ : null,
262
+ };
168
263
  };
169
- for (const statement of program.body) {
170
- const declaration = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration &&
171
- statement.declaration
172
- ? statement.declaration
173
- : statement;
174
- if (declaration.type === utils_1.AST_NODE_TYPES.ImportDeclaration) {
175
- if (!FS_MODULE_SOURCES.has(declaration.source.value)) {
264
+ const declaredBinding = (definition) => {
265
+ if (definition.type !== utils_1.TSESLint.Scope.DefinitionType.Variable) {
266
+ return undefined;
267
+ }
268
+ const { init, id } = definition.node;
269
+ if (!init) {
270
+ return undefined;
271
+ }
272
+ const source = requiredModuleSource(init);
273
+ if (source !== null && FS_MODULE_SOURCES.has(source)) {
274
+ return { operation: destructuredOperation(definition.name) };
275
+ }
276
+ const root = memberChainRoot(init);
277
+ if (root.type !== utils_1.AST_NODE_TYPES.Identifier) {
278
+ return undefined;
279
+ }
280
+ const rooted = lookup(root);
281
+ if (!rooted) {
282
+ return undefined;
283
+ }
284
+ // A bare alias (`const wf = writeFile`) denotes exactly what it aliases, so
285
+ // it carries that classification over rather than taking the module-object
286
+ // answer a member access leaves behind.
287
+ return unwrapWrappers(init) === root && definition.name === id
288
+ ? rooted
289
+ : { operation: destructuredOperation(definition.name) };
290
+ };
291
+ let classified = true;
292
+ while (classified) {
293
+ classified = false;
294
+ for (const variable of variables) {
295
+ if (bindings.has(variable)) {
176
296
  continue;
177
297
  }
178
- for (const specifier of declaration.specifiers) {
179
- // A namespace or default specifier binds the module OBJECT, whose
180
- // operation is named at the call site, so it records no exported name.
181
- bindings.set(specifier.local.name, specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier
182
- ? specifier.imported.name
183
- : null);
184
- }
185
- continue;
186
- }
187
- if (declaration.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
188
- for (const declarator of declaration.declarations) {
189
- const source = requiredModuleSource(declarator.init);
190
- if (source && FS_MODULE_SOURCES.has(source)) {
191
- recordPattern(declarator.id);
298
+ for (const definition of variable.defs) {
299
+ const binding = importedBinding(definition) ?? declaredBinding(definition);
300
+ if (!binding) {
301
+ continue;
192
302
  }
303
+ bindings.set(variable, binding.operation);
304
+ classified = true;
305
+ break;
193
306
  }
194
307
  }
195
308
  }
196
- return bindings;
309
+ return lookup;
197
310
  }
311
+ /**
312
+ * The promise combinators a call can be chained with without changing WHICH
313
+ * operation it performs. `writeFile(pending, data).catch(handle)` writes the
314
+ * same file the bare call does, so the chain is stripped before the callee is
315
+ * rooted. (#2167)
316
+ */
317
+ const PROMISE_CHAIN_METHODS = new Set(['then', 'catch', 'finally']);
198
318
  /**
199
319
  * Matches prettier's own default. The autofix authors a whole statement a
200
320
  * formatter owns, so a layout it emits that prettier would not is rewritten on
@@ -375,15 +495,14 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
375
495
  return {};
376
496
  }
377
497
  const sourceCode = context.sourceCode;
378
- // The file's filesystem bindings are a property of its module scope, not of
379
- // any one run, so they are resolved once per file and reused by every
498
+ // The file's filesystem bindings are a property of the file's scopes, not
499
+ // of any one run, so they are resolved once per file and reused by every
380
500
  // candidate run the traversal reaches.
381
501
  let fsBindings = null;
382
502
  const getFsBindings = () => {
383
- if (fsBindings === null) {
384
- fsBindings = collectFsBindings(sourceCode.ast);
385
- }
386
- return fsBindings;
503
+ const resolved = fsBindings ?? collectFsBindings(sourceCode.scopeManager);
504
+ fsBindings = resolved;
505
+ return resolved;
387
506
  };
388
507
  // The width the autofix lays the rewritten statement out against. It lives
389
508
  // in the consumer's formatter configuration, which no rule context carries,
@@ -1539,6 +1658,38 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1539
1658
  // every such write is by construction published to the outer scope.
1540
1659
  return { names, instancePaths: new Set(targets.instancePaths) };
1541
1660
  }
1661
+ /**
1662
+ * Strips the promise combinators a chained call is wrapped in, so the call
1663
+ * UNDERNEATH is what gets classified.
1664
+ *
1665
+ * `await writeFile(pending, data).catch(handle)` performs exactly the write
1666
+ * the bare spelling does. Rooting the outer callee walks to the inner CALL
1667
+ * rather than to a binding, which names no filesystem operation at all and
1668
+ * withdraws the ordering barrier -- the unsafe direction -- so the receiver
1669
+ * of a `then`/`catch`/`finally` is unwrapped before the callee is rooted.
1670
+ * (#2167)
1671
+ */
1672
+ function unwrapPromiseChain(node) {
1673
+ let current = unwrapExpression(node);
1674
+ while (current.type === utils_1.AST_NODE_TYPES.CallExpression) {
1675
+ const callee = unwrapExpression(current.callee);
1676
+ if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
1677
+ break;
1678
+ }
1679
+ const { property } = callee;
1680
+ const method = !callee.computed && property.type === utils_1.AST_NODE_TYPES.Identifier
1681
+ ? property.name
1682
+ : property.type === utils_1.AST_NODE_TYPES.Literal &&
1683
+ typeof property.value === 'string'
1684
+ ? property.value
1685
+ : null;
1686
+ if (method === null || !PROMISE_CHAIN_METHODS.has(method)) {
1687
+ break;
1688
+ }
1689
+ current = unwrapExpression(callee.object);
1690
+ }
1691
+ return current;
1692
+ }
1542
1693
  /**
1543
1694
  * Names the filesystem operation an await performs, or null when the await
1544
1695
  * does not reach the filesystem through a binding this file declares.
@@ -1556,7 +1707,7 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1556
1707
  * classifies as mutating, matching the fail-safe the allowlist is read with.
1557
1708
  */
1558
1709
  function getFsOperationName(awaitExpr) {
1559
- const argument = unwrapExpression(awaitExpr.argument);
1710
+ const argument = unwrapPromiseChain(awaitExpr.argument);
1560
1711
  if (argument.type !== utils_1.AST_NODE_TYPES.CallExpression) {
1561
1712
  return null;
1562
1713
  }
@@ -1565,8 +1716,8 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1565
1716
  if (root.type !== utils_1.AST_NODE_TYPES.Identifier) {
1566
1717
  return null;
1567
1718
  }
1568
- const bindings = getFsBindings();
1569
- if (!bindings.has(root.name)) {
1719
+ const binding = getFsBindings()(root);
1720
+ if (!binding) {
1570
1721
  return null;
1571
1722
  }
1572
1723
  if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
@@ -1580,7 +1731,7 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1580
1731
  }
1581
1732
  return '';
1582
1733
  }
1583
- return bindings.get(root.name) ?? root.name;
1734
+ return binding.operation ?? root.name;
1584
1735
  }
1585
1736
  /**
1586
1737
  * Checks if there are dependencies between await expressions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.183",
3
+ "version": "1.20.184",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,35 @@
1
1
  [
2
+ {
3
+ "version": "1.20.184",
4
+ "date": "2026-08-27T22:07:43.784Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-verb-noun-naming",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 2168,
11
+ 2169
12
+ ],
13
+ "summary": "reach the hook call a concise arrow body IS (closes #2169); defer an overload signature to its implementation (closes #2168)"
14
+ },
15
+ {
16
+ "name": "no-entire-object-hook-deps",
17
+ "changeType": "fix",
18
+ "issues": [
19
+ 2170
20
+ ],
21
+ "summary": "keep a ref dependency whole (closes #2170)"
22
+ },
23
+ {
24
+ "name": "parallelize-async-operations",
25
+ "changeType": "fix",
26
+ "issues": [
27
+ 2167
28
+ ],
29
+ "summary": "resolve fs bindings as a scope-aware fixpoint (closes #2167)"
30
+ }
31
+ ]
32
+ },
2
33
  {
3
34
  "version": "1.20.183",
4
35
  "date": "2026-08-27T17:21:40.008Z",