@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.
@@ -4,6 +4,13 @@ exports.useLatestCallback = 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
+ /**
8
+ * Matches Prettier's own default. Dropping the dependency array lets the call
9
+ * collapse onto one line, so the fixer decides a line break a formatter owns: a
10
+ * line it emits past this width is rewritten on the next `prettier --write`, and
11
+ * fails `prettier --check` in the meantime.
12
+ */
13
+ const DEFAULT_PRINT_WIDTH = 80;
7
14
  const LATEST_CALLBACK_MODULE = 'use-latest-callback';
8
15
  const LATEST_CALLBACK_HOOK = 'useLatestCallback';
9
16
  /**
@@ -44,6 +51,7 @@ const findLatestCallbackImport = (program) => {
44
51
  }
45
52
  return null;
46
53
  };
54
+ const isComma = (token) => !!token && token.type === utils_1.AST_TOKEN_TYPES.Punctuator && token.value === ',';
47
55
  /** The leading whitespace of the line the offset sits on. */
48
56
  const indentationAt = (sourceCode, offset) => {
49
57
  const text = sourceCode.getText();
@@ -51,6 +59,75 @@ const indentationAt = (sourceCode, offset) => {
51
59
  const match = /^[ \t]*/.exec(text.slice(lineStart, offset));
52
60
  return match ? match[0] : '';
53
61
  };
62
+ /** The indentation of an offset that starts its own line, else null. */
63
+ const ownLineIndentAt = (sourceCode, offset) => {
64
+ const text = sourceCode.getText();
65
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
66
+ const lead = text.slice(lineStart, offset);
67
+ return lead.trim() === '' ? lead : null;
68
+ };
69
+ /**
70
+ * The file's own nesting step, taken as the most common indentation increase
71
+ * between consecutive lines. Reading it from the source keeps emitted code in
72
+ * the author's units instead of assuming a two-space, space-indented file.
73
+ */
74
+ const indentUnitOf = (sourceCode) => {
75
+ const text = sourceCode.getText();
76
+ const blockComments = sourceCode
77
+ .getAllComments()
78
+ .filter((comment) => comment.type === utils_1.AST_TOKEN_TYPES.Block)
79
+ .map((comment) => comment.range);
80
+ // A block comment's interior lines carry whatever alignment the comment uses
81
+ // — the `*` one column in from its own indentation, or, for commented-out
82
+ // code, the original code's depths. Neither is a nesting step of the file, and
83
+ // counting them makes a JSDoc-heavy file look 1-space indented. Keying on the
84
+ // comment's range rather than a leading `*` also covers a body without them.
85
+ const continuesBlockComment = (offset) => blockComments.some(([start, end]) => start < offset && offset < end);
86
+ const frequencies = new Map();
87
+ let previous = '';
88
+ let offset = 0;
89
+ for (const line of text.split('\n')) {
90
+ const lineStart = offset;
91
+ offset += line.length + 1;
92
+ if (line.trim() === '') {
93
+ continue;
94
+ }
95
+ if (continuesBlockComment(lineStart)) {
96
+ continue;
97
+ }
98
+ const match = /^[ \t]*/.exec(line);
99
+ const indent = match ? match[0] : '';
100
+ if (indent.length > previous.length && indent.startsWith(previous)) {
101
+ const delta = indent.slice(previous.length);
102
+ frequencies.set(delta, (frequencies.get(delta) ?? 0) + 1);
103
+ }
104
+ previous = indent;
105
+ }
106
+ let unit = ' ';
107
+ let best = 0;
108
+ for (const [delta, count] of frequencies) {
109
+ if (count > best) {
110
+ unit = delta;
111
+ best = count;
112
+ }
113
+ }
114
+ return unit;
115
+ };
116
+ /** The source with every planned edit applied, used to measure emitted lines. */
117
+ const applyEdits = (text, edits) => {
118
+ const ordered = [...edits].sort((a, b) => b.range[0] - a.range[0]);
119
+ let result = text;
120
+ for (const edit of ordered) {
121
+ result = `${result.slice(0, edit.range[0])}${edit.text}${result.slice(edit.range[1])}`;
122
+ }
123
+ return result;
124
+ };
125
+ /** The length of the line `offset` sits on. */
126
+ const lineLengthAt = (text, offset) => {
127
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
128
+ const lineBreak = text.indexOf('\n', offset);
129
+ return (lineBreak === -1 ? text.length : lineBreak) - lineStart;
130
+ };
54
131
  /**
55
132
  * Ranges whose interior line breaks carry string data rather than formatting.
56
133
  * A multi-line template literal (or a string spliced together with line
@@ -65,42 +142,42 @@ const stringDataRangesOf = (sourceCode, node) => sourceCode
65
142
  token.loc.start.line !== token.loc.end.line)
66
143
  .map((token) => token.range);
67
144
  /**
68
- * The callback's text re-indented for the line the rewritten call puts it on.
145
+ * A per-line transform moving text written at `fromIndent` to `toIndent`, or
146
+ * null when neither indentation is a prefix of the other (tabs against spaces),
147
+ * where no delta can be applied without corrupting the layout.
148
+ */
149
+ const lineShifterBetween = (fromIndent, toIndent) => {
150
+ if (fromIndent === toIndent) {
151
+ return (line) => line;
152
+ }
153
+ if (fromIndent.startsWith(toIndent)) {
154
+ const removed = fromIndent.slice(toIndent.length);
155
+ return (line) => line.startsWith(removed) ? line.slice(removed.length) : line;
156
+ }
157
+ if (toIndent.startsWith(fromIndent)) {
158
+ const added = toIndent.slice(fromIndent.length);
159
+ return (line) => `${added}${line}`;
160
+ }
161
+ return null;
162
+ };
163
+ /**
164
+ * The callback's text with its continuation lines moved from the depth it was
165
+ * written at to `toIndent`, or null when that move is not expressible.
69
166
  *
70
- * Dropping the dependency array lets the call collapse onto one line, and when
71
- * the original spelled the callback on a line of its own that collapse removes
72
- * exactly one nesting level. Emitting the callback verbatim would leave every
73
- * interior line indented for the level it no longer occupies (issue #1559), so
74
- * each line moves by the difference between the callback's original indentation
75
- * and the indentation of the line the call starts on. Preserving the original
76
- * multi-line layout instead is not an option: a lone function argument is
77
- * hugged onto the call line by the formatter, so the broken-out form would be
78
- * reformatted away on the next write.
167
+ * Dropping the dependency array changes the nesting level the callback sits at,
168
+ * so emitting it verbatim would leave every interior line indented for a level
169
+ * it no longer occupies (issue #1559). The first line is excluded because it is
170
+ * spliced in directly after the call's open paren (or after a fresh indent when
171
+ * the call is broken open), so it has no indentation of its own left to adjust.
79
172
  */
80
- const reindentedCallbackText = (sourceCode, call, callback) => {
173
+ const reindentedText = (sourceCode, callback, fromIndent, toIndent) => {
81
174
  const text = sourceCode.getText(callback);
82
- const callIndent = indentationAt(sourceCode, call.range[0]);
83
- const callbackIndent = indentationAt(sourceCode, callback.range[0]);
84
- // A callback already on the call's line loses no nesting level, so its body
85
- // must be reproduced byte for byte.
86
- if (callIndent === callbackIndent) {
175
+ if (!text.includes('\n')) {
87
176
  return text;
88
177
  }
89
- const shiftLine = (() => {
90
- if (callbackIndent.startsWith(callIndent)) {
91
- const removed = callbackIndent.slice(callIndent.length);
92
- return (line) => line.startsWith(removed) ? line.slice(removed.length) : line;
93
- }
94
- if (callIndent.startsWith(callbackIndent)) {
95
- const added = callIndent.slice(callbackIndent.length);
96
- return (line) => `${added}${line}`;
97
- }
98
- // Indent characters that disagree give no delta that can be applied
99
- // without corrupting the layout, so the text is left as the author wrote it.
100
- return null;
101
- })();
178
+ const shiftLine = lineShifterBetween(fromIndent, toIndent);
102
179
  if (!shiftLine) {
103
- return text;
180
+ return null;
104
181
  }
105
182
  const stringData = stringDataRangesOf(sourceCode, callback);
106
183
  const carriesStringData = (offset) => stringData.some(([start, end]) => start < offset && offset < end);
@@ -110,8 +187,6 @@ const reindentedCallbackText = (sourceCode, call, callback) => {
110
187
  .map((line, index) => {
111
188
  const lineStart = offset;
112
189
  offset += line.length + 1;
113
- // The first line is spliced in after the call's open paren, so it has no
114
- // indentation of its own left to adjust.
115
190
  if (index === 0 || line.trim() === '' || carriesStringData(lineStart)) {
116
191
  return line;
117
192
  }
@@ -119,6 +194,75 @@ const reindentedCallbackText = (sourceCode, call, callback) => {
119
194
  })
120
195
  .join('\n');
121
196
  };
197
+ /**
198
+ * The callback's text for the collapsed, single-line call form. An indentation
199
+ * delta that cannot be applied leaves the text as the author wrote it, which is
200
+ * still valid code at the depth the formatter will correct it from.
201
+ */
202
+ const collapsedCallbackText = (sourceCode, call, callback) => reindentedText(sourceCode, callback, indentationAt(sourceCode, callback.range[0]), indentationAt(sourceCode, call.range[0])) ?? sourceCode.getText(callback);
203
+ /**
204
+ * Whether Prettier answers an over-long call by breaking the argument list
205
+ * open. It does for an arrow, and for a parameter-less function expression —
206
+ * but a function expression WITH parameters is instead hugged onto the call
207
+ * line with its parameter list broken, a shape this fixer cannot author. Left
208
+ * collapsed, such a call at least keeps the first line Prettier keeps.
209
+ */
210
+ const breaksOpenWhenLong = (callback) => callback.type !== utils_1.AST_NODE_TYPES.FunctionExpression ||
211
+ callback.params.length === 0;
212
+ /**
213
+ * Whether the callback's own head — its parameter list, up to where its body
214
+ * begins — is spelled across several lines. An arrow written that way is never
215
+ * hugged onto the call's line, because that would leave the call's open paren
216
+ * and the arrow's dangling at the end of one line; Prettier breaks the argument
217
+ * list open instead however short the collapsed line measures. A function
218
+ * expression is the exception handled above: its broken parameter list IS hugged.
219
+ */
220
+ const headSpansLines = (sourceCode, callback) => callback.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression &&
221
+ sourceCode
222
+ .getText()
223
+ .slice(callback.range[0], callback.body.range[0])
224
+ .includes('\n');
225
+ /**
226
+ * Whether the rewritten call should end its argument list with a comma. The
227
+ * answer is the formatter's `trailingComma` setting, which is read off the call
228
+ * being rewritten whenever it was already broken open — that is exactly the
229
+ * shape the setting governs. A call the author kept on one line says nothing
230
+ * about it, so the modern default (and Prettier's own, from v3) is assumed.
231
+ */
232
+ const wantsTrailingComma = (sourceCode, call) => {
233
+ const lastArgument = call.arguments[call.arguments.length - 1];
234
+ const closeParen = sourceCode.getLastToken(call);
235
+ if (!lastArgument || !closeParen) {
236
+ return true;
237
+ }
238
+ if (isComma(sourceCode.getTokenBefore(closeParen))) {
239
+ return true;
240
+ }
241
+ return closeParen.loc.start.line === lastArgument.loc.end.line;
242
+ };
243
+ /**
244
+ * The rewritten call with its argument list broken open — the callee on its own
245
+ * line, the callback one nesting step in, and the closing paren back at the
246
+ * statement's indentation. Null when the callback's lines cannot be moved to
247
+ * that depth, which asks the caller to keep the collapsed form.
248
+ */
249
+ const brokenOpenCallText = (sourceCode, call, callback, head, indentUnit) => {
250
+ const callIndent = indentationAt(sourceCode, call.range[0]);
251
+ // A callback the author already broke onto its own line keeps the exact
252
+ // indentation it was written at, so that layout survives byte for byte.
253
+ const ownLineIndent = ownLineIndentAt(sourceCode, callback.range[0]);
254
+ const calleeIndent = ownLineIndent !== null &&
255
+ ownLineIndent.length > callIndent.length &&
256
+ ownLineIndent.startsWith(callIndent)
257
+ ? ownLineIndent
258
+ : `${callIndent}${indentUnit}`;
259
+ const moved = reindentedText(sourceCode, callback, indentationAt(sourceCode, callback.range[0]), calleeIndent);
260
+ if (moved === null) {
261
+ return null;
262
+ }
263
+ const comma = wantsTrailingComma(sourceCode, call) ? ',' : '';
264
+ return `${head}(\n${calleeIndent}${moved}${comma}\n${callIndent})`;
265
+ };
122
266
  exports.useLatestCallback = (0, createRule_1.createRule)({
123
267
  name: 'use-latest-callback',
124
268
  meta: {
@@ -128,13 +272,27 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
128
272
  recommended: 'error',
129
273
  },
130
274
  fixable: 'code',
131
- schema: [],
275
+ schema: [
276
+ {
277
+ type: 'object',
278
+ properties: {
279
+ printWidth: {
280
+ type: 'number',
281
+ minimum: 1,
282
+ },
283
+ },
284
+ additionalProperties: false,
285
+ },
286
+ ],
132
287
  messages: {
133
288
  useLatestCallback: 'Replace {{currentHook}} with {{recommendedHook}} from "use-latest-callback" so the callback keeps a stable reference while still reading the latest props/state. useCallback recreates functions whenever dependencies change, which can trigger needless renders and stale closures. Drop the dependency array when switching to {{recommendedHook}}.',
134
289
  },
135
290
  },
136
- defaultOptions: [],
137
- create(context) {
291
+ defaultOptions: [{}],
292
+ create(context, [options]) {
293
+ const printWidth = typeof options.printWidth === 'number' && options.printWidth > 0
294
+ ? options.printWidth
295
+ : DEFAULT_PRINT_WIDTH;
138
296
  const filename = context.getFilename();
139
297
  if (filename.includes('/node_modules/')) {
140
298
  return {};
@@ -360,9 +518,6 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
360
518
  // The react import statement participates in the change set only when
361
519
  // it binds useCallback or anchors a React.useCallback member call.
362
520
  const touchesImport = specifiers.length > 0 || hasReactMemberUseCallback;
363
- const isComma = (token) => !!token &&
364
- token.type === utils_1.AST_TOKEN_TYPES.Punctuator &&
365
- token.value === ',';
366
521
  /**
367
522
  * Splices out one named specifier and the comma that separates it from
368
523
  * the list, touching nothing else. Re-emitting the surviving specifiers
@@ -480,13 +635,46 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
480
635
  }
481
636
  return fixes;
482
637
  };
483
- const conversionFix = (fixer, conversion) => {
484
- const callbackText = reindentedCallbackText(sourceCode, conversion.node, conversion.node.arguments[0]);
485
- const typeParams = conversion.node.typeParameters
486
- ? sourceCode.getText(conversion.node.typeParameters)
487
- : '';
488
- // Replace useCallback with useLatestCallback and remove the dependency array
489
- return fixer.replaceText(conversion.node, `${recommendedHook}${typeParams}(${callbackText})`);
638
+ /**
639
+ * The replacement text for every batched call, collapsed onto one line
640
+ * except where that line would overrun the print width.
641
+ *
642
+ * Prettier hugs a lone function argument onto the call line while it
643
+ * fits, so collapsing is the shape that survives the next write — and
644
+ * over-wrapping is not the safe direction, since a broken-open call
645
+ * short enough to fit is collapsed straight back. Past the width the
646
+ * two swap places, so the emitted line is MEASURED against the source
647
+ * with every collapse already applied, rather than predicted from the
648
+ * call's parts (issue #1579).
649
+ */
650
+ const conversionTexts = () => {
651
+ const headOf = (call) => `${recommendedHook}${call.typeParameters ? sourceCode.getText(call.typeParameters) : ''}`;
652
+ const collapsed = batchedConversions.map((conversion) => ({
653
+ conversion,
654
+ edit: {
655
+ range: conversion.node.range,
656
+ text: `${headOf(conversion.node)}(${collapsedCallbackText(sourceCode, conversion.node, conversion.node.arguments[0])})`,
657
+ },
658
+ }));
659
+ const source = sourceCode.getText();
660
+ const simulated = applyEdits(source, collapsed.map(({ edit }) => edit));
661
+ const shiftBefore = (offset) => collapsed
662
+ .filter(({ edit }) => edit.range[1] <= offset)
663
+ .reduce((total, { edit }) => total + edit.text.length - (edit.range[1] - edit.range[0]), 0);
664
+ const indentUnit = indentUnitOf(sourceCode);
665
+ const texts = new Map();
666
+ for (const { conversion, edit } of collapsed) {
667
+ const { node } = conversion;
668
+ const start = node.range[0] + shiftBefore(node.range[0]);
669
+ const callback = node.arguments[0];
670
+ const overflows = lineLengthAt(simulated, start) > printWidth ||
671
+ headSpansLines(sourceCode, callback);
672
+ const broken = overflows && breaksOpenWhenLong(callback)
673
+ ? brokenOpenCallText(sourceCode, node, callback, headOf(node), indentUnit)
674
+ : null;
675
+ texts.set(node, broken ?? edit.text);
676
+ }
677
+ return texts;
490
678
  };
491
679
  // Every call-site conversion and the import rewrite ride on ONE fix
492
680
  // from ONE report. ESLint discards a multi-part fix wholesale when any
@@ -512,8 +700,9 @@ exports.useLatestCallback = (0, createRule_1.createRule)({
512
700
  if (!batchedConversions.every(reachesHook)) {
513
701
  return null;
514
702
  }
703
+ const texts = conversionTexts();
515
704
  return [
516
- ...batchedConversions.map((conversion) => conversionFix(fixer, conversion)),
705
+ ...batchedConversions.map((conversion) => fixer.replaceText(conversion.node, texts.get(conversion.node))),
517
706
  ...importFixes(fixer),
518
707
  ];
519
708
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.68",
3
+ "version": "1.20.70",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,98 @@
1
1
  [
2
+ {
3
+ "version": "1.20.70",
4
+ "date": "2026-08-01T23:31:02.214Z",
5
+ "rules": [
6
+ {
7
+ "name": "class-methods-read-top-to-bottom",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1592
11
+ ],
12
+ "summary": "preserve the whitespace separating reordered class members (closes #1592)"
13
+ },
14
+ {
15
+ "name": "no-useless-usememo-primitives",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1591
19
+ ],
20
+ "summary": "decline the autofix when inlining would strand a comment (closes #1591)"
21
+ },
22
+ {
23
+ "name": "parallelize-async-operations",
24
+ "changeType": "fix",
25
+ "issues": [
26
+ 1589
27
+ ],
28
+ "summary": "preserve comments between merged awaits (closes #1589)"
29
+ },
30
+ {
31
+ "name": "prefer-map-over-conditional-dispatch",
32
+ "changeType": "fix",
33
+ "issues": [
34
+ 1590
35
+ ],
36
+ "summary": "preserve branch comments in the generated map (closes #1590)"
37
+ }
38
+ ]
39
+ },
40
+ {
41
+ "version": "1.20.69",
42
+ "date": "2026-08-01T20:48:40.484Z",
43
+ "rules": [
44
+ {
45
+ "name": "enforce-early-destructuring",
46
+ "changeType": "fix",
47
+ "issues": [
48
+ 1580
49
+ ],
50
+ "summary": "stop parenthesizing a source that already binds tighter than ?? (closes #1580)"
51
+ },
52
+ {
53
+ "name": "enforce-render-hits-memoization",
54
+ "changeType": "fix",
55
+ "issues": [
56
+ 1585,
57
+ 1586,
58
+ 1588
59
+ ],
60
+ "summary": "report shorthand props (closes #1588); accept a module-scope function as already stable (closes #1586); recognise useLatestCallback as a memoization boundary (closes #1585)"
61
+ },
62
+ {
63
+ "name": "enforce-transform-memoization",
64
+ "changeType": "fix",
65
+ "issues": [
66
+ 1584
67
+ ],
68
+ "summary": "recognise useLatestCallback as a stabilizing wrapper (closes #1584)"
69
+ },
70
+ {
71
+ "name": "optimize-object-boolean-conditions",
72
+ "changeType": "fix",
73
+ "issues": [
74
+ 1581
75
+ ],
76
+ "summary": "see through `as const` when judging a primitive (closes #1581)"
77
+ },
78
+ {
79
+ "name": "prefer-type-over-interface",
80
+ "changeType": "fix",
81
+ "issues": [
82
+ 1583
83
+ ],
84
+ "summary": "exempt merged interface declarations (closes #1583)"
85
+ },
86
+ {
87
+ "name": "use-latest-callback",
88
+ "changeType": "fix",
89
+ "issues": [
90
+ 1579
91
+ ],
92
+ "summary": "keep the call broken open when collapsing overflows the print width (closes #1579)"
93
+ }
94
+ ]
95
+ },
2
96
  {
3
97
  "version": "1.20.68",
4
98
  "date": "2026-08-01T18:03:14.950Z",