@blumintinc/eslint-plugin-blumint 1.20.68 → 1.20.70

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.
@@ -785,6 +785,61 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
785
785
  return null;
786
786
  }
787
787
  const awaitArguments = awaitExpressions.map((expr) => sourceCode.getText(expr.argument));
788
+ const startPos = awaitNodes[0].range[0];
789
+ const endPos = awaitNodes[awaitNodes.length - 1].range[1];
790
+ // The replacement text is rebuilt from the awaited expressions alone, so
791
+ // a comment inside the replaced span has no representation in it and
792
+ // would be silently deleted. Deleting an eslint-disable-next-line
793
+ // directive re-enables the suppressed rule on the code that survives
794
+ // inside the Promise.all (#1589). Each span comment is therefore either
795
+ // re-hosted directly above the array element built from the statement it
796
+ // annotates, or the fix is declined so no comment is ever destroyed. The
797
+ // report fires either way.
798
+ const spanComments = sourceCode
799
+ .getAllComments()
800
+ .filter((comment) => comment.range[0] >= startPos && comment.range[1] <= endPos);
801
+ const hostedComments = awaitNodes.map(() => []);
802
+ if (spanComments.length > 0) {
803
+ // Re-hosting maps the comments preceding statement i onto element i,
804
+ // which requires the element list to line up 1:1 with the statement
805
+ // list.
806
+ if (awaitExpressions.length !== awaitNodes.length) {
807
+ return null;
808
+ }
809
+ for (const comment of spanComments) {
810
+ const hostIndex = awaitNodes.findIndex((node, index) => index > 0 &&
811
+ comment.range[0] >= awaitNodes[index - 1].range[1] &&
812
+ comment.range[1] <= node.range[0]);
813
+ if (hostIndex === -1) {
814
+ // The comment sits inside one of the merged statements. A comment
815
+ // within the awaited expression itself travels verbatim with
816
+ // getText; anywhere else (between `await` and its operand, or
817
+ // around a declarator's `=`) has no slot in the rebuilt text.
818
+ const isInsideArgument = awaitExpressions.some((expr) => comment.range[0] >= expr.argument.range[0] &&
819
+ comment.range[1] <= expr.argument.range[1]);
820
+ if (!isInsideArgument) {
821
+ return null;
822
+ }
823
+ continue;
824
+ }
825
+ // A comment that shares the previous statement's last line is a
826
+ // trailing comment (e.g. an eslint-disable-line directive) governing
827
+ // THAT line; moving it above the next element would change which
828
+ // line it applies to.
829
+ if (comment.loc.start.line <= awaitNodes[hostIndex - 1].loc.end.line) {
830
+ return null;
831
+ }
832
+ // A directive above `const x = await f();` may target the
833
+ // declaration's identifier, which the rewrite moves into the
834
+ // destructuring pattern on the Promise.all line — away from every
835
+ // line the re-hosted directive could govern.
836
+ if (/^\s*eslint-/u.test(comment.value) &&
837
+ awaitNodes[hostIndex].type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
838
+ return null;
839
+ }
840
+ hostedComments[hostIndex].push(comment);
841
+ }
842
+ }
788
843
  const idsText = [];
789
844
  const declKinds = new Set();
790
845
  let hasVariableDeclarations = false;
@@ -817,7 +872,17 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
817
872
  // the contents of a template literal, where whitespace is significant
818
873
  // data rather than formatting and re-indenting would silently change the
819
874
  // produced string.
820
- const elementsText = awaitArguments.join(`,\n${elementIndent}`);
875
+ const elementsText = awaitArguments
876
+ .map((argumentText, index) => {
877
+ // Each re-hosted comment lands on its own line directly above the
878
+ // element, which is the only placement where a disable-next-line
879
+ // directive keeps suppressing it.
880
+ const leadingText = (hostedComments[index] ?? [])
881
+ .map((comment) => `${sourceCode.getText(comment)}\n${elementIndent}`)
882
+ .join('');
883
+ return `${leadingText}${argumentText}`;
884
+ })
885
+ .join(`,\n${elementIndent}`);
821
886
  let promiseAllText;
822
887
  if (hasVariableDeclarations) {
823
888
  if (declKinds.size !== 1) {
@@ -834,8 +899,6 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
834
899
  // Simple Promise.all without variable assignments
835
900
  promiseAllText = `await Promise.all([\n${elementIndent}${elementsText}\n${baseIndent}]);`;
836
901
  }
837
- const startPos = awaitNodes[0].range[0];
838
- const endPos = awaitNodes[awaitNodes.length - 1].range[1];
839
902
  return fixer.replaceTextRange([startPos, endPos], promiseAllText);
840
903
  }
