@blumintinc/eslint-plugin-blumint 1.20.147 → 1.20.149
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 +1 -1
- package/lib/rules/class-methods-read-top-to-bottom.js +74 -5
- package/lib/rules/enforce-early-destructuring.js +136 -1
- package/lib/rules/no-array-length-in-deps.js +129 -1
- package/lib/rules/no-entire-object-hook-deps.js +108 -8
- package/lib/rules/parallelize-async-operations.js +315 -3
- package/lib/rules/prefer-map-over-conditional-dispatch.js +70 -0
- package/lib/utils/ASTHelpers.d.ts +11 -0
- package/lib/utils/ASTHelpers.js +20 -9
- package/lib/utils/hungarianNaming.d.ts +22 -0
- package/lib/utils/hungarianNaming.js +130 -0
- package/package.json +1 -1
- package/release-manifest.json +68 -0
|
@@ -379,6 +379,31 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
379
379
|
* from that instance (`super.users` versus `this.posts`) stay distinct.
|
|
380
380
|
*/
|
|
381
381
|
const INSTANCE_RECEIVER_KEY = 'this';
|
|
382
|
+
/**
|
|
383
|
+
* Strips the wrappers that punctuate an expression without changing which
|
|
384
|
+
* value it denotes, so one path is recognized however it is spelled
|
|
385
|
+
* (`a.b`, `a?.b`, `a!.b`, `(a as T).b`).
|
|
386
|
+
*/
|
|
387
|
+
function unwrapExpression(node) {
|
|
388
|
+
let current = node;
|
|
389
|
+
while (current.type === utils_1.AST_NODE_TYPES.ChainExpression ||
|
|
390
|
+
current.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
|
|
391
|
+
current.type === utils_1.AST_NODE_TYPES.TSAsExpression) {
|
|
392
|
+
current = current.expression;
|
|
393
|
+
}
|
|
394
|
+
return current;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Walks a member chain down to the expression it is rooted at: the binding,
|
|
398
|
+
* the instance, or whatever else the chain starts from.
|
|
399
|
+
*/
|
|
400
|
+
function getPathRoot(node) {
|
|
401
|
+
let root = unwrapExpression(node);
|
|
402
|
+
while (root.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
403
|
+
root = unwrapExpression(root.object);
|
|
404
|
+
}
|
|
405
|
+
return root;
|
|
406
|
+
}
|
|
382
407
|
/**
|
|
383
408
|
* Builds a stable textual key for the object a method is invoked on, so two
|
|
384
409
|
* awaits can be compared for "same receiver" by string equality.
|
|
@@ -548,6 +573,104 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
548
573
|
visit(node);
|
|
549
574
|
return keys;
|
|
550
575
|
}
|
|
576
|
+
/**
|
|
577
|
+
* Collects the member paths an expression DEREFERENCES: the object of a
|
|
578
|
+
* member access that is itself a member access, keyed the same way a
|
|
579
|
+
* receiver is.
|
|
580
|
+
*
|
|
581
|
+
* Depth is what makes the path a REQUIREMENT rather than a mere read.
|
|
582
|
+
* Evaluating `store.data.id` demands that `store.data` already hold an
|
|
583
|
+
* object, and throws when it does not; evaluating `store.data` demands
|
|
584
|
+
* nothing, since an unfilled slot yields `undefined` and passing `undefined`
|
|
585
|
+
* onward is silent. Only the former can turn the Promise.all rewrite into a
|
|
586
|
+
* crash, so only the former mints a key here.
|
|
587
|
+
*
|
|
588
|
+
* The traversal stops at function boundaries, the opposite of
|
|
589
|
+
* getInstancePathKeys: the hazard is eager evaluation at array-literal
|
|
590
|
+
* construction, and a path inside a callback handed to the operation is
|
|
591
|
+
* dereferenced when that callback runs, which the rewrite does not move.
|
|
592
|
+
*
|
|
593
|
+
* The root node travels with the key because the two questions the barrier
|
|
594
|
+
* asks of a path -- which slot it names, and where its root is bound -- are
|
|
595
|
+
* answered by different things.
|
|
596
|
+
*/
|
|
597
|
+
function getDereferencedPaths(node) {
|
|
598
|
+
const paths = [];
|
|
599
|
+
const visit = (current) => {
|
|
600
|
+
if (current.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
601
|
+
const object = unwrapExpression(current.object);
|
|
602
|
+
if (object.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
603
|
+
const key = getReceiverKey(object);
|
|
604
|
+
if (key !== null) {
|
|
605
|
+
paths.push({ key, root: getPathRoot(object) });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (FUNCTION_BOUNDARY_TYPES.has(current.type)) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
for (const key in current) {
|
|
613
|
+
if (key === 'parent' || key === 'range' || key === 'loc')
|
|
614
|
+
continue;
|
|
615
|
+
const child = current[key];
|
|
616
|
+
if (!child || typeof child !== 'object')
|
|
617
|
+
continue;
|
|
618
|
+
if (Array.isArray(child)) {
|
|
619
|
+
for (const item of child) {
|
|
620
|
+
if (item && typeof item === 'object' && 'type' in item) {
|
|
621
|
+
visit(item);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
else if ('type' in child) {
|
|
626
|
+
visit(child);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
visit(node);
|
|
631
|
+
return paths;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Reports whether a path is rooted at state an opaque call could reach
|
|
635
|
+
* without ever being handed it: a module-scope binding (a module-level
|
|
636
|
+
* `const`, or an import), or a name that resolves nowhere and is therefore
|
|
637
|
+
* an implicit global.
|
|
638
|
+
*
|
|
639
|
+
* A function-local root fails the test. A parameter or a local `const` is
|
|
640
|
+
* not visible to a callee that receives neither, so a bare-identifier call
|
|
641
|
+
* cannot rebind it, and reading a slot beneath it after that call is not the
|
|
642
|
+
* hazard this barrier answers.
|
|
643
|
+
*
|
|
644
|
+
* The instance fails it too: `this` names an object a free function was not
|
|
645
|
+
* given, so an instance path can only be written through code the run
|
|
646
|
+
* itself shows -- which the closure-write barrier already reads.
|
|
647
|
+
*/
|
|
648
|
+
function isAmbientlyReachableRoot(path, root, declaredNames) {
|
|
649
|
+
if (root.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
if (isInstancePathKey(path) || declaredNames.has(root.name)) {
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, root), root.name);
|
|
656
|
+
if (!variable) {
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
return (variable.scope.type === utils_1.TSESLint.Scope.ScopeType.module ||
|
|
660
|
+
variable.scope.type === utils_1.TSESLint.Scope.ScopeType.global);
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Reports whether an awaited expression is a call through a bare identifier
|
|
664
|
+
* (`hydrate()`, `initAnalytics()`), the one callee shape that names no
|
|
665
|
+
* receiver at all. Such a call's effects are reachable only through the
|
|
666
|
+
* module scope it closes over, so nothing in the run describes what it
|
|
667
|
+
* touches. Handles optional-call ChainExpressions.
|
|
668
|
+
*/
|
|
669
|
+
function hasBareIdentifierCallee(awaitExpr) {
|
|
670
|
+
const argument = unwrapExpression(awaitExpr.argument);
|
|
671
|
+
return (argument.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
672
|
+
unwrapExpression(argument.callee).type === utils_1.AST_NODE_TYPES.Identifier);
|
|
673
|
+
}
|
|
551
674
|
/**
|
|
552
675
|
* Extracts the receiver key of an awaited *named-method* call: the key of
|
|
553
676
|
* the `object` of a `MemberExpression` callee, when the accessed member is
|
|
@@ -584,6 +707,105 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
584
707
|
}
|
|
585
708
|
return getReceiverKey(callee.object);
|
|
586
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* Finds the function a class declares for a member name, searching the
|
|
712
|
+
* class body that encloses a node. Both spellings of a method-valued member
|
|
713
|
+
* qualify: a `MethodDefinition` and a `PropertyDefinition` initialized with
|
|
714
|
+
* a function.
|
|
715
|
+
*
|
|
716
|
+
* The NEAREST enclosing class body is the one searched. A method reached
|
|
717
|
+
* through `super` resolves against a base class, and an inner class shadows
|
|
718
|
+
* an outer one, so the answer can name a function other than the one that
|
|
719
|
+
* actually runs -- but the caller only ever ADDS the resolved body's writes
|
|
720
|
+
* to the writes it already sees, so a wrong answer can only introduce a
|
|
721
|
+
* barrier, the direction this rule's trade-off prefers.
|
|
722
|
+
*/
|
|
723
|
+
function findClassMethodFunction(node, memberName) {
|
|
724
|
+
let current = node;
|
|
725
|
+
while (current && current.type !== utils_1.AST_NODE_TYPES.ClassBody) {
|
|
726
|
+
current = current.parent;
|
|
727
|
+
}
|
|
728
|
+
if (!current) {
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
for (const member of current.body) {
|
|
732
|
+
if (member.type !== utils_1.AST_NODE_TYPES.MethodDefinition &&
|
|
733
|
+
member.type !== utils_1.AST_NODE_TYPES.PropertyDefinition) {
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (member.computed) {
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
const key = member.key;
|
|
740
|
+
const name = key.type === utils_1.AST_NODE_TYPES.PrivateIdentifier
|
|
741
|
+
? `#${key.name}`
|
|
742
|
+
: key.type === utils_1.AST_NODE_TYPES.Identifier
|
|
743
|
+
? key.name
|
|
744
|
+
: null;
|
|
745
|
+
if (name !== memberName) {
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
const value = member.value;
|
|
749
|
+
if (value && FUNCTION_BOUNDARY_TYPES.has(value.type)) {
|
|
750
|
+
return value;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Resolves an awaited call to the function this file DECLARES for its
|
|
757
|
+
* callee: a hoisted `function` declaration, a binding initialized with a
|
|
758
|
+
* function, or a method of the enclosing class reached through
|
|
759
|
+
* `this`/`super`.
|
|
760
|
+
*
|
|
761
|
+
* The body is where an operation's ordering constraint usually lives.
|
|
762
|
+
* `await hydrate()` discards its result and mentions nothing it touches, so
|
|
763
|
+
* every barrier keyed on the run's own text reads it as independent, while
|
|
764
|
+
* the body assigns the very state the next await dereferences. Reading the
|
|
765
|
+
* body turns that guess into a fact wherever the file supplies one.
|
|
766
|
+
*
|
|
767
|
+
* Only one level is followed, so a write reached through a helper's own
|
|
768
|
+
* helper stays invisible. A callee that resolves nowhere -- an import, a
|
|
769
|
+
* parameter, a method on some other object -- yields null and leaves the
|
|
770
|
+
* syntactic barriers to answer for it.
|
|
771
|
+
*/
|
|
772
|
+
function resolveCalleeFunction(awaitExpr) {
|
|
773
|
+
const argument = unwrapExpression(awaitExpr.argument);
|
|
774
|
+
if (argument.type !== utils_1.AST_NODE_TYPES.CallExpression) {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
const callee = unwrapExpression(argument.callee);
|
|
778
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
779
|
+
const variable = ASTHelpers_1.ASTHelpers.findVariableInScope(ASTHelpers_1.ASTHelpers.getScope(context, callee), callee.name);
|
|
780
|
+
if (!variable) {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
for (const definition of variable.defs) {
|
|
784
|
+
const declaration = definition.node;
|
|
785
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.FunctionDeclaration) {
|
|
786
|
+
return declaration;
|
|
787
|
+
}
|
|
788
|
+
if (declaration.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
789
|
+
declaration.init &&
|
|
790
|
+
FUNCTION_BOUNDARY_TYPES.has(declaration.init.type)) {
|
|
791
|
+
return declaration.init;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
const object = unwrapExpression(callee.object);
|
|
800
|
+
if (object.type !== utils_1.AST_NODE_TYPES.ThisExpression &&
|
|
801
|
+
object.type !== utils_1.AST_NODE_TYPES.Super) {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
const memberName = getMemberSegment(callee);
|
|
805
|
+
return memberName === null
|
|
806
|
+
? null
|
|
807
|
+
: findClassMethodFunction(awaitExpr, memberName);
|
|
808
|
+
}
|
|
587
809
|
/**
|
|
588
810
|
* Matches the `Promise` combinators that take an array of promises and
|
|
589
811
|
* return a single promise standing in for the whole group. Awaiting one of
|
|
@@ -1196,11 +1418,32 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
1196
1418
|
// a write to `this.alpha` leaves a later `this.beta` parallelizable while
|
|
1197
1419
|
// a write to `this.state` still blocks a later `this.state.mutator`.
|
|
1198
1420
|
// (#1924)
|
|
1421
|
+
//
|
|
1422
|
+
// A write inside the awaited call's own BODY reaches the enclosing scope
|
|
1423
|
+
// exactly as a write inside a callback does -- `await hydrate()`, where
|
|
1424
|
+
// `hydrate` assigns `store.state`, publishes that slot by the time it
|
|
1425
|
+
// settles. The body is not part of the awaited expression, so the
|
|
1426
|
+
// traversal above cannot see it; wherever the file declares the callee,
|
|
1427
|
+
// its writes are read from there and joined to the ones spelled out in
|
|
1428
|
+
// the run. (#1989)
|
|
1199
1429
|
const assignedState = awaitNodes.map((node) => {
|
|
1200
1430
|
const awaitExpr = getAwaitExpression(node);
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1431
|
+
if (!awaitExpr) {
|
|
1432
|
+
return { names: new Set(), instancePaths: new Set() };
|
|
1433
|
+
}
|
|
1434
|
+
const written = getAssignedState(awaitExpr.argument);
|
|
1435
|
+
const calleeFunction = resolveCalleeFunction(awaitExpr);
|
|
1436
|
+
if (!calleeFunction) {
|
|
1437
|
+
return written;
|
|
1438
|
+
}
|
|
1439
|
+
const calleeWritten = getAssignedState(calleeFunction);
|
|
1440
|
+
return {
|
|
1441
|
+
names: new Set([...written.names, ...calleeWritten.names]),
|
|
1442
|
+
instancePaths: new Set([
|
|
1443
|
+
...written.instancePaths,
|
|
1444
|
+
...calleeWritten.instancePaths,
|
|
1445
|
+
]),
|
|
1446
|
+
};
|
|
1204
1447
|
});
|
|
1205
1448
|
const readInstancePaths = awaitNodes.map((node) => {
|
|
1206
1449
|
const awaitExpr = getAwaitExpression(node);
|
|
@@ -1249,6 +1492,75 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
1249
1492
|
return true;
|
|
1250
1493
|
}
|
|
1251
1494
|
}
|
|
1495
|
+
// 12. Deferred-dereference barrier. A statement AFTER an await occupies a
|
|
1496
|
+
// deferred position: it is evaluated only once the preceding promise
|
|
1497
|
+
// settles. The rewrite splices its awaited operand into a `Promise.all`
|
|
1498
|
+
// ARRAY LITERAL, whose elements evaluate eagerly at construction. Any slot
|
|
1499
|
+
// the later operand must dereference is therefore read BEFORE the earlier
|
|
1500
|
+
// operation runs, so a slot that operation installs is read while still
|
|
1501
|
+
// `undefined` and the element throws a TypeError -- code that returned a
|
|
1502
|
+
// value before the fix crashes after it. The two awaits are genuinely
|
|
1503
|
+
// ordered, so the run is not a latency mistake and the rule declines to
|
|
1504
|
+
// report it rather than merely declining the fix. (#1989)
|
|
1505
|
+
//
|
|
1506
|
+
// The write itself is invisible: it happens inside the awaited call's own
|
|
1507
|
+
// BODY, so no value flows out of the await, no variable is declared, and
|
|
1508
|
+
// no assignment appears anywhere the closure-write barrier looks. What the
|
|
1509
|
+
// run does show is REACH -- whether the earlier operation could have
|
|
1510
|
+
// installed the slot at all -- and only a discarded-result await qualifies
|
|
1511
|
+
// as the writer, since a captured result is a value dependency the
|
|
1512
|
+
// identifier comparison already sees.
|
|
1513
|
+
//
|
|
1514
|
+
// Reach comes in two shapes, and only these two, so that sibling slots
|
|
1515
|
+
// under a shared namespace (`api.users.getAll()` then
|
|
1516
|
+
// `api.posts.getRecent()`) stay parallelizable:
|
|
1517
|
+
//
|
|
1518
|
+
// (a) The earlier call is invoked ON a receiver that CONTAINS the slot
|
|
1519
|
+
// (`this.connect()` then `this.client.send()`, `store.load()` then
|
|
1520
|
+
// `send(store.data.id)`). A method can fill any slot beneath its own
|
|
1521
|
+
// receiver, which is what makes such a pair ordered. This widens the
|
|
1522
|
+
// shared-receiver barrier from equality to containment: barrier 7
|
|
1523
|
+
// compares two receivers, whereas here the earlier receiver is
|
|
1524
|
+
// compared against a path the later operand merely reads.
|
|
1525
|
+
//
|
|
1526
|
+
// (b) The earlier call goes through a bare identifier (`hydrate()`,
|
|
1527
|
+
// `initAnalytics()`) and the slot hangs off module-scope state. Such
|
|
1528
|
+
// a call names no receiver, so the run says nothing about what it
|
|
1529
|
+
// touches, while module-scope state is reachable from inside it
|
|
1530
|
+
// without ever being passed in. Function-local roots and instance
|
|
1531
|
+
// paths are excluded: a free function handed neither cannot rebind
|
|
1532
|
+
// them, so writes that reach them are visible in the run and belong
|
|
1533
|
+
// to the closure-write barrier.
|
|
1534
|
+
const dereferencedPaths = awaitNodes.map((node) => {
|
|
1535
|
+
const awaitExpr = getAwaitExpression(node);
|
|
1536
|
+
return awaitExpr ? getDereferencedPaths(awaitExpr.argument) : [];
|
|
1537
|
+
});
|
|
1538
|
+
for (let i = 1; i < awaitNodes.length; i++) {
|
|
1539
|
+
const paths = dereferencedPaths[i];
|
|
1540
|
+
if (paths.length === 0)
|
|
1541
|
+
continue;
|
|
1542
|
+
for (let j = 0; j < i; j++) {
|
|
1543
|
+
if (awaitNodes[j].type !== utils_1.AST_NODE_TYPES.ExpressionStatement) {
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
const priorExpr = getAwaitExpression(awaitNodes[j]);
|
|
1547
|
+
if (!priorExpr)
|
|
1548
|
+
continue;
|
|
1549
|
+
const priorReceiver = receiverKeys[j];
|
|
1550
|
+
for (const path of paths) {
|
|
1551
|
+
if (priorReceiver !== null &&
|
|
1552
|
+
(path.key === priorReceiver ||
|
|
1553
|
+
path.key.startsWith(`${priorReceiver}.`))) {
|
|
1554
|
+
return true;
|
|
1555
|
+
}
|
|
1556
|
+
if (priorReceiver === null &&
|
|
1557
|
+
hasBareIdentifierCallee(priorExpr) &&
|
|
1558
|
+
isAmbientlyReachableRoot(path.key, path.root, variableNames)) {
|
|
1559
|
+
return true;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1252
1564
|
return false;
|
|
1253
1565
|
}
|
|
1254
1566
|
/**
|
|
@@ -823,6 +823,68 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
823
823
|
}
|
|
824
824
|
return values.some((value) => referencesThis(value) || referencesPrivateName(value));
|
|
825
825
|
}
|
|
826
|
+
/**
|
|
827
|
+
* Whether stepping from `child` up to `parent` crosses a boundary that
|
|
828
|
+
* decides WHETHER — or how many times — the child is evaluated, or that
|
|
829
|
+
* owns bindings the child may read.
|
|
830
|
+
*
|
|
831
|
+
* The question is about the child's POSITION, not the parent's type alone:
|
|
832
|
+
* an `if` test, a `for` init and a `for…of` right-hand side are evaluated
|
|
833
|
+
* exactly once, before control reaches the construct they head, so text
|
|
834
|
+
* lifted out of them runs precisely when it used to. A while/do-while test
|
|
835
|
+
* is re-evaluated per iteration, so it is a boundary like the bodies are.
|
|
836
|
+
*/
|
|
837
|
+
function crossingGuardsEvaluation(child, parent) {
|
|
838
|
+
switch (parent.type) {
|
|
839
|
+
case utils_1.AST_NODE_TYPES.IfStatement:
|
|
840
|
+
return parent.test !== child;
|
|
841
|
+
case utils_1.AST_NODE_TYPES.WhileStatement:
|
|
842
|
+
case utils_1.AST_NODE_TYPES.DoWhileStatement:
|
|
843
|
+
return true;
|
|
844
|
+
case utils_1.AST_NODE_TYPES.ForStatement:
|
|
845
|
+
return parent.init !== child;
|
|
846
|
+
case utils_1.AST_NODE_TYPES.ForInStatement:
|
|
847
|
+
case utils_1.AST_NODE_TYPES.ForOfStatement:
|
|
848
|
+
return parent.right !== child;
|
|
849
|
+
case utils_1.AST_NODE_TYPES.LogicalExpression:
|
|
850
|
+
// `&&`/`||`/`??` evaluate the right operand only for some values of
|
|
851
|
+
// the left; the left operand always runs.
|
|
852
|
+
return parent.right === child;
|
|
853
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
854
|
+
return parent.test !== child;
|
|
855
|
+
default:
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Whether the ternary form's hoist would carry the branch values across a
|
|
861
|
+
* guard that decides whether they are evaluated, or out of a scope that
|
|
862
|
+
* owns bindings they read.
|
|
863
|
+
*
|
|
864
|
+
* The hoist walk treats a braceless body as transparent — it stops only at
|
|
865
|
+
* a `BlockStatement`/`Program`/`SwitchCase` — so `if (box) return kind ===
|
|
866
|
+
* 'a' ? box.a.v : box.b.v;` lands the `Record` ABOVE the guard and
|
|
867
|
+
* dereferences both values with the narrowing left behind (#1990). Braces
|
|
868
|
+
* are what make the hoist safe: with them the walk stops inside the guarded
|
|
869
|
+
* block, where the values are still narrowed and every loop binding is
|
|
870
|
+
* still in scope, so the boundary test also cures the scope violation that
|
|
871
|
+
* hoisting out of `for (const r of rows)` produces (TS2304).
|
|
872
|
+
*
|
|
873
|
+
* Nothing else sees this: the branch values are ordinary member reads, so
|
|
874
|
+
* `EAGER_UNSAFE_NODES` passes them, and `isNarrowingExempt` inspects only
|
|
875
|
+
* narrowing by the discriminant's own root, never narrowing applied by a
|
|
876
|
+
* surrounding statement.
|
|
877
|
+
*/
|
|
878
|
+
function hoistCrossesGuard(node) {
|
|
879
|
+
let child = node;
|
|
880
|
+
while (child.parent && !CONTAINER_TYPES.has(child.parent.type)) {
|
|
881
|
+
if (crossingGuardsEvaluation(child, child.parent)) {
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
child = child.parent;
|
|
885
|
+
}
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
826
888
|
// ---- Name derivation ----------------------------------------------------
|
|
827
889
|
function toUpperSnake(name) {
|
|
828
890
|
return name
|
|
@@ -1075,6 +1137,9 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
1075
1137
|
if (flags.hoistEscapesClass) {
|
|
1076
1138
|
return 'the Record would hoist outside the class body, where the branch values’ `this`/`#private` reads do not resolve; extract it manually inside the class';
|
|
1077
1139
|
}
|
|
1140
|
+
if (flags.hoistCrossesGuard) {
|
|
1141
|
+
return 'the Record would hoist above the guard or loop that decides whether the branch values are evaluated, dropping their narrowing and any binding the guard scopes; add braces around the branch — or lift the guarded expression into its own statement — then convert';
|
|
1142
|
+
}
|
|
1078
1143
|
if (!flags.inStatementList) {
|
|
1079
1144
|
return 'the dispatch is the whole body of a braceless branch, where a declaration is not allowed; add braces around it, then convert';
|
|
1080
1145
|
}
|
|
@@ -1090,6 +1155,9 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
1090
1155
|
const { entries, contributingValues, dText, form, assignTargetText, fullCoverage, hasNullish, canPlaceFix, commentBlocked, } = analysis;
|
|
1091
1156
|
const eagerSafe = contributingValues.every((expr) => !containsEagerUnsafe(expr));
|
|
1092
1157
|
const hoistEscapesClass = form === 'expr' && hoistLeavesClassBody(node, contributingValues);
|
|
1158
|
+
// Only the ternary hoists; every other form is replaced where it stands,
|
|
1159
|
+
// so no guard can be crossed.
|
|
1160
|
+
const crossesGuard = form === 'expr' && hoistCrossesGuard(node);
|
|
1093
1161
|
// Every non-ternary form replaces the construct with a declaration plus a
|
|
1094
1162
|
// statement, which needs a statement LIST to land in. As the sole body of
|
|
1095
1163
|
// a braceless `if`/`else`/loop the construct sits where exactly one
|
|
@@ -1106,6 +1174,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
1106
1174
|
eagerSafe &&
|
|
1107
1175
|
canPlaceFix &&
|
|
1108
1176
|
!hoistEscapesClass &&
|
|
1177
|
+
!crossesGuard &&
|
|
1109
1178
|
inStatementList &&
|
|
1110
1179
|
!commentBlocked) {
|
|
1111
1180
|
derivation = deriveLookupName(discriminantOf(node), fixScope);
|
|
@@ -1121,6 +1190,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
|
|
|
1121
1190
|
eagerSafe,
|
|
1122
1191
|
canPlaceFix,
|
|
1123
1192
|
hoistEscapesClass,
|
|
1193
|
+
hoistCrossesGuard: crossesGuard,
|
|
1124
1194
|
inStatementList,
|
|
1125
1195
|
commentSafe: !commentBlocked,
|
|
1126
1196
|
derivation,
|
|
@@ -49,6 +49,17 @@ export declare class ASTHelpers {
|
|
|
49
49
|
* happens to share a member's name is not a reference to that member.
|
|
50
50
|
*/
|
|
51
51
|
static classMethodDependenciesOf(node: TSESTree.Node | null, graph: Graph, className: string): string[];
|
|
52
|
+
/**
|
|
53
|
+
* Every class member a node reaches through `this.<member>` (or
|
|
54
|
+
* `<ClassName>.<member>`), fields included and unfiltered by any graph.
|
|
55
|
+
*
|
|
56
|
+
* Whatever a member's body reads, it reads as soon as that body runs, so a
|
|
57
|
+
* caller deciding whether an invocation is order-sensitive needs the field
|
|
58
|
+
* reads that `classMethodDependenciesOf` drops. A read nested in a callback
|
|
59
|
+
* counts: `arr.map((x) => this.field)` runs the callback before the
|
|
60
|
+
* enclosing body returns.
|
|
61
|
+
*/
|
|
62
|
+
static classMemberNamesReferenced(node: TSESTree.Node | null, className: string): string[];
|
|
52
63
|
/**
|
|
53
64
|
* Collects the class members a property initializer reads while that
|
|
54
65
|
* initializer runs.
|
package/lib/utils/ASTHelpers.js
CHANGED
|
@@ -308,15 +308,26 @@ class ASTHelpers {
|
|
|
308
308
|
* happens to share a member's name is not a reference to that member.
|
|
309
309
|
*/
|
|
310
310
|
static classMethodDependenciesOf(node, graph, className) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
311
|
+
return this.classMemberNamesReferenced(node, className).filter((dep) => {
|
|
312
|
+
// Only include dependencies that exist exactly in the graph
|
|
313
|
+
// This prevents substring matches (e.g., 'nextMatches' vs 'nextMatchesWithResults')
|
|
314
|
+
return graph?.[dep] !== undefined && graph?.[dep]?.type !== 'property';
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Every class member a node reaches through `this.<member>` (or
|
|
319
|
+
* `<ClassName>.<member>`), fields included and unfiltered by any graph.
|
|
320
|
+
*
|
|
321
|
+
* Whatever a member's body reads, it reads as soon as that body runs, so a
|
|
322
|
+
* caller deciding whether an invocation is order-sensitive needs the field
|
|
323
|
+
* reads that `classMethodDependenciesOf` drops. A read nested in a callback
|
|
324
|
+
* counts: `arr.map((x) => this.field)` runs the callback before the
|
|
325
|
+
* enclosing body returns.
|
|
326
|
+
*/
|
|
327
|
+
static classMemberNamesReferenced(node, className) {
|
|
328
|
+
const references = [];
|
|
329
|
+
this.collectClassMemberReferences(node, className, true, references);
|
|
330
|
+
return [...new Set(references)];
|
|
320
331
|
}
|
|
321
332
|
/**
|
|
322
333
|
* Collects the class members a property initializer reads while that
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirrors the type-marker predicate the shipped `no-hungarian` rule applies to
|
|
3
|
+
* a declaration name, so a fixer can refuse to SYNTHESISE a name that rule
|
|
4
|
+
* rejects. `no-hungarian` is not fixable, so a generated identifier it flags
|
|
5
|
+
* converts machine-resolvable work into a manual rename inside a file that was
|
|
6
|
+
* clean before `--fix` ran (#1997).
|
|
7
|
+
*
|
|
8
|
+
* Scope is the shape a fixer emits: an alphanumeric camelCase/PascalCase
|
|
9
|
+
* identifier. `no-hungarian`'s SCREAMING_SNAKE_CASE branch and its
|
|
10
|
+
* compound-noun / descriptive-suffix / <entity>Number / <domain>Symbol
|
|
11
|
+
* carve-outs are deliberately absent. Every one of them can only turn a
|
|
12
|
+
* rejection into an acceptance, and each is keyed on a trailing word a
|
|
13
|
+
* generated name does not carry, so their absence is unreachable for such a
|
|
14
|
+
* name and, if ever reached, costs the caller its next candidate rather than
|
|
15
|
+
* reintroducing the defect. `no-array-length-in-deps.test.ts` pins agreement
|
|
16
|
+
* with the real rule over the corpus of names its fixer can emit, so a change
|
|
17
|
+
* to either predicate surfaces there.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Whether `no-hungarian` reads `name` as encoding its own type.
|
|
21
|
+
*/
|
|
22
|
+
export declare function encodesTypeMarker(name: string): boolean;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Mirrors the type-marker predicate the shipped `no-hungarian` rule applies to
|
|
4
|
+
* a declaration name, so a fixer can refuse to SYNTHESISE a name that rule
|
|
5
|
+
* rejects. `no-hungarian` is not fixable, so a generated identifier it flags
|
|
6
|
+
* converts machine-resolvable work into a manual rename inside a file that was
|
|
7
|
+
* clean before `--fix` ran (#1997).
|
|
8
|
+
*
|
|
9
|
+
* Scope is the shape a fixer emits: an alphanumeric camelCase/PascalCase
|
|
10
|
+
* identifier. `no-hungarian`'s SCREAMING_SNAKE_CASE branch and its
|
|
11
|
+
* compound-noun / descriptive-suffix / <entity>Number / <domain>Symbol
|
|
12
|
+
* carve-outs are deliberately absent. Every one of them can only turn a
|
|
13
|
+
* rejection into an acceptance, and each is keyed on a trailing word a
|
|
14
|
+
* generated name does not carry, so their absence is unreachable for such a
|
|
15
|
+
* name and, if ever reached, costs the caller its next candidate rather than
|
|
16
|
+
* reintroducing the defect. `no-array-length-in-deps.test.ts` pins agreement
|
|
17
|
+
* with the real rule over the corpus of names its fixer can emit, so a change
|
|
18
|
+
* to either predicate surfaces there.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.encodesTypeMarker = void 0;
|
|
22
|
+
// Abbreviation type markers. No English word is spelled this way, so their
|
|
23
|
+
// presence as a whole camelCase segment is unambiguously a type tag.
|
|
24
|
+
const ABBREVIATION_MARKERS = ['str', 'num', 'int', 'bool', 'arr', 'obj'];
|
|
25
|
+
const COMMON_TYPES = [
|
|
26
|
+
'String',
|
|
27
|
+
'Number',
|
|
28
|
+
'Boolean',
|
|
29
|
+
'Array',
|
|
30
|
+
'Object',
|
|
31
|
+
'RegExp',
|
|
32
|
+
'Promise',
|
|
33
|
+
'Symbol',
|
|
34
|
+
'BigInt',
|
|
35
|
+
];
|
|
36
|
+
const TYPE_MARKERS = [
|
|
37
|
+
...ABBREVIATION_MARKERS,
|
|
38
|
+
'array',
|
|
39
|
+
...COMMON_TYPES,
|
|
40
|
+
'Class',
|
|
41
|
+
'Interface',
|
|
42
|
+
'Enum',
|
|
43
|
+
];
|
|
44
|
+
const ABBREVIATION_MARKER_SET = new Set(ABBREVIATION_MARKERS);
|
|
45
|
+
const FULL_TYPE_WORDS = new Set(COMMON_TYPES.map((word) => word.toLowerCase()));
|
|
46
|
+
// Single-letter Hungarian type prefixes (b=boolean, i=integer/index), matched
|
|
47
|
+
// only as a strict camelCase prefix. This is the arm that rejects `bHash`.
|
|
48
|
+
const SINGLE_LETTER_PREFIXES = new Set(['b', 'i']);
|
|
49
|
+
const CAMEL_SEGMENT_REGEX = /[A-Z]+(?![a-z])|[A-Z]?[a-z0-9]+|[A-Z]/g;
|
|
50
|
+
function splitCamelSegmentsRaw(name) {
|
|
51
|
+
return name.match(CAMEL_SEGMENT_REGEX) ?? [];
|
|
52
|
+
}
|
|
53
|
+
// A built-in type word carrying an internal capital (BigInt) splits into parts
|
|
54
|
+
// whose tail collides with an abbreviation marker (Int/int), so the parts are
|
|
55
|
+
// re-merged into one atomic segment before segment matching.
|
|
56
|
+
const MULTI_CAPITAL_TYPE_WORD_PARTS = COMMON_TYPES.map(splitCamelSegmentsRaw).filter((parts) => parts.length > 1);
|
|
57
|
+
function mergeMultiCapitalTypeWords(segments) {
|
|
58
|
+
const merged = [];
|
|
59
|
+
let index = 0;
|
|
60
|
+
while (index < segments.length) {
|
|
61
|
+
const match = MULTI_CAPITAL_TYPE_WORD_PARTS.find((parts) => parts.every((part, offset) => segments[index + offset]?.toLowerCase() === part.toLowerCase()));
|
|
62
|
+
if (match) {
|
|
63
|
+
merged.push(segments.slice(index, index + match.length).join(''));
|
|
64
|
+
index += match.length;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
merged.push(segments[index]);
|
|
68
|
+
index += 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return merged;
|
|
72
|
+
}
|
|
73
|
+
function splitCamelSegments(name) {
|
|
74
|
+
return mergeMultiCapitalTypeWords(splitCamelSegmentsRaw(name));
|
|
75
|
+
}
|
|
76
|
+
function isPascalCaseName(name) {
|
|
77
|
+
return /^[A-Z]/.test(name) && name !== name.toUpperCase();
|
|
78
|
+
}
|
|
79
|
+
// A PascalCase name whose type word qualifies a DIFFERENT head noun names a
|
|
80
|
+
// concept or relation (StringToNumber, NumberAmountEditor), not the value's own
|
|
81
|
+
// runtime type.
|
|
82
|
+
function isSemanticTypeConcept(name) {
|
|
83
|
+
const segments = splitCamelSegments(name);
|
|
84
|
+
if (segments.length < 2) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const hasTypeWord = segments.some((segment) => FULL_TYPE_WORDS.has(segment.toLowerCase()));
|
|
88
|
+
if (!hasTypeWord) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return segments.some((segment) => !FULL_TYPE_WORDS.has(segment.toLowerCase()));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether `no-hungarian` reads `name` as encoding its own type.
|
|
95
|
+
*/
|
|
96
|
+
function encodesTypeMarker(name) {
|
|
97
|
+
if (isPascalCaseName(name) && isSemanticTypeConcept(name)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
if (name.length > 1 &&
|
|
101
|
+
SINGLE_LETTER_PREFIXES.has(name[0]) &&
|
|
102
|
+
/[A-Z]/.test(name[1])) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
const normalizedName = name.toLowerCase();
|
|
106
|
+
return TYPE_MARKERS.some((marker) => {
|
|
107
|
+
const normalizedMarker = marker.toLowerCase();
|
|
108
|
+
if (normalizedName === normalizedMarker) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
// An abbreviation marker is a type tag only as a whole segment: "int"
|
|
112
|
+
// inside "Mint" or "point" is a word fragment, not a tag.
|
|
113
|
+
if (ABBREVIATION_MARKER_SET.has(normalizedMarker)) {
|
|
114
|
+
return splitCamelSegments(name).some((segment) => segment.toLowerCase() === normalizedMarker);
|
|
115
|
+
}
|
|
116
|
+
// A full type word tags the value's type only at a word boundary in
|
|
117
|
+
// leading or trailing position; a middle segment qualifies a domain concept.
|
|
118
|
+
if (normalizedName.startsWith(normalizedMarker) &&
|
|
119
|
+
normalizedName.length > normalizedMarker.length &&
|
|
120
|
+
/[A-Z0-9]/.test(name[normalizedMarker.length])) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return (normalizedName.endsWith(normalizedMarker) &&
|
|
124
|
+
normalizedName.length > normalizedMarker.length &&
|
|
125
|
+
(/[A-Z0-9]/.test(name[name.length - normalizedMarker.length - 1]) ||
|
|
126
|
+
/[A-Z]/.test(name[name.length - normalizedMarker.length])));
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
exports.encodesTypeMarker = encodesTypeMarker;
|
|
130
|
+
//# sourceMappingURL=hungarianNaming.js.map
|