@blumintinc/eslint-plugin-blumint 1.20.140 → 1.20.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -223,7 +223,7 @@ function noFrontendImportsFromFunctionsPatterns(pattern) {
223
223
  module.exports = {
224
224
  meta: {
225
225
  name: '@blumintinc/eslint-plugin-blumint',
226
- version: '1.20.140',
226
+ version: '1.20.142',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -218,19 +218,14 @@ function editOffset(anchor) {
218
218
  *
219
219
  * Widening to the anchor's line start is what lets the emitted
220
220
  * `${indent}import …\n` leave the displaced statement on the indentation it
221
- * already had, but that is sound only while whitespace is all that precedes
222
- * the anchor: a `'use client';` sharing the anchor's line would be demoted by
223
- * an insertion at column 0. When the resulting position still falls inside a
224
- * retirement the anchor statement is itself the declaration being retired,
225
- * and the retirement claims the indentation ahead of it — the insertion moves
226
- * to that edit's start, since ESLint rejects a fix nested inside another.
221
+ * already had; `importAnchorLineStartIfOwned` restricts that to the anchors it
222
+ * is sound for. When the resulting position still falls inside a retirement
223
+ * the anchor statement is itself the declaration being retired, and the
224
+ * retirement claims the indentation ahead of it the insertion moves to that
225
+ * edit's start, since ESLint rejects a fix nested inside another.
227
226
  */
228
227
  function importPlacement(sourceCode, anchor, edits) {
229
- const anchorStart = editOffset(anchor);
230
- const lineStart = sourceCode.text.lastIndexOf('\n', anchorStart - 1) + 1;
231
- const placement = /^[ \t]*$/.test(sourceCode.text.slice(lineStart, anchorStart))
232
- ? (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor)
233
- : anchor;
228
+ const placement = (0, importInsertion_1.importAnchorLineStartIfOwned)(sourceCode, anchor);
234
229
  const offset = editOffset(placement);
235
230
  const enclosing = edits.find((edit) => edit.start < offset && offset < edit.end);
236
231
  return enclosing ? { kind: 'index', index: enclosing.start } : placement;
@@ -894,7 +894,7 @@ function generateHoistingFixes(groups, callback, depsArray, depTexts, insertionS
894
894
  for (const group of groups.values()) {
895
895
  const sortedProps = Array.from(group.properties.values()).sort((a, b) => a.order - b.order);
896
896
  const pattern = `{ ${sortedProps.map((p) => p.text).join(', ')} }`;
897
- hoistedLines.push(`${indent}const ${pattern} = ${nullishSourceText(group.objectText, group.inits[0])} ?? {};`);
897
+ hoistedLines.push(`const ${pattern} = ${nullishSourceText(group.objectText, group.inits[0])} ?? {};`);
898
898
  }
899
899
  reservedNamesByScope.set(scope, updatedReservedNames);
900
900
  const newDepSet = new Set(newDepTexts);
@@ -904,9 +904,18 @@ function generateHoistingFixes(groups, callback, depsArray, depTexts, insertionS
904
904
  newDepSet.add(name);
905
905
  }
906
906
  }
907
- const insertAt = sourceCode.getText().lastIndexOf('\n', insertionStatement.range[0]) + 1;
907
+ // Anchoring to the start of the statement's LINE is the same offset as the
908
+ // statement itself only while the statement opens that line. When it does not
909
+ // — a body collapsed onto one line, or a sibling declared ahead of it — that
910
+ // offset sits outside the enclosing function, so a declaration reading the
911
+ // function's own parameters would be hoisted out of the scope that binds them.
912
+ const text = sourceCode.getText();
913
+ const lineStart = text.lastIndexOf('\n', insertionStatement.range[0]) + 1;
914
+ const ownsItsLine = /^[\t ]*$/.test(text.slice(lineStart, insertionStatement.range[0]));
908
915
  const fixes = [
909
- fixer.insertTextBeforeRange([insertAt, insertAt], `${hoistedLines.join('\n')}\n`),
916
+ ownsItsLine
917
+ ? fixer.insertTextBeforeRange([lineStart, lineStart], `${hoistedLines.map((line) => `${indent}${line}`).join('\n')}\n`)
918
+ : fixer.insertTextBefore(insertionStatement, `${hoistedLines.join(' ')} `),
910
919
  fixer.replaceText(depsArray, `[${newDepTexts.join(', ')}]`),
911
920
  ];
912
921
  for (const decl of declarationsToRemove) {
@@ -459,7 +459,7 @@ exports.enforceMemoizeAsync = (0, createRule_1.createRule)({
459
459
  // a header comment — while still placing it above the first
460
460
  // existing import.
461
461
  const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
462
- fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor), `${(0, importInsertion_1.importAnchorIndent)(sourceCode, anchor)}${importStatement}\n`));
462
+ fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importAnchorLineStartIfOwned)(sourceCode, anchor), `${(0, importInsertion_1.importAnchorIndent)(sourceCode, anchor)}${importStatement}\n`));
463
463
  scheduledImportFix = true;
464
464
  }
465
465
  // Anchor the decorator on the method — its first token, so ahead of
@@ -507,7 +507,7 @@ exports.enforceMemoizeGetters = (0, createRule_1.createRule)({
507
507
  // with the statement it displaces; widening to the line start
508
508
  // then leaves that statement's indentation intact.
509
509
  const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
510
- fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor), `${indent}import { Memoize } from '${MEMOIZE_PREFERRED_MODULE}';\n`));
510
+ fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importAnchorLineStartIfOwned)(sourceCode, anchor), `${indent}import { Memoize } from '${MEMOIZE_PREFERRED_MODULE}';\n`));
511
511
  scheduledImportFix = true;