841
904
  /**
@@ -62,6 +62,37 @@ function parenthesizeForUnion(text) {
62
62
  const needsParens = text.includes('=>') || /^new\b/.test(text) || /\bextends\b/.test(text);
63
63
  return needsParens ? `(${text})` : text;
64
64
  }
65
+ /**
66
+ * How position-sensitive a comment is when the fix relocates it onto the
67
+ * generated Record:
68
+ *
69
+ * - `next-line`: suppresses exactly the following line
70
+ * (`eslint-disable-next-line`, `@ts-expect-error`, `@ts-ignore`) — hosting
71
+ * it anywhere but directly above the line it suppressed silently changes
72
+ * which rules report.
73
+ * - `own-line`: suppresses its own line (`eslint-disable-line`) — must stay on
74
+ * the same line as the value it annotates.
75
+ * - `range`: opens/closes a suppression region or configures the linter
76
+ * (`eslint-disable`, `eslint-enable`, `eslint-env`, `eslint`, `global`,
77
+ * `exported`) — ESLint honors these only in block-comment form, and
78
+ * relocation can move the region boundary across reported lines, so the fix
79
+ * is never attempted around one.
80
+ * - `none`: prose; content preservation suffices.
81
+ */
82
+ function directiveKindOf(comment) {
83
+ const text = comment.value.trim();
84
+ if (/^(eslint-disable-next-line|@ts-expect-error|@ts-ignore)\b/.test(text)) {
85
+ return 'next-line';
86
+ }
87
+ if (/^eslint-disable-line\b/.test(text)) {
88
+ return 'own-line';
89
+ }
90
+ if (comment.type === utils_1.AST_TOKEN_TYPES.Block &&
91
+ /^(eslint-disable|eslint-enable|eslint-env|eslint\b|globals?\b|exported\b)/.test(text)) {
92
+ return 'range';
93
+ }
94
+ return 'none';
95
+ }
65
96
  exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
66
97
  name: 'prefer-map-over-conditional-dispatch',
67
98
  meta: {
@@ -360,7 +391,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
360
391
  }
361
392
  const stmt = stmts[0];
362
393
  if (stmt.type === utils_1.AST_NODE_TYPES.ReturnStatement && stmt.argument) {
363
- return { kind: 'return', expr: stmt.argument };
394
+ return { kind: 'return', expr: stmt.argument, stmt };
364
395
  }
365
396
  if (stmt.type === utils_1.AST_NODE_TYPES.ExpressionStatement &&
366
397
  stmt.expression.type === utils_1.AST_NODE_TYPES.AssignmentExpression &&
@@ -369,6 +400,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
369
400
  kind: 'assign',
370
401
  target: stmt.expression.left,
371
402
  expr: stmt.expression.right,
403
+ stmt,
372
404
  };
373
405
  }
374
406
  return null;
@@ -504,13 +536,92 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
504
536
  return match ? match[0] : '';
505
537
  }
506
538
  function buildRecordText(name, dText, vText, entries, baseIndent) {
507
- const lines = entries.map((e) => `${baseIndent} ${formatKey(e.key)}: ${e.valueText},`);
539
+ const lines = entries.flatMap((e) => {
540
+ const hosted = (e.leadingComments ?? []).map((comment) => `${baseIndent} ${comment}`);
541
+ const trailing = e.trailingComments && e.trailingComments.length > 0
542
+ ? ` ${e.trailingComments.join(' ')}`
543
+ : '';
544
+ hosted.push(`${baseIndent} ${formatKey(e.key)}: ${e.valueText},${trailing}`);
545
+ return hosted;
546
+ });
508
547
  return [
509
548
  `const ${name}: Record<${dText}, ${vText}> = {`,
510
549
  ...lines,
511
550
  `${baseIndent}};`,
512
551
  ].join('\n');
513
552
  }
