@blumintinc/eslint-plugin-blumint 1.20.25 → 1.20.26
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/enforce-assert-safe-object-key.js +17 -0
- package/lib/rules/enforce-memoize-async.js +19 -2
- package/lib/rules/enforce-memoize-getters.js +17 -0
- package/lib/rules/enforce-querykey-ts.js +25 -3
- package/lib/rules/enforce-stable-hash-spread-props.js +16 -0
- package/lib/rules/fast-deep-equal-over-microdiff.js +14 -0
- package/lib/rules/logical-top-to-bottom-grouping.js +226 -48
- package/lib/rules/no-array-length-in-deps.js +23 -1
- package/lib/rules/prefer-fragment-component.js +52 -1
- package/lib/rules/prefer-type-over-interface.js +38 -11
- package/lib/rules/prefer-usecallback-over-usememo-for-functions.js +23 -2
- package/lib/rules/require-memoize-jsx-returners.js +17 -0
- package/lib/utils/disableDirectives.d.ts +51 -0
- package/lib/utils/disableDirectives.js +168 -0
- package/package.json +1 -1
- package/release-manifest.json +103 -0
|
@@ -699,34 +699,20 @@ function getNextStart(body, index, parent, sourceCode) {
|
|
|
699
699
|
const closingBraceOffset = parent.type === utils_1.AST_NODE_TYPES.BlockStatement ? 1 : 0;
|
|
700
700
|
return parent.range[1] - closingBraceOffset;
|
|
701
701
|
}
|
|
702
|
-
function buildMoveFix(body, fromIndex, toIndex, parent, sourceCode, fixer) {
|
|
703
|
-
const text = sourceCode.getText();
|
|
704
|
-
if (toIndex < fromIndex) {
|
|
705
|
-
const segmentStart = getStartWithComments(body[toIndex], sourceCode);
|
|
706
|
-
const movingStart = getStartWithComments(body[fromIndex], sourceCode);
|
|
707
|
-
const segmentEnd = getNextStart(body, fromIndex, parent, sourceCode);
|
|
708
|
-
const before = text.slice(segmentStart, movingStart);
|
|
709
|
-
const moving = text.slice(movingStart, segmentEnd).replace(/[ \t]+$/u, '');
|
|
710
|
-
const newText = moving + before;
|
|
711
|
-
return fixer.replaceTextRange([segmentStart, segmentEnd], newText);
|
|
712
|
-
}
|
|
713
|
-
const segmentStart = getStartWithComments(body[fromIndex], sourceCode);
|
|
714
|
-
const movingEnd = getNextStart(body, fromIndex, parent, sourceCode);
|
|
715
|
-
const segmentEnd = getStartWithComments(body[toIndex], sourceCode);
|
|
716
|
-
const moving = text.slice(segmentStart, movingEnd).replace(/[ \t]+$/u, '');
|
|
717
|
-
const between = text.slice(movingEnd, segmentEnd);
|
|
718
|
-
const newText = between + moving;
|
|
719
|
-
return fixer.replaceTextRange([segmentStart, segmentEnd], newText);
|
|
720
|
-
}
|
|
721
702
|
function truncateWithEllipsis(text, max = 60) {
|
|
722
703
|
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
723
704
|
}
|
|
724
|
-
|
|
725
|
-
|
|
705
|
+
/**
|
|
706
|
+
* At most one violation per statement: a statement can qualify under several
|
|
707
|
+
* handlers with contradictory targets, so the first handler to claim it wins and
|
|
708
|
+
* the handler call order in `detectViolations` is the tie-break.
|
|
709
|
+
*/
|
|
710
|
+
function record(sink, statement, messageId, data, fromIndex, toIndex) {
|
|
711
|
+
if (sink.flagged.has(statement)) {
|
|
726
712
|
return;
|
|
727
713
|
}
|
|
728
|
-
|
|
729
|
-
|
|
714
|
+
sink.flagged.add(statement);
|
|
715
|
+
sink.violations.push({ statement, messageId, data, fromIndex, toIndex });
|
|
730
716
|
}
|
|
731
717
|
function isGuardIfStatement(statement) {
|
|
732
718
|
if (statement.type !== utils_1.AST_NODE_TYPES.IfStatement || statement.alternate) {
|
|
@@ -749,8 +735,8 @@ function isGuardIfStatement(statement) {
|
|
|
749
735
|
}
|
|
750
736
|
return false;
|
|
751
737
|
}
|
|
752
|
-
function handleGuardHoists(
|
|
753
|
-
const { sourceCode } =
|
|
738
|
+
function handleGuardHoists(sink, body) {
|
|
739
|
+
const { sourceCode } = sink;
|
|
754
740
|
body.forEach((statement, index) => {
|
|
755
741
|
if (!isGuardIfStatement(statement)) {
|
|
756
742
|
return;
|
|
@@ -770,10 +756,10 @@ function handleGuardHoists(ruleContext, body, parent) {
|
|
|
770
756
|
if (targetIndex === index) {
|
|
771
757
|
return;
|
|
772
758
|
}
|
|
773
|
-
|
|
759
|
+
record(sink, statement, 'moveGuardUp', { guard: truncateWithEllipsis(sourceCode.getText(statement.test)) }, index, targetIndex);
|
|
774
760
|
});
|
|
775
761
|
}
|
|
776
|
-
function handleDerivedGrouping(
|
|
762
|
+
function handleDerivedGrouping(sink, body) {
|
|
777
763
|
const declaredIndices = new Map();
|
|
778
764
|
/**
|
|
779
765
|
* Each binding name → the declarator that introduced it. Sibling bindings of
|
|
@@ -782,10 +768,9 @@ function handleDerivedGrouping(ruleContext, body, parent) {
|
|
|
782
768
|
* from `b` even though `a` separates `b` from its declaration.
|
|
783
769
|
*/
|
|
784
770
|
const sourceDeclarators = new Map();
|
|
785
|
-
const { sourceCode } = ruleContext;
|
|
786
771
|
body.forEach((statement, index) => {
|
|
787
772
|
if (isVariableDeclaration(statement)) {
|
|
788
|
-
processVariableDeclaration(
|
|
773
|
+
processVariableDeclaration(sink, statement, index, body, declaredIndices, sourceDeclarators);
|
|
789
774
|
}
|
|
790
775
|
trackDeclaredNames(statement, index, declaredIndices);
|
|
791
776
|
trackSourceDeclarators(statement, sourceDeclarators);
|
|
@@ -794,11 +779,10 @@ function handleDerivedGrouping(ruleContext, body, parent) {
|
|
|
794
779
|
function isVariableDeclaration(statement) {
|
|
795
780
|
return statement.type === utils_1.AST_NODE_TYPES.VariableDeclaration;
|
|
796
781
|
}
|
|
797
|
-
function processVariableDeclaration(
|
|
782
|
+
function processVariableDeclaration(sink, statement, index, body, declaredIndices, sourceDeclarators) {
|
|
798
783
|
const dependencies = collectDependencies(statement);
|
|
799
784
|
const priorDependencies = findPriorDependencies(dependencies, declaredIndices);
|
|
800
|
-
if (priorDependencies.length === 0 ||
|
|
801
|
-
ruleContext.reportedStatements.has(statement)) {
|
|
785
|
+
if (priorDependencies.length === 0 || sink.flagged.has(statement)) {
|
|
802
786
|
return;
|
|
803
787
|
}
|
|
804
788
|
const lastDependencyIndex = findLastDependencyIndex(priorDependencies, declaredIndices);
|
|
@@ -813,7 +797,7 @@ function processVariableDeclaration(ruleContext, statement, index, body, declare
|
|
|
813
797
|
if (hasBlockers(body, lastDependencyIndex, index, priorDependencySet, declaredNames)) {
|
|
814
798
|
return;
|
|
815
799
|
}
|
|
816
|
-
reportDerivedGroupingViolation(
|
|
800
|
+
reportDerivedGroupingViolation(sink, statement, priorDependencies, declaredNames, index, lastDependencyIndex);
|
|
817
801
|
}
|
|
818
802
|
function collectDependencies(statement) {
|
|
819
803
|
const dependencies = new Set();
|
|
@@ -843,13 +827,13 @@ function hasBlockers(body, lastDependencyIndex, currentIndex, priorDependencySet
|
|
|
843
827
|
statementDeclaresAny(between, declaredNames) ||
|
|
844
828
|
statementReferencesAny(between, declaredNames));
|
|
845
829
|
}
|
|
846
|
-
function reportDerivedGroupingViolation(
|
|
830
|
+
function reportDerivedGroupingViolation(sink, statement, priorDependencies, declaredNames, currentIndex, lastDependencyIndex) {
|
|
847
831
|
const dependency = priorDependencies[0];
|
|
848
832
|
const name = declaredNames.values().next().value ?? 'value';
|
|
849
|
-
|
|
833
|
+
record(sink, statement, 'groupDerived', {
|
|
850
834
|
dependency,
|
|
851
835
|
name,
|
|
852
|
-
},
|
|
836
|
+
}, currentIndex, lastDependencyIndex + 1);
|
|
853
837
|
}
|
|
854
838
|
function trackDeclaredNames(statement, index, declaredIndices) {
|
|
855
839
|
const declared = getDeclaredNames(statement);
|
|
@@ -971,8 +955,7 @@ function isMutatedInLoop(body, usageIndex, nameSet) {
|
|
|
971
955
|
}
|
|
972
956
|
return statementMutatesAny(firstUsage, nameSet);
|
|
973
957
|
}
|
|
974
|
-
function handleLateDeclarations(
|
|
975
|
-
const { sourceCode } = ruleContext;
|
|
958
|
+
function handleLateDeclarations(sink, body) {
|
|
976
959
|
body.forEach((statement, index) => {
|
|
977
960
|
if (!isLateDeclarationCandidate(statement)) {
|
|
978
961
|
return;
|
|
@@ -1026,7 +1009,7 @@ function handleLateDeclarations(ruleContext, body, parent) {
|
|
|
1026
1009
|
if (crossesImpureOrTracked) {
|
|
1027
1010
|
return;
|
|
1028
1011
|
}
|
|
1029
|
-
|
|
1012
|
+
record(sink, statement, 'moveDeclarationCloser', { name }, index, usageIndex);
|
|
1030
1013
|
});
|
|
1031
1014
|
}
|
|
1032
1015
|
function extractCallExpression(expression) {
|
|
@@ -1327,8 +1310,8 @@ function isSideEffectExpression(statement) {
|
|
|
1327
1310
|
}
|
|
1328
1311
|
return Boolean(extractCallExpression(statement.expression));
|
|
1329
1312
|
}
|
|
1330
|
-
function handleSideEffects(
|
|
1331
|
-
const { sourceCode } =
|
|
1313
|
+
function handleSideEffects(sink, body) {
|
|
1314
|
+
const { sourceCode } = sink;
|
|
1332
1315
|
body.forEach((statement, index) => {
|
|
1333
1316
|
if (!isSideEffectExpression(statement)) {
|
|
1334
1317
|
return;
|
|
@@ -1357,15 +1340,211 @@ function handleSideEffects(ruleContext, body, parent) {
|
|
|
1357
1340
|
return;
|
|
1358
1341
|
}
|
|
1359
1342
|
const effectText = truncateWithEllipsis(sourceCode.getText(statement).trim());
|
|
1360
|
-
|
|
1343
|
+
record(sink, statement, 'moveSideEffect', { effect: effectText }, index, targetIndex);
|
|
1361
1344
|
});
|
|
1362
1345
|
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Detection is pure over the statement order, so it scores a hypothetical
|
|
1348
|
+
* reordering exactly as it scores the real one. Handler order is the tie-break for
|
|
1349
|
+
* a statement several handlers claim — do not reorder these calls.
|
|
1350
|
+
*/
|
|
1351
|
+
function detectViolations(sourceCode, body) {
|
|
1352
|
+
const sink = {
|
|
1353
|
+
sourceCode,
|
|
1354
|
+
flagged: new Set(),
|
|
1355
|
+
violations: [],
|
|
1356
|
+
};
|
|
1357
|
+
handleGuardHoists(sink, body);
|
|
1358
|
+
handleDerivedGrouping(sink, body);
|
|
1359
|
+
handleLateDeclarations(sink, body);
|
|
1360
|
+
handleSideEffects(sink, body);
|
|
1361
|
+
return sink.violations;
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Mirrors the rotation `moveSegment` performs on text, at statement granularity, so a
|
|
1365
|
+
* candidate ordering can be scored without rewriting and reparsing the source. The
|
|
1366
|
+
* two must stay in step: the search verifies the order that the fix emits.
|
|
1367
|
+
*/
|
|
1368
|
+
function applyMove(body, fromIndex, toIndex) {
|
|
1369
|
+
const next = [...body];
|
|
1370
|
+
const [moved] = next.splice(fromIndex, 1);
|
|
1371
|
+
next.splice(toIndex < fromIndex ? toIndex : toIndex - 1, 0, moved);
|
|
1372
|
+
return next;
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Upper bound on candidate moves expanded per search node. Each candidate costs one
|
|
1376
|
+
* full detection pass, so a node offering more violations than this has its tail
|
|
1377
|
+
* ignored: the frontier stays bounded and the budget buys depth rather than width.
|
|
1378
|
+
*/
|
|
1379
|
+
const MAX_FIX_CANDIDATES = 12;
|
|
1380
|
+
/**
|
|
1381
|
+
* Real code routinely needs a run of moves before every constraint holds: hoisting
|
|
1382
|
+
* one `jest.mock` exposes the next, and interleaved `before`/`after` fixtures pass
|
|
1383
|
+
* through orders with *more* violations than they started with before settling at
|
|
1384
|
+
* zero. Satisfying every adjacency constraint at once therefore requires looking well
|
|
1385
|
+
* past the first move.
|
|
1386
|
+
*
|
|
1387
|
+
* The block's violation count is the search's initial branching factor, so a lone
|
|
1388
|
+
* violation starts a near-chain that can be followed far while a wide block cannot.
|
|
1389
|
+
* Bodies too large for repeated detection are skipped outright, and `SEARCH_BUDGET`
|
|
1390
|
+
* caps total detections whatever the shape — a block whose ordering cannot be settled
|
|
1391
|
+
* inside the budget is reported without a fix.
|
|
1392
|
+
*/
|
|
1393
|
+
const SEARCH_DEPTH_CHAIN = 16;
|
|
1394
|
+
const SEARCH_DEPTH_BRANCHING = 6;
|
|
1395
|
+
const SEARCH_MAX_VIOLATIONS = 12;
|
|
1396
|
+
const SEARCH_MAX_STATEMENTS = 120;
|
|
1397
|
+
const SEARCH_BUDGET = 400;
|
|
1398
|
+
function searchDepthFor(violationCount, statementCount) {
|
|
1399
|
+
if (statementCount > SEARCH_MAX_STATEMENTS) {
|
|
1400
|
+
return 0;
|
|
1401
|
+
}
|
|
1402
|
+
if (violationCount === 1) {
|
|
1403
|
+
return SEARCH_DEPTH_CHAIN;
|
|
1404
|
+
}
|
|
1405
|
+
if (violationCount <= SEARCH_MAX_VIOLATIONS) {
|
|
1406
|
+
return SEARCH_DEPTH_BRANCHING;
|
|
1407
|
+
}
|
|
1408
|
+
return 0;
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Statement identity is stable across reorderings, so the original indices of an
|
|
1412
|
+
* order identify it uniquely and cheaply — which is what lets the search skip orders
|
|
1413
|
+
* reachable by more than one sequence of moves.
|
|
1414
|
+
*/
|
|
1415
|
+
function orderKey(order, indices) {
|
|
1416
|
+
return order.map((statement) => indices.get(statement)).join(',');
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Shortest sequence of moves reaching an order with **zero** violations, or null when
|
|
1420
|
+
* the search bounds contain no such order.
|
|
1421
|
+
*
|
|
1422
|
+
* Breadth-first for two reasons: the emitted fix is then the smallest reordering that
|
|
1423
|
+
* satisfies every constraint, and a block whose single named move already suffices
|
|
1424
|
+
* yields exactly that move.
|
|
1425
|
+
*
|
|
1426
|
+
* Violation count is not used to prune: an order that trades one violation for two
|
|
1427
|
+
* can still be the only route to a clean order, so the frontier is bounded by move
|
|
1428
|
+
* count and detection budget rather than by any notion of progress. Only the
|
|
1429
|
+
* zero-violation goal test decides whether a fix is emitted at all.
|
|
1430
|
+
*/
|
|
1431
|
+
function findResolvingMoves(sourceCode, body, violations, maxMoves) {
|
|
1432
|
+
if (maxMoves === 0) {
|
|
1433
|
+
return null;
|
|
1434
|
+
}
|
|
1435
|
+
const indices = new Map(body.map((statement, index) => [statement, index]));
|
|
1436
|
+
const seen = new Set([orderKey(body, indices)]);
|
|
1437
|
+
const queue = [{ order: body, violations, moves: [] }];
|
|
1438
|
+
let budget = SEARCH_BUDGET;
|
|
1439
|
+
while (queue.length > 0) {
|
|
1440
|
+
const node = queue.shift();
|
|
1441
|
+
if (node.moves.length >= maxMoves) {
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
const candidates = Math.min(node.violations.length, MAX_FIX_CANDIDATES);
|
|
1445
|
+
for (let candidate = 0; candidate < candidates; candidate += 1) {
|
|
1446
|
+
if (budget <= 0) {
|
|
1447
|
+
return null;
|
|
1448
|
+
}
|
|
1449
|
+
const { fromIndex, toIndex } = node.violations[candidate];
|
|
1450
|
+
const order = applyMove(node.order, fromIndex, toIndex);
|
|
1451
|
+
const key = orderKey(order, indices);
|
|
1452
|
+
if (seen.has(key)) {
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
seen.add(key);
|
|
1456
|
+
budget -= 1;
|
|
1457
|
+
const next = detectViolations(sourceCode, order);
|
|
1458
|
+
const moves = [...node.moves, { fromIndex, toIndex }];
|
|
1459
|
+
if (next.length === 0) {
|
|
1460
|
+
return moves;
|
|
1461
|
+
}
|
|
1462
|
+
queue.push({ order, violations: next, moves });
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
return null;
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Statement `i` owns the text from its own leading comments and indentation up to
|
|
1469
|
+
* where statement `i + 1`'s begins, so the segments tile the block's statement region
|
|
1470
|
+
* exactly and any reordering is a permutation of them.
|
|
1471
|
+
*/
|
|
1472
|
+
function collectSegments(body, parent, sourceCode) {
|
|
1473
|
+
const text = sourceCode.getText();
|
|
1474
|
+
const bounds = body.map((statement) => getStartWithComments(statement, sourceCode));
|
|
1475
|
+
bounds.push(getNextStart(body, body.length - 1, parent, sourceCode));
|
|
1476
|
+
return {
|
|
1477
|
+
bounds,
|
|
1478
|
+
segments: body.map((_, index) => text.slice(bounds[index], bounds[index + 1])),
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Trailing spaces and tabs are dropped as a segment moves: that whitespace is the
|
|
1483
|
+
* indentation of whatever used to follow the segment, not part of the segment itself.
|
|
1484
|
+
* Keeping it would double the indentation at the segment's destination.
|
|
1485
|
+
*/
|
|
1486
|
+
function moveSegment(segments, fromIndex, toIndex) {
|
|
1487
|
+
const next = [...segments];
|
|
1488
|
+
const [moved] = next.splice(fromIndex, 1);
|
|
1489
|
+
next.splice(toIndex < fromIndex ? toIndex : toIndex - 1, 0, moved.replace(/[ \t]+$/u, ''));
|
|
1490
|
+
return next;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Emits an entire reordering as one fix by permuting the block's text segments.
|
|
1494
|
+
*
|
|
1495
|
+
* Only the span that actually changes is replaced. That keeps the fix disjoint from
|
|
1496
|
+
* fixes in nested and sibling blocks — ESLint discards overlapping fixes — and makes
|
|
1497
|
+
* a one-move reordering byte-identical to relocating that one statement.
|
|
1498
|
+
*/
|
|
1499
|
+
function buildReorderFix(body, moves, parent, sourceCode, fixer) {
|
|
1500
|
+
const { bounds, segments } = collectSegments(body, parent, sourceCode);
|
|
1501
|
+
const reordered = moves.reduce((current, move) => moveSegment(current, move.fromIndex, move.toIndex), segments);
|
|
1502
|
+
let first = 0;
|
|
1503
|
+
while (first < segments.length && reordered[first] === segments[first]) {
|
|
1504
|
+
first += 1;
|
|
1505
|
+
}
|
|
1506
|
+
let last = segments.length - 1;
|
|
1507
|
+
while (last > first && reordered[last] === segments[last]) {
|
|
1508
|
+
last -= 1;
|
|
1509
|
+
}
|
|
1510
|
+
if (first > last) {
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
return fixer.replaceTextRange([bounds[first], bounds[last + 1]], reordered.slice(first, last + 1).join(''));
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* A fix is emitted only for a reordering the detector scores at zero violations, and
|
|
1517
|
+
* the whole reordering ships as a single fix. Relocating one statement per report
|
|
1518
|
+
* satisfies its own adjacency constraint while breaking another's, which under
|
|
1519
|
+
* `--fix` oscillates or exhausts the pass budget (#1405).
|
|
1520
|
+
*
|
|
1521
|
+
* Convergence is structural rather than argued: the emitted order is verified clean
|
|
1522
|
+
* by the same detector that produced the reports, and detection depends only on
|
|
1523
|
+
* statement order, so a single pass settles the block. Blocks with no clean order in
|
|
1524
|
+
* range are reported without a fix — a report the developer resolves beats a fix that
|
|
1525
|
+
* leaves a different violation behind.
|
|
1526
|
+
*
|
|
1527
|
+
* The fix rides on the first report because the reordering resolves every violation
|
|
1528
|
+
* in the block at once; a second fix would be redundant and would overlap this one.
|
|
1529
|
+
*/
|
|
1363
1530
|
function handleBlock(ruleContext, node) {
|
|
1364
|
-
const
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1531
|
+
const { context, sourceCode } = ruleContext;
|
|
1532
|
+
const body = node.body;
|
|
1533
|
+
const violations = detectViolations(sourceCode, body);
|
|
1534
|
+
if (violations.length === 0) {
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
const moves = findResolvingMoves(sourceCode, body, violations, searchDepthFor(violations.length, body.length));
|
|
1538
|
+
violations.forEach((violation, index) => {
|
|
1539
|
+
context.report({
|
|
1540
|
+
node: violation.statement,
|
|
1541
|
+
messageId: violation.messageId,
|
|
1542
|
+
data: violation.data,
|
|
1543
|
+
fix: moves && index === 0
|
|
1544
|
+
? (fixer) => buildReorderFix(body, moves, node, sourceCode, fixer)
|
|
1545
|
+
: null,
|
|
1546
|
+
});
|
|
1547
|
+
});
|
|
1369
1548
|
}
|
|
1370
1549
|
exports.logicalTopToBottomGrouping = (0, createRule_1.createRule)({
|
|
1371
1550
|
name: 'logical-top-to-bottom-grouping',
|
|
@@ -1392,7 +1571,6 @@ exports.logicalTopToBottomGrouping = (0, createRule_1.createRule)({
|
|
|
1392
1571
|
const ruleContext = {
|
|
1393
1572
|
context,
|
|
1394
1573
|
sourceCode,
|
|
1395
|
-
reportedStatements: new WeakSet(),
|
|
1396
1574
|
};
|
|
1397
1575
|
const visitBlock = (node) => handleBlock(ruleContext, node);
|
|
1398
1576
|
return {
|
|
@@ -4,6 +4,7 @@ exports.noArrayLengthInDeps = void 0;
|
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
6
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
7
8
|
// React hooks to check
|
|
8
9
|
const HOOK_NAMES = new Set(['useEffect', 'useCallback', 'useMemo']);
|
|
9
10
|
const DEFAULT_HASH_IMPORT = {
|
|
@@ -370,6 +371,15 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
370
371
|
let importsPlanned = false;
|
|
371
372
|
const perBlockDeclaredBases = new WeakMap();
|
|
372
373
|
const perBlockBaseToVar = new WeakMap();
|
|
374
|
+
/**
|
|
375
|
+
* `useMemo` and the hash helper are imported by a single violation's fix,
|
|
376
|
+
* making that violation the file's import carrier. ESLint builds fixes
|
|
377
|
+
* before it applies inline disable directives, so a suppressed carrier
|
|
378
|
+
* takes both imports down with it while the surviving violations still
|
|
379
|
+
* emit `useMemo(() => stableHash(...))` — two unbound identifiers that no
|
|
380
|
+
* number of `--fix` passes can repair.
|
|
381
|
+
*/
|
|
382
|
+
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
373
383
|
return {
|
|
374
384
|
CallExpression(node) {
|
|
375
385
|
if (!isHookCall(node))
|
|
@@ -404,7 +414,10 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
404
414
|
const dependencies = lengthDeps
|
|
405
415
|
.map(({ element }) => sourceCode.getText(element))
|
|
406
416
|
.join(', ');
|
|
407
|
-
// Report once on the dependency array
|
|
417
|
+
// Report once on the dependency array. The report is emitted even when
|
|
418
|
+
// suppressed: ESLint discards it, and reporting keeps the user's
|
|
419
|
+
// disable directive "used" so `--report-unused-disable-directives`
|
|
420
|
+
// does not flag it.
|
|
408
421
|
context.report({
|
|
409
422
|
node: depsArg,
|
|
410
423
|
messageId: 'noArrayLengthInDeps',
|
|
@@ -412,6 +425,15 @@ exports.noArrayLengthInDeps = (0, createRule_1.createRule)({
|
|
|
412
425
|
dependencies,
|
|
413
426
|
},
|
|
414
427
|
fix(fixer) {
|
|
428
|
+
// A suppressed report is dropped together with its fix. Declining
|
|
429
|
+
// to fix — and leaving the imports and the per-block declarations
|
|
430
|
+
// unclaimed — passes the carrier slot to the first violation that
|
|
431
|
+
// actually survives. Checked against the reported node so the
|
|
432
|
+
// resolution matches ESLint's own, and before every other bail so
|
|
433
|
+
// no shared state is touched.
|
|
434
|
+
if (isReportSuppressed(depsArg)) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
415
437
|
// All bail checks precede any shared-state mutation so a skipped
|
|
416
438
|
// fix cannot make a later fix believe imports or declarations are
|
|
417
439
|
// already handled.
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.preferFragmentComponent = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
6
7
|
exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
7
8
|
name: 'prefer-fragment-component',
|
|
8
9
|
meta: {
|
|
@@ -21,6 +22,13 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
21
22
|
defaultOptions: [],
|
|
22
23
|
create(context) {
|
|
23
24
|
const sourceCode = context.sourceCode;
|
|
25
|
+
// The `import { Fragment } from 'react'` edit rides on one violation's fix,
|
|
26
|
+
// making that violation the file's import carrier. ESLint builds fixes
|
|
27
|
+
// eagerly and drops inline-disabled reports afterwards, so a suppressed
|
|
28
|
+
// carrier would take the import down with it while the surviving
|
|
29
|
+
// violations still emit <Fragment>. Resolving suppression before the latch
|
|
30
|
+
// is read hands the carrier slot to the first violation that survives.
|
|
31
|
+
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
24
32
|
let hasFragmentImport = false;
|
|
25
33
|
let reactImportNode = null;
|
|
26
34
|
let defaultReactImportNode = null;
|
|
@@ -90,6 +98,29 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
90
98
|
}
|
|
91
99
|
}
|
|
92
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Where a brand-new import statement can be spliced in without changing
|
|
103
|
+
* what the file's directives govern. A whole-line insertion directly before
|
|
104
|
+
* the first statement slides in between an `eslint-disable-next-line`
|
|
105
|
+
* comment and the line it applies to, silently retargeting that directive
|
|
106
|
+
* at the import. Anchoring above such comments keeps every directive
|
|
107
|
+
* pointed at the code its author aimed it at.
|
|
108
|
+
*/
|
|
109
|
+
function findImportAnchor() {
|
|
110
|
+
const firstStatement = sourceCode.ast.body[0];
|
|
111
|
+
let anchor = firstStatement;
|
|
112
|
+
const leadingComments = sourceCode.getCommentsBefore(firstStatement);
|
|
113
|
+
for (let index = leadingComments.length - 1; index >= 0; index--) {
|
|
114
|
+
const comment = leadingComments[index];
|
|
115
|
+
const [directive] = (0, disableDirectives_1.parseDisableDirectives)([comment]);
|
|
116
|
+
if (directive?.kind !== 'disable-next-line' ||
|
|
117
|
+
comment.loc.end.line + 1 !== anchor.loc.start.line) {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
anchor = comment;
|
|
121
|
+
}
|
|
122
|
+
return anchor;
|
|
123
|
+
}
|
|
93
124
|
/**
|
|
94
125
|
* Adds Fragment import to an appropriate React import or creates a new one
|
|
95
126
|
*/
|
|
@@ -116,7 +147,7 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
116
147
|
// No React import found, create a new one
|
|
117
148
|
const importText = "import { Fragment } from 'react';\n";
|
|
118
149
|
const indentation = sourceCode.text.match(/^[ \t]*/m)?.[0] || '';
|
|
119
|
-
return fixer.insertTextBefore(
|
|
150
|
+
return fixer.insertTextBefore(findImportAnchor(), indentation + importText);
|
|
120
151
|
}
|
|
121
152
|
return {
|
|
122
153
|
ImportDeclaration: checkFragmentImport,
|
|
@@ -149,6 +180,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
149
180
|
messageId: 'preferFragment',
|
|
150
181
|
data: { type: 'shorthand fragment (<>)' },
|
|
151
182
|
fix(fixer) {
|
|
183
|
+
// A suppressed report is discarded together with its fix, so it
|
|
184
|
+
// must not claim the import carrier slot.
|
|
185
|
+
if (isReportSuppressed(node)) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
152
188
|
const fixes = [];
|
|
153
189
|
// Add Fragment import if needed
|
|
154
190
|
if (!hasFragmentImport) {
|
|
@@ -204,6 +240,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
204
240
|
messageId: 'preferFragment',
|
|
205
241
|
data: { type: 'shorthand fragment (<>)' },
|
|
206
242
|
fix(fixer) {
|
|
243
|
+
// A suppressed report is discarded together with its fix, so it
|
|
244
|
+
// must not claim the import carrier slot.
|
|
245
|
+
if (isReportSuppressed(node)) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
207
248
|
const fixes = [];
|
|
208
249
|
// Add Fragment import if needed
|
|
209
250
|
if (!hasFragmentImport) {
|
|
@@ -250,6 +291,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
250
291
|
messageId: 'preferFragment',
|
|
251
292
|
data: { type: 'React.Fragment' },
|
|
252
293
|
fix(fixer) {
|
|
294
|
+
// A suppressed report is discarded together with its fix, so it
|
|
295
|
+
// must not claim the import carrier slot.
|
|
296
|
+
if (isReportSuppressed(node.name)) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
253
299
|
const fixes = [];
|
|
254
300
|
// Add Fragment import if needed
|
|
255
301
|
if (!hasFragmentImport) {
|
|
@@ -281,6 +327,11 @@ exports.preferFragmentComponent = (0, createRule_1.createRule)({
|
|
|
281
327
|
messageId: 'preferFragment',
|
|
282
328
|
data: { type: 'React.Fragment' },
|
|
283
329
|
fix(fixer) {
|
|
330
|
+
// A suppressed report is discarded together with its fix, so it
|
|
331
|
+
// must not claim the import carrier slot.
|
|
332
|
+
if (isReportSuppressed(node.name)) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
284
335
|
const fixes = [];
|
|
285
336
|
// Add Fragment import if needed
|
|
286
337
|
if (!hasFragmentImport) {
|
|
@@ -30,19 +30,46 @@ exports.preferTypeOverInterface = (0, createRule_1.createRule)({
|
|
|
30
30
|
},
|
|
31
31
|
fix(fixer) {
|
|
32
32
|
const sourceCode = context.sourceCode;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
33
|
+
// The `=` must land after the entire declaration header (the
|
|
34
|
+
// name plus any type-parameter list); anchoring on the
|
|
35
|
+
// identifier alone emits unparseable `type Name =<T> {`.
|
|
36
|
+
const header = node.typeParameters ?? node.id;
|
|
37
|
+
const keywordSpan = [
|
|
38
|
+
node.range[0],
|
|
39
|
+
node.id.range[0],
|
|
38
40
|
];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
// Everything between the header and the opening brace is
|
|
42
|
+
// rewritten wholesale rather than patched token by token: the
|
|
43
|
+
// heritage list needs `,` turned into `&` and the `extends`
|
|
44
|
+
// keyword dropped, and surgical edits leave the separators and
|
|
45
|
+
// the keyword's surrounding whitespace behind. The body starts at
|
|
46
|
+
// the opening brace, so this span cannot swallow a `{` belonging
|
|
47
|
+
// to a heritage type argument or a type-parameter constraint.
|
|
48
|
+
const headerSpan = [
|
|
49
|
+
header.range[1],
|
|
50
|
+
node.body.range[0],
|
|
51
|
+
];
|
|
52
|
+
// Both rewritten spans are replaced in full, so a comment sitting
|
|
53
|
+
// inside either one would be silently destroyed (and a line
|
|
54
|
+
// comment would even swallow the `=` that follows it). Reporting
|
|
55
|
+
// without a fix preserves the author's prose; the conversion is
|
|
56
|
+
// then made by hand.
|
|
57
|
+
const clobbersComment = sourceCode
|
|
58
|
+
.getAllComments()
|
|
59
|
+
.some((comment) => [keywordSpan, headerSpan].some(([start, end]) => comment.range[0] < end && comment.range[1] > start));
|
|
60
|
+
if (clobbersComment) {
|
|
61
|
+
return null;
|
|
44
62
|
}
|
|
45
|
-
|
|
63
|
+
const heritage = node.extends ?? [];
|
|
64
|
+
// `getText` round-trips type arguments and qualified names, so
|
|
65
|
+
// `extends ns.B<T>, C` becomes `ns.B<T> & C`.
|
|
66
|
+
const intersection = heritage
|
|
67
|
+
.map((clause) => sourceCode.getText(clause))
|
|
68
|
+
.join(' & ');
|
|
69
|
+
return [
|
|
70
|
+
fixer.replaceTextRange(keywordSpan, 'type '),
|
|
71
|
+
fixer.replaceTextRange(headerSpan, heritage.length > 0 ? ` = ${intersection} & ` : ' = '),
|
|
72
|
+
];
|
|
46
73
|
},
|
|
47
74
|
});
|
|
48
75
|
},
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.preferUseCallbackOverUseMemoForFunctions = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
6
7
|
exports.preferUseCallbackOverUseMemoForFunctions = (0, createRule_1.createRule)({
|
|
7
8
|
name: 'prefer-usecallback-over-usememo-for-functions',
|
|
8
9
|
meta: {
|
|
@@ -119,6 +120,14 @@ exports.preferUseCallbackOverUseMemoForFunctions = (0, createRule_1.createRule)(
|
|
|
119
120
|
// knowing every conversion in the file: useMemo may only be dropped from the
|
|
120
121
|
// import when no reference to it survives the fixes.
|
|
121
122
|
const candidates = [];
|
|
123
|
+
/**
|
|
124
|
+
* A suppressed report is discarded together with its fix, yet its
|
|
125
|
+
* `useMemo(...)` call stays in the file. Both halves of the import rewrite
|
|
126
|
+
* therefore hinge on suppression: the rename must not ride on a violation
|
|
127
|
+
* that disappears, and it must not retire a specifier a suppressed call
|
|
128
|
+
* still resolves to.
|
|
129
|
+
*/
|
|
130
|
+
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
122
131
|
return {
|
|
123
132
|
CallExpression(node) {
|
|
124
133
|
// Check if the call is to useMemo
|
|
@@ -149,7 +158,7 @@ exports.preferUseCallbackOverUseMemoForFunctions = (0, createRule_1.createRule)(
|
|
|
149
158
|
if (candidates.length === 0) {
|
|
150
159
|
return;
|
|
151
160
|
}
|
|
152
|
-
const plan = planConversion(context, candidates);
|
|
161
|
+
const plan = planConversion(context, candidates, isReportSuppressed);
|
|
153
162
|
for (const candidate of candidates) {
|
|
154
163
|
reportAndFix(candidate.node, context, plan);
|
|
155
164
|
}
|
|
@@ -261,7 +270,7 @@ function removeImportSpecifierFixes(sourceCode, fixer, binding) {
|
|
|
261
270
|
* Works out which conversions can be autofixed and how the import list must
|
|
262
271
|
* change so the emitted useCallback resolves and useMemo is not left dangling.
|
|
263
272
|
*/
|
|
264
|
-
function planConversion(context, candidates) {
|
|
273
|
+
function planConversion(context, candidates, isReportSuppressed) {
|
|
265
274
|
const sourceCode = context.getSourceCode();
|
|
266
275
|
const program = sourceCode.ast;
|
|
267
276
|
const existingUseCallback = findImportedSpecifier(program, 'useCallback');
|
|
@@ -270,7 +279,13 @@ function planConversion(context, candidates) {
|
|
|
270
279
|
: 'useCallback';
|
|
271
280
|
// A local binding of the target name would capture the emitted call, so those
|
|
272
281
|
// conversions are reported without a fix rather than silently miscompiled.
|
|
282
|
+
// A suppressed violation is skipped for the same reason it is skipped for the
|
|
283
|
+
// import: ESLint drops its fix, so treating it as unfixable keeps the plan
|
|
284
|
+
// honest about which useMemo calls actually go away.
|
|
273
285
|
const fixableCandidates = candidates.filter((candidate) => {
|
|
286
|
+
if (isReportSuppressed(candidate.node)) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
274
289
|
const bound = resolveVariable(candidate.scope, calleeName);
|
|
275
290
|
if (!existingUseCallback) {
|
|
276
291
|
return bound === null;
|
|
@@ -298,6 +313,10 @@ function planConversion(context, candidates) {
|
|
|
298
313
|
const convertedReferences = new Set(fixableCandidates
|
|
299
314
|
.filter((candidate) => toImportBinding(resolveVariable(candidate.scope, candidate.node.callee.name))?.variable === useMemoBinding.variable)
|
|
300
315
|
.map((candidate) => candidate.node.callee));
|
|
316
|
+
// Every reference the fixes do not rewrite still needs the specifier: a plain
|
|
317
|
+
// value memo, a bare `useMemo` mention, a conversion blocked by shadowing, or
|
|
318
|
+
// the call behind a disable directive. Any one of them turns the rename into
|
|
319
|
+
// a plain insertion of useCallback beside useMemo.
|
|
301
320
|
const useMemoSurvives = useMemoBinding.variable.references.some((reference) => !convertedReferences.has(reference.identifier));
|
|
302
321
|
const shouldAdd = existingUseCallback === null;
|
|
303
322
|
const shouldRemove = !useMemoSurvives;
|
|
@@ -307,6 +326,8 @@ function planConversion(context, candidates) {
|
|
|
307
326
|
return {
|
|
308
327
|
calleeName,
|
|
309
328
|
fixable,
|
|
329
|
+
// The carrier is the first violation whose fix actually survives, so a
|
|
330
|
+
// suppressed leading violation cannot take the import edit down with it.
|
|
310
331
|
importOwner: fixableCandidates[0].node,
|
|
311
332
|
importFixes: (fixer) => {
|
|
312
333
|
if (shouldAdd && shouldRemove) {
|