512
512
  }
513
513
  // Anchor the decorator on the member — its first token, so ahead of
@@ -846,6 +846,115 @@ function carriedText(source, range) {
846
846
  : ' ';
847
847
  return `${lead}${body}${trail}`;
848
848
  }
849
+ /** Every character the syntactic grammar counts as a LineTerminator. */
850
+ const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
851
+ const textOf = (source, range) => source.text.slice(range[0], range[1]);
852
+ /**
853
+ * The span an arrow's return annotation occupies between the parameter list and
854
+ * the `=>`, together with that arrow token.
855
+ *
856
+ * The span holds the annotation, whitespace and comments and nothing else,
857
+ * which is what makes it safe to rewrite wholesale: no binding reference can
858
+ * hide in it beyond the ones the annotation itself names.
859
+ */
860
+ function arrowAnnotationGap(source, returnType) {
861
+ const parametersEnd = source.getTokenBefore(returnType);
862
+ const arrow = source.getTokenAfter(returnType, {
863
+ filter: (token) => token.value === '=>',
864
+ });
865
+ if (!parametersEnd || !arrow)
866
+ return null;
867
+ const gap = [parametersEnd.range[1], arrow.range[0]];
868
+ return containsRange(gap, returnType.range) ? { gap, arrow } : null;
869
+ }
870
+ /**
871
+ * Re-emits `comments` on the far side of the arrow, where a line terminator is
872
+ * inert, consuming the horizontal whitespace the arrow already had after it so
873
+ * the body keeps a single separator.
874
+ */
875
+ function hoistPastArrow(source, arrow, comments) {
876
+ const indent = indentAt(source, arrow.range[0]);
877
+ const trailingText = source.text.slice(arrow.range[1]);
878
+ const [spacing] = /^[ \t]*/.exec(trailingText) ?? [''];
879
+ const body = (0, replacementSegments_1.joinSegmentBody)(comments.map((comment) => ({
880
+ text: textOf(source, comment.range),
881
+ breakAfter: true,
882
+ })), indent);
883
+ const rest = trailingText.slice(spacing.length);
884
+ const separator = LINE_TERMINATOR.test(rest.charAt(0))
885
+ ? ''
886
+ : (0, replacementSegments_1.requiresLineBreakAfter)(comments[comments.length - 1])
887
+ ? `\n${indent}`
888
+ : ' ';
889
+ return {
890
+ range: [arrow.range[1], arrow.range[1] + spacing.length],
891
+ text: ` ${body}${separator}`,
892
+ };
893
+ }
894
+ /**
895
+ * The edits that strip one annotation, carrying every comment the strip
896
+ * strands rather than deleting it (#1877). `null` withholds the fix, for a
897
+ * comment whose meaning is its position and which cannot stay where it is.
898
+ *
899
+ * An arrow is the one subject whose annotation sits inside a restricted
900
+ * production: `ArrowParameters [no LineTerminator here] =>` forbids a line
901
+ * terminator between the parameter list and the arrow, and a block comment
902
+ * carrying a line terminator IS one to the grammar. A comment left there — or
903
+ * carried there from inside the annotation — therefore turns the output into a
904
+ * hard SyntaxError that only V8 reports, since `@typescript-eslint/parser`
905
+ * accepts it (#1964). Such a comment is re-emitted past the `=>` instead, the
906
+ * nearest position outside the restricted gap that cannot itself begin one;
907
+ * hoisting it above the enclosing line would anchor an insertion at a column
908
+ * zero that may sit inside a template literal or JSX text, where the comment
909
+ * would become content rather than code.
910
+ *
911
+ * Every other subject ends its parameter list at a body or a semicolon, so its
912
+ * stranded comments stay where they were written.
913
+ */
914
+ function planAnnotationEdits(source, entry) {
915
+ const range = entry.returnType.range;
916
+ if (entry.node.type !== utils_1.AST_NODE_TYPES.ArrowFunctionExpression) {
917
+ const carried = carriedText(source, range);
918
+ return carried === null ? null : [{ range, text: carried }];
919
+ }
920
+ const gapInfo = arrowAnnotationGap(source, entry.returnType);
921
+ if (!gapInfo)
922
+ return null;
923
+ const { gap, arrow } = gapInfo;
924
+ const comments = source
925
+ .getAllComments()
926
+ .filter((comment) => containsRange(gap, comment.range));
927
+ const stranded = comments.filter((comment) => containsRange(range, comment.range));
928
+ // What the plain deletion would leave between the parameters and the arrow.
929
+ // A comment left there contributes its own text, so a line comment or a
930
+ // multi-line block comment shows up here as the line terminator it is.
931
+ const residue = `${textOf(source, [gap[0], range[0]])}${textOf(source, [
932
+ range[1],
933
+ gap[1],
934
+ ])}`;
935
+ // The plain deletion is kept wherever it already lands a legal gap and
936
+ // strands nothing, so no output that survives today moves by a byte.
937
+ if (stranded.length === 0 && !LINE_TERMINATOR.test(residue)) {
938
+ return [{ range, text: '' }];
939
+ }
940
+ // Rewriting the gap collapses the lines it spanned, which moves the line a
941
+ // directive inside it points at, so the whole fix is withheld rather than
942
+ // retargeting one. The gap a directive can share with nothing else is left
943
+ // untouched by the branch above.
944
+ if (comments.some(isPositionalDirective))
945
+ return null;
946
+ const hoisted = comments.filter(replacementSegments_1.requiresOwnLine);
947
+ const inline = comments
948
+ .filter((comment) => !(0, replacementSegments_1.requiresOwnLine)(comment))
949
+ .map((comment) => textOf(source, comment.range));
950
+ const edits = [
951
+ { range: gap, text: inline.length === 0 ? ' ' : ` ${inline.join(' ')} ` },
952
+ ];
953
+ if (hoisted.length > 0) {
954
+ edits.push(hoistPastArrow(source, arrow, hoisted));
955
+ }
956
+ return edits;
957
+ }
849
958
  /**
850
959
  * ESLint applies a fix whole or not at all, and rejects one whose edits
851
960
  * overlap. Two spans planned independently — an annotation and the declaration
@@ -866,14 +975,23 @@ function isDisjoint(edits) {
866
975
  * outright. The planner computes spans that reach across separators, so a
867
976
  * comment among the specifiers sits inside one; declining on that comment is no
868
977
  * remedy, since it lets a comment decide whether the annotations are stripped at
869
- * all, which is a comment changing the transform just the same (#1877).
978
+ * all, which is a comment changing the transform just the same (#1877). The
979
+ * annotation spans are carried the same way by {@link planAnnotationEdits},
980
+ * which additionally answers for the arrow whose annotation sits inside a
981
+ * restricted production.
870
982
  */