553
+ /**
554
+ * Decides where every comment inside the replaced construct lands on the
555
+ * generated Record, mutating `entries` with the hosted text. Comments
556
+ * inside a carried span (a branch value expression, the discriminant, the
557
+ * assignment target) survive verbatim inside the copied text; comments
558
+ * inside a dropped span (an unreachable default/tail) die together with
559
+ * the code they annotate, which is not an orphaning. Every other comment
560
+ * must be hosted onto a map entry — leading comments go on the line(s)
561
+ * directly above the entry, same-line trailing comments append after it.
562
+ * Returns false when a comment cannot be hosted without changing what it
563
+ * annotates or suppresses; the caller then withholds the autofix so a
564
+ * directive is never silently deleted or retargeted.
565
+ */
566
+ function planCommentHosting(args) {
567
+ const { container, carriedSpans, droppedSpans, anchors, entries } = args;
568
+ const ordered = [...anchors].sort((a, b) => a.range[0] - b.range[0]);
569
+ const hostLeading = (entry, text) => {
570
+ entry.leadingComments = [...(entry.leadingComments ?? []), text];
571
+ };
572
+ const hostTrailing = (entry, text) => {
573
+ entry.trailingComments = [...(entry.trailingComments ?? []), text];
574
+ };
575
+ for (const comment of sourceCode.getCommentsInside(container)) {
576
+ const [cStart, cEnd] = comment.range;
577
+ const within = (spans) => spans.some(([start, end]) => cStart >= start && cEnd <= end);
578
+ if (within(carriedSpans) || within(droppedSpans)) {
579
+ continue;
580
+ }
581
+ const kind = directiveKindOf(comment);
582
+ if (kind === 'range') {
583
+ return false;
584
+ }
585
+ const text = sourceCode.getText(comment);
586
+ // Same-line trailing (`doThing(); // note`): append to the entry line
587
+ // so an `eslint-disable-line` keeps suppressing the line its value
588
+ // lands on. The last matching anchor wins so a comment trailing two
589
+ // same-line statements attaches to the nearer one.
590
+ const trailingCandidates = ordered.filter((anchor) => anchor.endLine === comment.loc.start.line &&
591
+ cStart >= anchor.range[1]);
592
+ const trailingAnchor = trailingCandidates[trailingCandidates.length - 1];
593
+ if (trailingAnchor) {
594
+ if (kind === 'next-line') {
595
+ // A trailing disable-next-line targets whatever follows the
596
+ // statement; the generated map has no equivalent following line.
597
+ return false;
598
+ }
599
+ if (kind === 'own-line' && trailingAnchor.entryCount > 1) {
600
+ return false;
601
+ }
602
+ hostTrailing(entries[trailingAnchor.entryIndex], text);
603
+ continue;
604
+ }
605
+ // A comment inside the value statement but outside the copied
606
+ // expression (`return /* why */ value;`) belongs to that branch.
607
+ const containingAnchor = ordered.find((anchor) => cStart >= anchor.range[0] && cEnd <= anchor.range[1]);
608
+ const leadingAnchor = containingAnchor ?? ordered.find((anchor) => anchor.range[0] >= cEnd);
609
+ if (!leadingAnchor) {
610
+ return false;
611
+ }
612
+ if (kind === 'next-line') {
613
+ // Host a line-targeted directive only when it provably targeted the
614
+ // branch's value line, so relocation preserves — never widens or
615
+ // drops — the suppression.
616
+ if (leadingAnchor.entryCount > 1 ||
617
+ leadingAnchor.startLine !== comment.loc.end.line + 1) {
618
+ return false;
619
+ }
620
+ }
621
+ hostLeading(entries[leadingAnchor.entryIndex], text);
622
+ }
623
+ return true;
624
+ }
514
625
  /**
515
626
  * Given ordered explicit branches + optional tail, resolve coverage against
516
627
  * the union's literal keys and (when full) the ordered Record entries.
@@ -564,20 +675,28 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
564
675
  if (!flags.canPlaceFix) {
565
676
  return 'the dispatch sits inside an expression-bodied function; extract the Record manually so it stays in scope';
566
677
  }
678
+ if (!flags.commentSafe) {
679
+ return 'a comment inside the dispatch cannot be carried onto the generated Record without changing what it annotates or suppresses — relocate the comment, then convert';
680
+ }
567
681
  return 'a collision-free lookup name could not be derived from the discriminant';
568
682
  }
569
683
  function report(node, analysis) {
570
- const { entries, contributingValues, dText, form, assignTargetText, fullCoverage, hasNullish, canPlaceFix, } = analysis;
684
+ const { entries, contributingValues, dText, form, assignTargetText, fullCoverage, hasNullish, canPlaceFix, commentBlocked, } = analysis;
571
685
  const eagerSafe = contributingValues.every((expr) => !containsEagerUnsafe(expr));
572
686
  let name = null;
573
687
  // Name derivation is only needed for the autofix path.
574
- if (fullCoverage && !hasNullish && eagerSafe && canPlaceFix) {
688
+ if (fullCoverage &&
689
+ !hasNullish &&
690
+ eagerSafe &&
691
+ canPlaceFix &&
692
+ !commentBlocked) {
575
693
  name = deriveLookupName(discriminantOf(node));
576
694
  }
577
695
  const autofixable = fullCoverage &&
578
696
  !hasNullish &&
579
697
  eagerSafe &&
580
698
  canPlaceFix &&
699
+ !commentBlocked &&
581
700
  name !== null;
582
701
  if (!autofixable) {
583
702
  context.report({
@@ -589,6 +708,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
589
708
  hasNullish,
590
709
  eagerSafe,
591
710
  canPlaceFix,
711
+ commentSafe: !commentBlocked,
592
712
  }),
593
713
  },
594
714
  });
@@ -700,7 +820,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
700
820
  continue;
701
821
  }
702
822
  const value = extractBranchValue(c.consequent);
703
- parsed.push({ tests: [...pending, c.test], value });
823
+ parsed.push({ tests: [...pending, c.test], value, caseNode: c });
704
824
  pending = [];
705
825
  }
706
826
  if (pending.length > 0) {
@@ -714,6 +834,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
714
834
  const explicit = [];
715
835
  let defaultValue;
716
836
  let hasDefault = false;
837
+ let defaultCaseNode = null;
717
838
  for (const branch of parsed) {
718
839
  const literalTests = branch.tests.filter((t) => t !== null);
719
840
  const isDefaultGroup = literalTests.length !== branch.tests.length;
@@ -724,6 +845,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
724
845
  }
725
846
  hasDefault = true;
726
847
  defaultValue = branch.value;
848
+ defaultCaseNode = branch.caseNode;
727
849
  continue;
728
850
  }
729
851
  if (!branch.value) {
@@ -798,6 +920,57 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
798
920
  if (isNarrowingExempt(node.discriminant, contributingValues)) {
799
921
  return;
800
922
  }
923
+ // Comment hosting only gates the autofix path (partial coverage is
924
+ // already report-only), so it is planned for full coverage alone.
925
+ let commentBlocked = false;
926
+ if (coverage.fullCoverage) {
927
+ const tailUsed = coverage.remainingCount === 1 && defaultVal !== null;
928
+ const carriedSpans = [node.discriminant.range];
929
+ const droppedSpans = [];
930
+ const anchors = [];
931
+ let entryIndex = 0;
932
+ for (const branch of explicit) {
933
+ carriedSpans.push(branch.value.expr.range);
934
+ anchors.push({
935
+ range: branch.value.stmt.range,
936
+ startLine: branch.value.stmt.loc.start.line,
937
+ endLine: branch.value.stmt.loc.end.line,
938
+ entryIndex,
939
+ entryCount: branch.keys.length,
940
+ });
941
+ entryIndex += branch.keys.length;
942
+ }
943
+ if (kind === 'assign' && explicit[0].value.kind === 'assign') {
944
+ carriedSpans.push(explicit[0].value.target.range);
945
+ }
946
+ if (tailUsed && defaultVal) {
947
+ carriedSpans.push(defaultVal.expr.range);
948
+ anchors.push({
949
+ range: defaultVal.stmt.range,
950
+ startLine: defaultVal.stmt.loc.start.line,
951
+ endLine: defaultVal.stmt.loc.end.line,
952
+ entryIndex: coverage.entries.length - 1,
953
+ entryCount: 1,
954
+ });
955
+ }
956
+ else if (defaultCaseNode) {
957
+ // The unreachable default is deleted wholesale; its comments —
958
+ // including any directly above the `default:` label — annotate
959
+ // deleted code and die with it.
960
+ const caseIndex = node.cases.indexOf(defaultCaseNode);
961
+ const droppedStart = caseIndex > 0
962
+ ? node.cases[caseIndex - 1].range[1]
963
+ : node.discriminant.range[1];
964
+ droppedSpans.push([droppedStart, defaultCaseNode.range[1]]);
965
+ }
966
+ commentBlocked = !planCommentHosting({
967
+ container: node,
968
+ carriedSpans,
969
+ droppedSpans,
970
+ anchors,
971
+ entries: coverage.entries,
972
+ });
973
+ }
801
974
  report(node, {
802
975
  entries: coverage.entries,
803
976
  contributingValues,
@@ -807,6 +980,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
807
980
  fullCoverage: coverage.fullCoverage,
808
981
  hasNullish,
809
982
  canPlaceFix: true,
983
+ commentBlocked,
810
984
  });
811
985
  }
812
986
  // ---- Ternary form -------------------------------------------------------
@@ -906,6 +1080,47 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
906
1080
  crossesFunction = true;
907
1081
  }
908
1082
  }
1083
+ let commentBlocked = false;
1084
+ if (coverage.fullCoverage) {
1085
+ const tailUsed = coverage.remainingCount === 1;
1086
+ const carriedSpans = [
1087
+ head.discNode.range,
1088
+ ...links.map((l) => l.expr.range),
1089
+ ];
1090
+ const droppedSpans = [];
1091
+ const anchors = links.map((l, i) => ({
1092
+ range: l.expr.range,
1093
+ startLine: l.expr.loc.start.line,
1094
+ endLine: l.expr.loc.end.line,
1095
+ entryIndex: i,
1096
+ entryCount: 1,
1097
+ }));
1098
+ if (tailUsed) {
1099
+ carriedSpans.push(tailExpr.range);
1100
+ anchors.push({
1101
+ range: tailExpr.range,
1102
+ startLine: tailExpr.loc.start.line,
1103
+ endLine: tailExpr.loc.end.line,
1104
+ entryIndex: coverage.entries.length - 1,
1105
+ entryCount: 1,
1106
+ });
1107
+ }
1108
+ else {
1109
+ // The unreachable tail is deleted; comments between the last kept
1110
+ // consequent and the tail's end annotate deleted code.
1111
+ droppedSpans.push([
1112
+ links[links.length - 1].expr.range[1],
1113
+ tailExpr.range[1],
1114
+ ]);
1115
+ }
1116
+ commentBlocked = !planCommentHosting({
1117
+ container: node,
1118
+ carriedSpans,
1119
+ droppedSpans,
1120
+ anchors,
1121
+ entries: coverage.entries,
1122
+ });
1123
+ }
909
1124
  discriminantMap.set(node, head.discNode);
910
1125
  report(node, {
911
1126
  entries: coverage.entries,
@@ -915,6 +1130,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
915
1130
  fullCoverage: coverage.fullCoverage,
916
1131
  hasNullish,
917
1132
  canPlaceFix: !crossesFunction,
1133
+ commentBlocked,
918
1134
  });
919
1135
  }
920
1136
  // ---- if / else-if form --------------------------------------------------
@@ -935,6 +1151,10 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
935
1151
  }
936
1152
  const links = [];
937
1153
  let tail = null;
1154
+ // The final else statement/block and the end of the consequent before
1155
+ // it — the source region a dropped tail's comments die with.
1156
+ let tailNode = null;
1157
+ let tailPrevEnd = 0;
938
1158
  let cur = node;
939
1159
  while (cur) {
940
1160
  const link = equalityDiscriminant(cur.test);
@@ -960,6 +1180,8 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
960
1180
  return;
961
1181
  }
962
1182
  tail = tailValue;
1183
+ tailNode = alt;
1184
+ tailPrevEnd = cur.consequent.range[1];
963
1185
  cur = null;
964
1186
  }
965
1187
  if (links.length === 0) {
@@ -1012,6 +1234,48 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1012
1234
  if (isNarrowingExempt(head.discNode, contributingValues)) {
1013
1235
  return;
1014
1236
  }
1237
+ let commentBlocked = false;
1238
+ if (coverage.fullCoverage) {
1239
+ const tailUsed = coverage.remainingCount === 1 && tail !== null;
1240
+ const carriedSpans = [head.discNode.range];
1241
+ const droppedSpans = [];
1242
+ const anchors = [];
1243
+ links.forEach((link, i) => {
1244
+ carriedSpans.push(link.value.expr.range);
1245
+ anchors.push({
1246
+ range: link.value.stmt.range,
1247
+ startLine: link.value.stmt.loc.start.line,
1248
+ endLine: link.value.stmt.loc.end.line,
1249
+ entryIndex: i,
1250
+ entryCount: 1,
1251
+ });
1252
+ });
1253
+ if (kind === 'assign' && links[0].value.kind === 'assign') {
1254
+ carriedSpans.push(links[0].value.target.range);
1255
+ }
1256
+ if (tailUsed && tail) {
1257
+ carriedSpans.push(tail.expr.range);
1258
+ anchors.push({
1259
+ range: tail.stmt.range,
1260
+ startLine: tail.stmt.loc.start.line,
1261
+ endLine: tail.stmt.loc.end.line,
1262
+ entryIndex: coverage.entries.length - 1,
1263
+ entryCount: 1,
1264
+ });
1265
+ }
1266
+ else if (tailNode) {
1267
+ // The unreachable else is deleted; comments from the last kept
1268
+ // consequent through the else's end annotate deleted code.
1269
+ droppedSpans.push([tailPrevEnd, tailNode.range[1]]);
1270
+ }
1271
+ commentBlocked = !planCommentHosting({
1272
+ container: node,
1273
+ carriedSpans,
1274
+ droppedSpans,
1275
+ anchors,
1276
+ entries: coverage.entries,
1277
+ });
1278
+ }
1015
1279
  discriminantMap.set(node, head.discNode);
1016
1280
  report(node, {
1017
1281
  entries: coverage.entries,
@@ -1022,6 +1286,7 @@ exports.preferMapOverConditionalDispatch = (0, createRule_1.createRule)({
1022
1286
  fullCoverage: coverage.fullCoverage,
1023
1287
  hasNullish,
1024
1288
  canPlaceFix: true,
1289
+ commentBlocked,
1025
1290
  });
1026
1291
  }
1027
1292
  return {
@@ -47,6 +47,38 @@ function isInsideModuleAugmentation(node) {
47
47
  }
48
48
  return false;
49
49
  }
50
+ /**
51
+ * Declaration merging is what `interface` can do and `type` cannot, so a name
52
+ * carrying more than one declaration is not a stylistic choice: rewriting one
53
+ * of the halves emits two declarations of the same name (TS2300) and silently
54
+ * splits the merged shape, so members that used to coexist no longer do.
55
+ *
56
+ * The check keys on the **declaring scope**, not on a count of the name across
57
+ * the file, because merging is a property of the declaration space. Two
58
+ * same-named interfaces in different function bodies or blocks are distinct
59
+ * types that never merge, and each of those still converts cleanly.
60
+ *
61
+ * Any second definition counts, not only another interface. `class A {}` and
62
+ * `enum A {}` occupy the same type-space slot a type alias would claim, so
63
+ * those conversions break the build too; `function A() {}` and
64
+ * `namespace A {}` are value-space only and would survive the rewrite, but
65
+ * they are rare enough that treating every co-declaration as merged costs
66
+ * almost nothing and keeps the rule from having to model TypeScript's merging
67
+ * table.
68
+ */
69
+ function isMergedDeclaration(context, node) {
70
+ // The variable is located by the definition that points back at *this*
71
+ // declaration rather than by taking the first entry, so a parser that also
72
+ // surfaces the type parameters here cannot make the count read off the
73
+ // wrong name.
74
+ const declared = context
75
+ .getDeclaredVariables(node)
76
+ .find((variable) => variable.defs.some((definition) => definition.node === node));
77
+ // A scope manager that never registered the name tells us nothing about
78
+ // merging; falling through preserves the report rather than exempting on a
79
+ // missing signal.
80
+ return declared !== undefined && declared.defs.length > 1;
81
+ }
50
82
  exports.preferTypeOverInterface = (0, createRule_1.createRule)({
51
83
  name: 'prefer-type-over-interface',
52
84
  meta: {
@@ -70,6 +102,12 @@ exports.preferTypeOverInterface = (0, createRule_1.createRule)({
70
102
  if (isInsideModuleAugmentation(node)) {
71
103
  return;
72
104
  }
105
+ // Reporting without a fix would be unactionable here — the author
106
+ // cannot honour it without collapsing the merge by hand — and an
107
+ // unactionable report is what manufactures `eslint-disable` (#1568).
108
+ if (isMergedDeclaration(context, node)) {
109
+ return;
110
+ }
73
111
  context.report({
74
112
  node,
75
113
  messageId: 'preferType',
@@ -1,3 +1,8 @@
1
1
  import type { TSESLint } from '@typescript-eslint/utils';
2
- export declare const useLatestCallback: TSESLint.RuleModule<"useLatestCallback", [], TSESLint.RuleListener>;
2
+ type Options = [
3
+ {
4
+ printWidth?: number;
5
+ }
6
+ ];
7
+ export declare const useLatestCallback: TSESLint.RuleModule<"useLatestCallback", Options, TSESLint.RuleListener>;
3
8
  export default useLatestCallback;