@blumintinc/eslint-plugin-blumint 1.20.24 → 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.
@@ -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
- function reportOnce({ context, reportedStatements }, statement, messageId, data, fix) {
725
- if (reportedStatements.has(statement)) {
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
- reportedStatements.add(statement);
729
- context.report({ node: statement, messageId, data, fix });
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(ruleContext, body, parent) {
753
- const { sourceCode } = ruleContext;
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
- reportOnce(ruleContext, statement, 'moveGuardUp', { guard: truncateWithEllipsis(sourceCode.getText(statement.test)) }, (fixer) => buildMoveFix(body, index, targetIndex, parent, sourceCode, fixer));
759
+ record(sink, statement, 'moveGuardUp', { guard: truncateWithEllipsis(sourceCode.getText(statement.test)) }, index, targetIndex);
774
760
  });
775
761
  }
776
- function handleDerivedGrouping(ruleContext, body, parent) {
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(ruleContext, statement, index, body, declaredIndices, sourceDeclarators, parent, sourceCode);
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(ruleContext, statement, index, body, declaredIndices, sourceDeclarators, parent, sourceCode) {
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(ruleContext, statement, priorDependencies, declaredNames, body, index, lastDependencyIndex, parent, sourceCode);
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(ruleContext, statement, priorDependencies, declaredNames, body, currentIndex, lastDependencyIndex, parent, sourceCode) {
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
- reportOnce(ruleContext, statement, 'groupDerived', {
833
+ record(sink, statement, 'groupDerived', {
850
834
  dependency,
851
835
  name,
852
- }, (fixer) => buildMoveFix(body, currentIndex, lastDependencyIndex + 1, parent, sourceCode, fixer));
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(ruleContext, body, parent) {
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
- reportOnce(ruleContext, statement, 'moveDeclarationCloser', { name }, (fixer) => buildMoveFix(body, index, usageIndex, parent, sourceCode, fixer));
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(ruleContext, body, parent) {
1331
- const { sourceCode } = ruleContext;
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
- reportOnce(ruleContext, statement, 'moveSideEffect', { effect: effectText }, (fixer) => buildMoveFix(body, index, targetIndex, parent, sourceCode, fixer));
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 statements = node.body;
1365
- handleGuardHoists(ruleContext, statements, node);
1366
- handleDerivedGrouping(ruleContext, statements, node);
1367
- handleLateDeclarations(ruleContext, statements, node);
1368
- handleSideEffects(ruleContext, statements, node);
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.