@blumintinc/eslint-plugin-blumint 1.20.182 → 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.182',
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) {
@@ -4089,6 +4150,16 @@ exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
4089
4150
  parent.key.type === utils_1.AST_NODE_TYPES.Identifier) {
4090
4151
  return parent.key.name;
4091
4152
  }
4153
+ // A method holds its name on the member key exactly as a field does.
4154
+ // Without this arm a method's `FunctionExpression` is anonymous, and an
4155
+ // anonymous function never reaches the PascalCase component evidence —
4156
+ // leaving `Panel() { return <div />; }` judged by the weak
4157
+ // props-and-JSX fallback alone, which a parameterless component fails.
4158
+ if (parent?.type === utils_1.AST_NODE_TYPES.MethodDefinition &&
4159
+ !parent.computed &&
4160
+ parent.key.type === utils_1.AST_NODE_TYPES.Identifier) {
4161
+ return parent.key.name;
4162
+ }
4092
4163
  }
4093
4164
  return '';
4094
4165
  }
@@ -4134,12 +4205,15 @@ exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
4134
4205
  * scope analysis, which records JSX element names as references.
4135
4206
  */
4136
4207
  function isUsedAsReactComponent(node, functionName) {
4137
- // A class field's name is a member, not a lexical binding, so a variable
4208
+ // A class member's name is a member, not a lexical binding, so a variable
4138
4209
  // of the same name found in scope belongs to some other symbol entirely
4139
- // and says nothing about the field. `<this.Foo />` is a member expression
4140
- // and records no reference to resolve, so the field relies on the other
4141
- // component evidence.
4142
- if (node.parent?.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
4210
+ // and says nothing about the member. `<this.Foo />` is a member expression
4211
+ // and records no reference to resolve, so the member relies on the other
4212
+ // component evidence. This holds for a method as much as for a field:
4213
+ // resolving `Panel` lexically from inside a class would answer with an
4214
+ // imported component of that name.
4215
+ if (node.parent?.type === utils_1.AST_NODE_TYPES.PropertyDefinition ||
4216
+ node.parent?.type === utils_1.AST_NODE_TYPES.MethodDefinition) {
4143
4217
  return false;
4144
4218
  }
4145
4219
  const scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
@@ -4152,11 +4226,20 @@ exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
4152
4226
  function isReactComponent(node) {
4153
4227
  if (node.type !== utils_1.AST_NODE_TYPES.FunctionDeclaration &&
4154
4228
  node.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
4155
- 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) {
4156
4233
  return false;
4157
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;
4158
4241
  const functionName = getFunctionName(node);
4159
- const returnsJsx = ASTHelpers_1.ASTHelpers.returnsJSX(node.body, context);
4242
+ const returnsJsx = ASTHelpers_1.ASTHelpers.returnsJSX(bodied?.body, context);
4160
4243
  const hasProps = hasPropsParameter(node);
4161
4244
  const hasReactType = hasReactTypeAnnotation(node);
4162
4245
  const isUnmemoized = !!functionName && functionName.endsWith('Unmemoized');
@@ -4180,9 +4263,10 @@ exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
4180
4263
  // demanding a rename that would break every JSX call site. A component
4181
4264
  // is therefore also recognised by what it renders, by the hooks it calls,
4182
4265
  // and by how the rest of the file uses it.
4183
- if (!isGeneratorFunction(node) &&
4184
- (rendersEveryReturn(node) ||
4185
- callsReactHook(node) ||
4266
+ if (bodied &&
4267
+ !isGeneratorFunction(bodied) &&
4268
+ (rendersEveryReturn(bodied) ||
4269
+ callsReactHook(bodied) ||
4186
4270
  isUsedAsReactComponent(node, functionName))) {
4187
4271
  return true;
4188
4272
  }
@@ -4281,6 +4365,20 @@ exports.enforceVerbNounNaming = (0, createRule_1.createRule)({
4281
4365
  // Skip constructors since they are special class methods
4282
4366
  if (node.kind === 'constructor')
4283
4367
  return;
4368
+ // A component is a noun by convention, and `Panel() { return <div />; }`
4369
+ // is the same member as `Panel = () => <div />` with one token changed.
4370
+ // The carve-out reaches every other spelling of a function, so a method
4371
+ // that is a component answers to it too. A `set` accessor is an
4372
+ // assignment target rather than a callable, so it can never be a
4373
+ // component and is deliberately left to the naming demand.
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)) {
4380
+ return;
4381
+ }
4284
4382
  if (!isVerbPhrase(node.key.name)) {
4285
4383
  context.report({
4286
4384
  node: node.key,
@@ -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,
@@ -22,6 +22,299 @@ const TEST_FILE_DIRECTORY = /(^|\/)(__tests__|__mocks__)\//;
22
22
  * assertion against the interaction (issue #1395).
23
23
  */
24
24
  const isTestFile = (filename) => TEST_FILE_SUFFIX.test(filename) || TEST_FILE_DIRECTORY.test(filename);
25
+ /**
26
+ * Module specifiers whose exports operate the filesystem.
27
+ *
28
+ * The `node:`-prefixed and bare spellings name the same built-in module, and
29
+ * `graceful-fs` is a drop-in wrapper over it, so a run mixing the spellings
30
+ * still touches ONE resource. Membership is by specifier rather than by callee
31
+ * name because the name alone proves nothing: a project's own `writeFile`
32
+ * helper shares the name while sharing no resource.
33
+ */
34
+ const FS_MODULE_SOURCES = new Set([
35
+ 'fs',
36
+ 'node:fs',
37
+ 'fs/promises',
38
+ 'node:fs/promises',
39
+ 'graceful-fs',
40
+ ]);
41
+ /**
42
+ * Filesystem operations that only OBSERVE the filesystem.
43
+ *
44
+ * Two observations commute -- neither can change what the other returns -- so a
45
+ * run of them carries no ordering and stays parallelizable, which is where this
46
+ * rule's value lies for I/O-bound reads. Every other operation is treated as
47
+ * mutating.
48
+ *
49
+ * The set is an allowlist read fail-safe, so an operation it does not know
50
+ * counts as mutating. The failure directions are not symmetric: misreading a
51
+ * mutation as an observation races a write against its own precondition and
52
+ * ships a silent corruption, whereas misreading an observation as a mutation
53
+ * only declines a parallelization. An fs surface this list does not enumerate
54
+ * therefore keeps the barrier.
55
+ */
56
+ const READ_ONLY_FS_OPERATIONS = new Set([
57
+ 'readFile',
58
+ 'readdir',
59
+ 'stat',
60
+ 'lstat',
61
+ 'fstat',
62
+ 'access',
63
+ 'realpath',
64
+ 'readlink',
65
+ 'opendir',
66
+ 'exists',
67
+ ]);
68
+ /**
69
+ * The `*Sync` variant of an fs operation performs the same operation on the
70
+ * same resource, so it classifies identically to the asynchronous spelling and
71
+ * the suffix is dropped before the lookup. This keeps the allowlist to one
72
+ * entry per operation, which is what stops a `readFileSync` omission from
73
+ * quietly reclassifying a read as a mutation.
74
+ */
75
+ const SYNC_OPERATION_SUFFIX = /Sync$/;
76
+ function isReadOnlyFsOperation(operation) {
77
+ return READ_ONLY_FS_OPERATIONS.has(operation.replace(SYNC_OPERATION_SUFFIX, ''));
78
+ }
79
+ /**
80
+ * Strips the wrappers that carry no value of their own, so a shape test reaches
81
+ * the expression the source actually denotes.
82
+ *
83
+ * `a?.b` is wrapped in a ChainExpression, so a bare `MemberExpression` or
84
+ * `CallExpression` test sees the wrapper instead and answers no. Here that
85
+ * answer is the UNSAFE one -- an unrecognised `require` spelling yields no
86
+ * filesystem binding, which withdraws the ordering barrier rather than merely
87
+ * declining a parallelization -- so the optional spellings are unwrapped to the
88
+ * same node their non-optional spellings produce. `create` declares its own
89
+ * `unwrapExpression` for the same purpose; this one exists because binding
90
+ * collection runs at module scope, where that closure is out of reach.
91
+ */
92
+ function unwrapWrappers(node) {
93
+ let current = node;
94
+ while (current.type === utils_1.AST_NODE_TYPES.ChainExpression ||
95
+ current.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
96
+ current.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
97
+ current = current.expression;
98
+ }
99
+ return current;
100
+ }
101
+ /**
102
+ * The module specifier a `require(...)` call loads, when the call names it as a
103
+ * string literal.
104
+ *
105
+ * `require('fs').promises` is the same load one member deeper, so a member
106
+ * expression rooted at the call answers with the call's own specifier: the
107
+ * binding it produces still reaches the filesystem.
108
+ */
109
+ function requiredModuleSource(node) {
110
+ if (!node) {
111
+ return null;
112
+ }
113
+ const unwrapped = unwrapWrappers(node);
114
+ if (unwrapped.type === utils_1.AST_NODE_TYPES.MemberExpression) {
115
+ return requiredModuleSource(unwrapped.object);
116
+ }
117
+ if (unwrapped.type !== utils_1.AST_NODE_TYPES.CallExpression ||
118
+ unwrapped.arguments.length !== 1) {
119
+ return null;
120
+ }
121
+ const callee = unwrapWrappers(unwrapped.callee);
122
+ if (callee.type !== utils_1.AST_NODE_TYPES.Identifier || callee.name !== 'require') {
123
+ return null;
124
+ }
125
+ const [specifier] = unwrapped.arguments;
126
+ return specifier.type === utils_1.AST_NODE_TYPES.Literal &&
127
+ typeof specifier.value === 'string'
128
+ ? specifier.value
129
+ : null;
130
+ }
131
+ /**
132
+ * Every variable the file declares, in every scope.
133
+ *
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.
204
+ *
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)
214
+ */
215
+ function collectFsBindings(scopeManager) {
216
+ const variables = allScopeVariables(scopeManager);
217
+ const bindings = new Map();
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);
224
+ }
225
+ }
226
+ const lookup = (identifier) => {
227
+ const variable = variableOf.get(identifier);
228
+ if (!variable || !bindings.has(variable)) {
229
+ return undefined;
230
+ }
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;
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
+ };
263
+ };
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)) {
296
+ continue;
297
+ }
298
+ for (const definition of variable.defs) {
299
+ const binding = importedBinding(definition) ?? declaredBinding(definition);
300
+ if (!binding) {
301
+ continue;
302
+ }
303
+ bindings.set(variable, binding.operation);
304
+ classified = true;
305
+ break;
306
+ }
307
+ }
308
+ }
309
+ return lookup;
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']);
25
318
  /**
26
319
  * Matches prettier's own default. The autofix authors a whole statement a
27
320
  * formatter owns, so a layout it emits that prettier would not is rewritten on
@@ -202,6 +495,15 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
202
495
  return {};
203
496
  }
204
497
  const sourceCode = context.sourceCode;
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
500
+ // candidate run the traversal reaches.
501
+ let fsBindings = null;
502
+ const getFsBindings = () => {
503
+ const resolved = fsBindings ?? collectFsBindings(sourceCode.scopeManager);
504
+ fsBindings = resolved;
505
+ return resolved;
506
+ };
205
507
  // The width the autofix lays the rewritten statement out against. It lives
206
508
  // in the consumer's formatter configuration, which no rule context carries,
207
509
  // so a project formatting at 100 or 120 states it here.
@@ -1356,6 +1658,81 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1356
1658
  // every such write is by construction published to the outer scope.
1357
1659
  return { names, instancePaths: new Set(targets.instancePaths) };
1358
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
+ }
1693
+ /**
1694
+ * Names the filesystem operation an await performs, or null when the await
1695
+ * does not reach the filesystem through a binding this file declares.
1696
+ *
1697
+ * The classification keys on the ORIGIN of the callee's root binding rather
1698
+ * than on the callee's spelling, so a project-local helper that happens to
1699
+ * be called `writeFile` is not mistaken for the fs export of that name, and
1700
+ * a renamed import (`writeFile as wf`) is not missed for lacking it.
1701
+ *
1702
+ * The operation is read off the LAST member of a member callee
1703
+ * (`fs.promises.writeFile` -> `writeFile`), which is where the module object
1704
+ * spells it, and off the imported name for a bare callee, which is where a
1705
+ * named import spells it. A computed member whose key is not a literal names
1706
+ * an operation the source does not state, so it yields the empty string and
1707
+ * classifies as mutating, matching the fail-safe the allowlist is read with.
1708
+ */
1709
+ function getFsOperationName(awaitExpr) {
1710
+ const argument = unwrapPromiseChain(awaitExpr.argument);
1711
+ if (argument.type !== utils_1.AST_NODE_TYPES.CallExpression) {
1712
+ return null;
1713
+ }
1714
+ const callee = unwrapExpression(argument.callee);
1715
+ const root = getPathRoot(callee);
1716
+ if (root.type !== utils_1.AST_NODE_TYPES.Identifier) {
1717
+ return null;
1718
+ }
1719
+ const binding = getFsBindings()(root);
1720
+ if (!binding) {
1721
+ return null;
1722
+ }
1723
+ if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
1724
+ const { property } = callee;
1725
+ if (!callee.computed && property.type === utils_1.AST_NODE_TYPES.Identifier) {
1726
+ return property.name;
1727
+ }
1728
+ if (property.type === utils_1.AST_NODE_TYPES.Literal &&
1729
+ typeof property.value === 'string') {
1730
+ return property.value;
1731
+ }
1732
+ return '';
1733
+ }
1734
+ return binding.operation ?? root.name;
1735
+ }
1359
1736
  /**
1360
1737
  * Checks if there are dependencies between await expressions
1361
1738
  */
@@ -1744,6 +2121,53 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
1744
2121
  }
1745
2122
  }
