@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
|
@@ -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
|
-
|
|
3888
|
-
if (found ||
|
|
3902
|
+
const recordHookCall = (candidate) => {
|
|
3903
|
+
if (found || candidate.type !== utils_1.AST_NODE_TYPES.CallExpression) {
|
|
3889
3904
|
return;
|
|
3890
3905
|
}
|
|
3891
|
-
const name = calleeName(
|
|
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(
|
|
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 (
|
|
4197
|
-
(
|
|
4198
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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(
|
|
215
|
+
function collectFsBindings(scopeManager) {
|
|
216
|
+
const variables = allScopeVariables(scopeManager);
|
|
148
217
|
const bindings = new Map();
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
225
|
+
}
|
|
226
|
+
const lookup = (identifier) => {
|
|
227
|
+
const variable = variableOf.get(identifier);
|
|
228
|
+
if (!variable || !bindings.has(variable)) {
|
|
229
|
+
return undefined;
|
|
156
230
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
384
|
-
|
|
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 =
|
|
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
|
|
1569
|
-
if (!
|
|
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
|
|
1734
|
+
return binding.operation ?? root.name;
|
|
1584
1735
|
}
|
|
1585
1736
|
/**
|
|
1586
1737
|
* Checks if there are dependencies between await expressions
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -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",
|