871
983
  function planRemoval(source, removalSource, batch) {
872
984
  const annotations = batch.map((entry) => entry.returnType.range);
873
985
  const cleanups = (0, importRemoval_1.planOrphanedBindingRemoval)(removalSource, annotations, (variables, planned) => (0, typeDeclarationRemoval_1.planTypeDeclarationRemoval)(removalSource, variables, planned));
874
986
  if (!cleanups)
875
987
  return null;
876
- const edits = annotations.map((range) => ({ range, text: '' }));
988
+ const edits = [];
989
+ for (const entry of batch) {
990
+ const planned = planAnnotationEdits(source, entry);
991
+ if (planned === null)
992
+ return null;
993
+ edits.push(...planned);
994
+ }
877
995
  for (const range of cleanups) {
878
996
  if (removesWholeStatement(source, range)) {
879
997
  edits.push({ range, text: '' });
@@ -551,11 +551,13 @@ exports.noUsememoForPassByValue = (0, createRule_1.createRule)({
551
551
  // line is hoisted onto a full line of its own ABOVE the line the
552
552
  // call starts on. That insertion can never split a token pair, and
553
553
  // it lands a `-next-line` directive exactly one line above the
554
- // statement that now hosts its subject. Everything else stays
555
- // inline: a block comment beside the expression, and a trailing
556
- // line-bound comment followed by a line break, which is safe after
557
- // the expression has begun.
558
- const hoistedComments = leadingComments.filter(replacementSegments_1.requiresLineBreakAfter);
554
+ // statement that now hosts its subject. A block comment carrying a
555
+ // line terminator demands a line the same way, because the grammar
556
+ // reads it AS a line terminator (#1963). Everything else stays
557
+ // inline: a single-line block comment beside the expression, and a
558
+ // trailing line-bound comment followed by a line break, which is
559
+ // safe after the expression has begun.
560
+ const hoistedComments = leadingComments.filter(replacementSegments_1.requiresOwnLine);
559
561
  if (hoistedComments.length > 0) {
560
562
  const lineStartIndex = sourceCode.getIndexFromLoc({
561
563
  line: node.loc.start.line,
@@ -571,7 +573,7 @@ exports.noUsememoForPassByValue = (0, createRule_1.createRule)({
571
573
  }
572
574
  const segments = [
573
575
  ...leadingComments
574
- .filter((comment) => !(0, replacementSegments_1.requiresLineBreakAfter)(comment))
576
+ .filter((comment) => !(0, replacementSegments_1.requiresOwnLine)(comment))
575
577
  .map(toSegment),
576
578
  { text: replacementText, breakAfter: false },
577
579
  ...trailingComments.map(toSegment),
@@ -258,10 +258,13 @@ function ensureDeepCompareImportFixes(context, fixer) {
258
258
  const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
259
259
  const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
260
260
  const importText = `${indent}import { ${DEEP_COMPARE_HOOK} } from '${DEEP_COMPARE_MODULE}';\n`;
261
- // Widened to the anchor's line start because the emitted statement carries
262
- // its own indentation, leaving the displaced anchor sitting on the original.
261
+ // Widened to the anchor's line start where the anchor opens it, because the
262
+ // emitted statement carries its own indentation and so leaves the displaced
263
+ // anchor sitting on the original. Where the anchor shares its line the
264
+ // widening is declined: the offset would otherwise fall ahead of the prologue
265
+ // this anchor was chosen to sit below.
263
266
  return [
264
- (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor), importText),
267
+ (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, (0, importInsertion_1.importAnchorLineStartIfOwned)(sourceCode, anchor), importText),
265
268
  ];
266
269
  }
267
270
  function isImportedIdentifier(context, name) {
@@ -43,7 +43,30 @@ const TESTS_DIR = path.join(__dirname, '..', 'tests');
43
43
  * (`const jsx = ruleTesterJsx`) before calling `run`, so a call-site pattern
44
44
  * drops it.
45
45
  */
46
- exports.IMPORTS_SHARED_TESTER = /from\s+'\.\.\/utils\/ruleTester'/;
46
+ exports.IMPORTS_SHARED_TESTER = /from\s+'(?:\.\.\/)+utils\/ruleTester'/;
47
+ /**
48
+ * Every suite file under the tests root, as a path relative to it.
49
+ *
50
+ * The enumeration is recursive because a suite in a subdirectory is still a
51
+ * suite: `src/tests/rules/` holds three that jest runs and that every
52
+ * harvest-based gate used to miss, while their rules' top-level namesakes kept
53
+ * the per-rule closure green — so the gap read as coverage from every angle
54
+ * that was checked.
55
+ *
56
+ * Paths stay relative rather than collapsing to a basename so that two suites
57
+ * with the same name (`no-circular-references.test.ts` exists at both depths)
58
+ * remain distinguishable, which is what keeps a per-file baseline or a dedupe
59
+ * key from silently merging them.
60
+ */
61
+ function suiteFilesUnder(root) {
62
+ const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
63
+ const full = path.join(dir, entry.name);
64
+ if (entry.isDirectory())
65
+ return walk(full);
66
+ return entry.name.endsWith('.test.ts') ? [path.relative(root, full)] : [];
67
+ });
68
+ return walk(root).sort();
69
+ }
47
70
  /**
48
71
  * Jest registers a test for every `describe`/`it` a loaded module calls, so
49
72
  * loading 271 suites inside a suite would graft their entire test list onto
@@ -156,10 +179,7 @@ function harvestRuleTesterCases() {
156
179
  const realCwd = process.cwd();
157
180
  process.chdir(scratchRoot);
158
181
  try {
159
- const files = fs
160
- .readdirSync(TESTS_DIR)
161
- .filter((file) => file.endsWith('.test.ts'))
162
- .sort();
182
+ const files = suiteFilesUnder(TESTS_DIR);
163
183
  for (const file of files) {
164
184
  const fullPath = path.join(TESTS_DIR, file);
165
185
  if (!exports.IMPORTS_SHARED_TESTER.test(fs.readFileSync(fullPath, 'utf8'))) {
@@ -40,11 +40,25 @@ export declare function importInsertionAnchor(sourceCode: ImportInsertionSource)
40
40
  */
41
41
  export declare function insertAtImportAnchor(sourceCode: ImportInsertionSource, fixer: TSESLint.RuleFixer, anchor: ImportInsertionAnchor, text: string): TSESLint.RuleFix;
42
42
  /**
43
- * Widens `anchor` to the start of its line, for rules that emit
44
- * `${indent}import …\n` so the displaced anchor keeps its own indentation.
43
+ * Widens `anchor` to the start of its line only where whitespace is all that
44
+ * precedes it there, and leaves it alone otherwise.
45
+ *
45
46
  * Raw-offset anchors pass through: they never sit inside an indented line.
47
+ *
48
+ * The widening buys one thing — an emitted `${indent}import …\n` leaves the
49
+ * displaced anchor on the indentation it already had — and that reasoning holds
50
+ * only while the anchor opens its line. When something else opens that line the
51
+ * widened offset sits ahead of it, and the anchor resolves past the prologue
52
+ * precisely because the prologue is what precedes it: a `'use client'` is a
53
+ * directive only while it is the first statement, so an import spliced above it
54
+ * demotes it to an inert expression statement that no bundler reads. Nothing
55
+ * downstream reports that — the output re-lints clean and the demoted directive
56
+ * is still valid TypeScript.
57
+ *
58
+ * Declining to widen costs the displaced anchor its indentation and keeps the
59
+ * file's meaning, which is the safe direction of that trade.
46
60
  */
47
- export declare function importAnchorLineStart(sourceCode: ImportInsertionSource, anchor: ImportInsertionAnchor): ImportInsertionAnchor;
61
+ export declare function importAnchorLineStartIfOwned(sourceCode: ImportInsertionSource, anchor: ImportInsertionAnchor): ImportInsertionAnchor;
48
62
  /**
49
63
  * The whitespace prefix of the anchor's line, for fixers that replicate the
50
64
  * anchor's indentation on the inserted import.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.importAnchorIndent = exports.importAnchorLineStart = exports.insertAtImportAnchor = exports.importInsertionAnchor = void 0;
3
+ exports.importAnchorIndent = exports.importAnchorLineStartIfOwned = exports.insertAtImportAnchor = exports.importInsertionAnchor = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const disableDirectives_1 = require("./disableDirectives");
6
6
  /**
@@ -95,18 +95,35 @@ function insertAtImportAnchor(sourceCode, fixer, anchor, text) {
95
95
  }
96
96
  exports.insertAtImportAnchor = insertAtImportAnchor;
97
97
  /**
98
- * Widens `anchor` to the start of its line, for rules that emit
99
- * `${indent}import …\n` so the displaced anchor keeps its own indentation.
98
+ * Widens `anchor` to the start of its line only where whitespace is all that
99
+ * precedes it there, and leaves it alone otherwise.
100
+ *
100
101
  * Raw-offset anchors pass through: they never sit inside an indented line.
102
+ *
103
+ * The widening buys one thing — an emitted `${indent}import …\n` leaves the
104
+ * displaced anchor on the indentation it already had — and that reasoning holds
105
+ * only while the anchor opens its line. When something else opens that line the
106
+ * widened offset sits ahead of it, and the anchor resolves past the prologue
107
+ * precisely because the prologue is what precedes it: a `'use client'` is a
108
+ * directive only while it is the first statement, so an import spliced above it
109
+ * demotes it to an inert expression statement that no bundler reads. Nothing
110
+ * downstream reports that — the output re-lints clean and the demoted directive
111
+ * is still valid TypeScript.
112
+ *
113
+ * Declining to widen costs the displaced anchor its indentation and keeps the
114
+ * file's meaning, which is the safe direction of that trade.
101
115
  */
102
- function importAnchorLineStart(sourceCode, anchor) {
116
+ function importAnchorLineStartIfOwned(sourceCode, anchor) {
103
117
  if (anchor.kind === 'index') {
104
118
  return anchor;
105
119
  }
106
- const lineStart = sourceCode.text.lastIndexOf('\n', anchor.target.range[0] - 1) + 1;
107
- return { kind: 'index', index: lineStart };
120
+ const start = anchor.target.range[0];
121
+ const lineStart = sourceCode.text.lastIndexOf('\n', start - 1) + 1;
122
+ return /^[\t ]*$/.test(sourceCode.text.slice(lineStart, start))
123
+ ? { kind: 'index', index: lineStart }
124
+ : anchor;
108
125
  }
109
- exports.importAnchorLineStart = importAnchorLineStart;
126
+ exports.importAnchorLineStartIfOwned = importAnchorLineStartIfOwned;
110
127
  /**
111
128
  * The whitespace prefix of the anchor's line, for fixers that replicate the
112
129
  * anchor's indentation on the inserted import.
@@ -16,6 +16,25 @@ import { TSESTree } from '@typescript-eslint/utils';
16
16
  * silently retarget one line past its subject.
17
17
  */
18
18
  export declare function requiresLineBreakAfter(comment: TSESTree.Comment): boolean;
19
+ /**
20
+ * A comment that cannot be folded onto the code that follows it, so a fixer
21
+ * placing it ahead of an expression must give it a line of its own.
22
+ *
23
+ * Two kinds qualify. One is the line-bound comment {@link requiresLineBreakAfter}
24
+ * describes, whose meaning is tied to the line it occupies. The other is a block
25
+ * comment containing a line terminator: the syntactic grammar treats such a
26
+ * comment as a LineTerminator in its own right, so it triggers every restricted
27
+ * production a raw newline would. Measured with `node --check`, a block comment
28
+ * on one line between arrow parameters and their arrow parses, while the same
29
+ * comment broken across two lines is a SyntaxError; ahead of a `return`
30
+ * argument, the multi-line form is worse still — it parses, and ASI silently
31
+ * replaces the returned value with `undefined` (#1963).
32
+ *
33
+ * Only fixers emitting text where a newline is meaningful need this;
34
+ * a replacement wrapped in parentheses can never trip a restricted production
35
+ * and can keep such a comment inline.
36
+ */
37
+ export declare function requiresOwnLine(comment: TSESTree.Comment): boolean;
19
38
  export type ReplacementSegment = {
20
39
  text: string;
21
40
  breakAfter: boolean;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.joinSegments = exports.joinSegmentBody = exports.requiresLineBreakAfter = void 0;
3
+ exports.joinSegments = exports.joinSegmentBody = exports.requiresOwnLine = exports.requiresLineBreakAfter = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const disableDirectives_1 = require("./disableDirectives");
6
6
  /**
@@ -26,6 +26,35 @@ function requiresLineBreakAfter(comment) {
26
26
  return (0, disableDirectives_1.parseDisableDirectives)([comment]).some((directive) => directive.kind === 'disable-next-line');
27
27
  }
28
28
  exports.requiresLineBreakAfter = requiresLineBreakAfter;
29
+ /**
30
+ * Whether a comment's own text carries a line terminator, which only a block
31
+ * comment can do.
32
+ */
33
+ function spansMultipleLines(comment) {
34
+ return comment.loc.start.line !== comment.loc.end.line;
35
+ }
36
+ /**
37
+ * A comment that cannot be folded onto the code that follows it, so a fixer
38
+ * placing it ahead of an expression must give it a line of its own.
39
+ *
40
+ * Two kinds qualify. One is the line-bound comment {@link requiresLineBreakAfter}
41
+ * describes, whose meaning is tied to the line it occupies. The other is a block
42
+ * comment containing a line terminator: the syntactic grammar treats such a
43
+ * comment as a LineTerminator in its own right, so it triggers every restricted
44
+ * production a raw newline would. Measured with `node --check`, a block comment
45
+ * on one line between arrow parameters and their arrow parses, while the same
46
+ * comment broken across two lines is a SyntaxError; ahead of a `return`
47
+ * argument, the multi-line form is worse still — it parses, and ASI silently
48
+ * replaces the returned value with `undefined` (#1963).
49
+ *
50
+ * Only fixers emitting text where a newline is meaningful need this;
51
+ * a replacement wrapped in parentheses can never trip a restricted production
52
+ * and can keep such a comment inline.
53
+ */
54
+ function requiresOwnLine(comment) {
55
+ return requiresLineBreakAfter(comment) || spansMultipleLines(comment);
56
+ }
57
+ exports.requiresOwnLine = requiresOwnLine;
29
58
  /**
30
59
  * Joins the inlined expression and its carried comments into one run of text,
31
60
  * keeping each comment on the side of the expression it was written on and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.140",
3
+ "version": "1.20.142",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,64 @@
1
1
  [
2
+ {
3
+ "version": "1.20.142",
4
+ "date": "2026-08-12T07:04:58.677Z",
5
+ "rules": [
6
+ {
7
+ "name": "no-explicit-return-type",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1964
11
+ ],
12
+ "summary": "keep the annotation's comments clear of the arrow gap (closes #1964)"
13
+ },
14
+ {
15
+ "name": "no-usememo-for-pass-by-value",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1963
19
+ ],
20
+ "summary": "hoist a carried multi-line comment clear of the return (closes #1963)"
21
+ }
22
+ ]
23
+ },
24
+ {
25
+ "version": "1.20.141",
26
+ "date": "2026-08-12T00:57:09.340Z",
27
+ "rules": [
28
+ {
29
+ "name": "enforce-early-destructuring",
30
+ "changeType": "fix",
31
+ "issues": [
32
+ 1956
33
+ ],
34
+ "summary": "anchor the hoist to the hook call, not to its line (closes #1956)"
35
+ },
36
+ {
37
+ "name": "enforce-memoize-async",
38
+ "changeType": "fix",
39
+ "issues": [
40
+ 1957
41
+ ],
42
+ "summary": "keep the injected import below a shared-line directive (closes #1957)"
43
+ },
44
+ {
45
+ "name": "enforce-memoize-getters",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 1958
49
+ ],
50
+ "summary": "keep the injected import below a shared-line directive (closes #1958)"
51
+ },
52
+ {
53
+ "name": "prefer-use-deep-compare-memo",
54
+ "changeType": "fix",
55
+ "issues": [
56
+ 1959
57
+ ],
58
+ "summary": "keep the injected import below a shared-line directive (closes #1959)"
59
+ }
60
+ ]
61
+ },
2
62
  {
3
63
  "version": "1.20.140",
4
64
  "date": "2026-08-11T22:49:18.139Z",