1746
2123
  }
2124
+ // 13. Shared external-resource ordering barrier (filesystem). Two awaits
2125
+ // that operate the same filesystem are ordered by that resource, not by
2126
+ // any JS value: `writeFile(pending, data)` then `rename(pending, path)`
2127
+ // passes nothing from one call to the other, yet the second one's
2128
+ // precondition is precisely the first one's side effect. Every barrier
2129
+ // above reads bindings, receivers and slots -- the JS-level surface -- so
2130
+ // a dependency carried entirely through an external resource is invisible
2131
+ // to all of them, and the run reads as independent while being strictly
2132
+ // ordered. Promise.all issues all of it in one tick, which either throws
2133
+ // ENOENT or publishes a partial state, and WHICH of the two is a race, so
2134
+ // the damage is timing-dependent rather than reproducible. (#2166)
2135
+ //
2136
+ // The barrier engages only when at least one operation MUTATES. Two
2137
+ // observations of the filesystem commute -- neither can change what the
2138
+ // other returns -- so `readFile(a)` then `readFile(b)` is a genuine
2139
+ // latency mistake and keeps its report, which is where the rule earns
2140
+ // most of its value on I/O-bound code. A mutation makes the ordering
2141
+ // observable, and observable ordering is exactly what the rewrite
2142
+ // destroys.
2143
+ //
2144
+ // A single fs await raises no ordering question: the resource has to be
2145
+ // SHARED for the sequencing to exist, so a run mixing one fs call with
2146
+ // unrelated network calls still parallelizes.
2147
+ //
2148
+ // Member-callee spellings (`fs.writeFile()`, `fs.promises.rename()`) are
2149
+ // incidentally held by the shared-receiver barrier above, which keys on
2150
+ // the receiver they have in common. They are classified here too because
2151
+ // that coverage is a side effect of an unrelated question: it lapses the
2152
+ // moment a file mixes spellings (`fs.writeFile()` then a named-import
2153
+ // `rename()`), which shares the resource while sharing no receiver.
2154
+ let fsAwaitCount = 0;
2155
+ let mutatesFilesystem = false;
2156
+ for (const node of awaitNodes) {
2157
+ const awaitExpr = getAwaitExpression(node);
2158
+ if (!awaitExpr)
2159
+ continue;
2160
+ const operation = getFsOperationName(awaitExpr);
2161
+ if (operation === null)
2162
+ continue;
2163
+ fsAwaitCount++;
2164
+ if (!isReadOnlyFsOperation(operation)) {
2165
+ mutatesFilesystem = true;
2166
+ }
2167
+ }
2168
+ if (fsAwaitCount >= 2 && mutatesFilesystem) {
2169
+ return true;
2170
+ }
1747
2171
  return false;
1748
2172
  }
1749
2173
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.182",
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,57 @@
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
+ },
33
+ {
34
+ "version": "1.20.183",
35
+ "date": "2026-08-27T17:21:40.008Z",
36
+ "rules": [
37
+ {
38
+ "name": "enforce-verb-noun-naming",
39
+ "changeType": "fix",
40
+ "issues": [
41
+ 2165
42
+ ],
43
+ "summary": "reach the class-METHOD spelling with the React-component carve-out (closes #2165)"
44
+ },
45
+ {
46
+ "name": "parallelize-async-operations",
47
+ "changeType": "fix",
48
+ "issues": [
49
+ 2166
50
+ ],
51
+ "summary": "barrier awaits that share the filesystem (closes #2166)"
52
+ }
53
+ ]
54
+ },
2
55
  {
3
56
  "version": "1.20.182",
4
57
  "date": "2026-08-27T12:12:45.593Z",