@blumintinc/eslint-plugin-blumint 1.20.67 → 1.20.69

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.67',
226
+ version: '1.20.69',
227
227
  },
228
228
  parseOptions: {
229
229
  ecmaVersion: 2020,
@@ -3,6 +3,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceEarlyDestructuring = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ /**
7
+ * Source forms that already bind tighter than `??`, so wrapping them in the
8
+ * hoisted `(obj) ?? {}` initializer adds parentheses a formatter then strips.
9
+ *
10
+ * The set is deliberately limited to what this rule can actually emit: only an
11
+ * identifier-rooted source is ever hoisted, so a call, conditional or logical
12
+ * source never reaches here. Anything absent keeps its parentheses, which is the
13
+ * safe direction — a stray pair is cosmetic, a missing pair changes what the
14
+ * initializer evaluates. `as` and `satisfies` are excluded on purpose: TypeScript
15
+ * rejects them beside `??` unparenthesized.
16
+ */
17
+ const TIGHTER_THAN_NULLISH = new Set([
18
+ utils_1.AST_NODE_TYPES.Identifier,
19
+ utils_1.AST_NODE_TYPES.ThisExpression,
20
+ utils_1.AST_NODE_TYPES.MemberExpression,
21
+ utils_1.AST_NODE_TYPES.ChainExpression,
22
+ utils_1.AST_NODE_TYPES.TSNonNullExpression,
23
+ ]);
24
+ const nullishSourceText = (objectText, init) => init && TIGHTER_THAN_NULLISH.has(init.type) ? objectText : `(${objectText})`;
6
25
  const HOOK_NAMES = new Set([
7
26
  'useEffect',
8
27
  'useMemo',
@@ -875,7 +894,7 @@ function generateHoistingFixes(groups, callback, depsArray, depTexts, insertionS
875
894
  for (const group of groups.values()) {
876
895
  const sortedProps = Array.from(group.properties.values()).sort((a, b) => a.order - b.order);
877
896
  const pattern = `{ ${sortedProps.map((p) => p.text).join(', ')} }`;
878
- hoistedLines.push(`${indent}const ${pattern} = (${group.objectText}) ?? {};`);
897
+ hoistedLines.push(`${indent}const ${pattern} = ${nullishSourceText(group.objectText, group.inits[0])} ?? {};`);
879
898
  }
880
899
  reservedNamesByScope.set(scope, updatedReservedNames);
881
900
  const newDepSet = new Set(newDepTexts);
@@ -1,3 +1,4 @@
1
+ import { TSESLint } from '@typescript-eslint/utils';
1
2
  type MessageIds = 'requireMemoizedTransformBefore' | 'requireMemoizedRender' | 'requireMemoizedRenderHits' | 'noDirectComponentInRender';
2
- export declare const enforceRenderHitsMemoization: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<MessageIds, [], import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
3
+ export declare const enforceRenderHitsMemoization: TSESLint.RuleModule<MessageIds, [], TSESLint.RuleListener>;
3
4
  export {};
@@ -3,6 +3,31 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.enforceRenderHitsMemoization = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const createRule_1 = require("../utils/createRule");
6
+ const LATEST_CALLBACK_MODULE = 'use-latest-callback';
7
+ const LATEST_CALLBACK_HOOK = 'useLatestCallback';
8
+ /**
9
+ * Declaration forms whose binding is created once for the program's lifetime.
10
+ *
11
+ * `let` and `var` are deliberately excluded: a reassignable binding can hand
12
+ * `useRenderHits` a different function on a later render, which is precisely
13
+ * the instability this rule exists to catch.
14
+ */
15
+ function isStableDeclaration(def) {
16
+ switch (def.type) {
17
+ case utils_1.TSESLint.Scope.DefinitionType.FunctionName:
18
+ return true;
19
+ case utils_1.TSESLint.Scope.DefinitionType.ImportBinding:
20
+ // A type-only import binds no value, so its local name names nothing
21
+ // callable — the same reason the memoization-callee set rejects one.
22
+ return (def.parent.importKind !== 'type' &&
23
+ (def.node.type !== utils_1.AST_NODE_TYPES.ImportSpecifier ||
24
+ def.node.importKind !== 'type'));
25
+ case utils_1.TSESLint.Scope.DefinitionType.Variable:
26
+ return def.parent.kind === 'const';
27
+ default:
28
+ return false;
29
+ }
30
+ }
6
31
  exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
7
32
  name: 'enforce-render-hits-memoization',
8
33
  meta: {
@@ -13,14 +38,36 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
13
38
  },
14
39
  schema: [],
15
40
  messages: {
16
- requireMemoizedTransformBefore: 'transformBefore prop must be memoized using useCallback',
17
- requireMemoizedRender: 'render prop must be memoized using useCallback',
18
- requireMemoizedRenderHits: 'renderHits must be used inside useMemo or useCallback',
41
+ requireMemoizedTransformBefore: 'transformBefore is recreated on every render, so useRenderHits sees a new transform identity each pass and re-derives (and re-renders) the whole hit list even when the hits did not change. Memoize it with useCallback, useMemo, or useLatestCallback so the reference stays stable across renders.',
42
+ requireMemoizedRender: 'render is recreated on every render, so useRenderHits sees a new render identity each pass and re-renders every hit even when the hits did not change. Memoize it with useCallback, useMemo, or useLatestCallback so the reference stays stable across renders.',
43
+ requireMemoizedRenderHits: 'renderHits builds a fresh element for every hit, so calling it outside a memoization boundary re-creates the entire list on each render. Wrap the call in useCallback, useMemo, or useLatestCallback so the elements are rebuilt only when the hits they came from change.',
19
44
  noDirectComponentInRender: 'Do not pass React components directly to render prop, use a memoized arrow function instead',
20
45
  },
21
46
  },
22
47
  defaultOptions: [],
23
48
  create(context) {
49
+ // Every callee this rule accepts as a memoization boundary. `useCallback`
50
+ // and `useMemo` are seeded bare because the rule never resolves React's
51
+ // import either; local names bound from `use-latest-callback` are added as
52
+ // its imports are visited.
53
+ //
54
+ // `useLatestCallback` belongs in the same set rather than a separate one:
55
+ // nothing here inspects a dependency array, so the hook taking none (it
56
+ // keeps the latest callback behind a ref that is stable for the component's
57
+ // whole life) costs the rule no precision. It has to be here because
58
+ // `use-latest-callback` — 'error' in the same recommended config, and
59
+ // fixable — rewrites every `useCallback` into it, and ESLint re-lints until
60
+ // the output settles, so one `eslint --fix` run does both steps. Without
61
+ // this entry correctly memoized code goes in and a demand to memoize it
62
+ // comes out, and the demand names the very hook the sibling fixer just
63
+ // removed, so following it loops forever (issue #1585).
64
+ const memoizationCallees = new Set([
65
+ 'useCallback',
66
+ 'useMemo',
67
+ LATEST_CALLBACK_HOOK,
68
+ ]);
69
+ const isMemoizationCallee = (node) => node.type === utils_1.AST_NODE_TYPES.Identifier &&
70
+ memoizationCallees.has(node.name);
24
71
  const isReactComponent = (node) => {
25
72
  if (node.type !== utils_1.AST_NODE_TYPES.Identifier)
26
73
  return false;
@@ -29,17 +76,30 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
29
76
  const isMemoizedCall = (node) => {
30
77
  if (node.type !== utils_1.AST_NODE_TYPES.CallExpression)
31
78
  return false;
32
- if (!node.callee || node.callee.type !== utils_1.AST_NODE_TYPES.Identifier)
79
+ if (!node.callee)
33
80
  return false;
34
- return (node.callee.name === 'useCallback' || node.callee.name === 'useMemo');
81
+ return isMemoizationCallee(node.callee);
82
+ };
83
+ const isWithinMemoizationCall = (node) => {
84
+ let current = node;
85
+ while (current?.parent) {
86
+ if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression &&
87
+ isMemoizationCallee(current.parent.callee)) {
88
+ return true;
89
+ }
90
+ current = current.parent;
91
+ }
92
+ return false;
35
93
  };
36
94
  const isMemoizedVariable = (node) => {
37
95
  if (node.type !== utils_1.AST_NODE_TYPES.Identifier)
38
96
  return false;
39
- // Get the variable declaration for this identifier
40
- const variable = context
41
- .getScope()
42
- .variables.find((v) => v.name === node.name);
97
+ // The whole scope chain has to be searched rather than the current
98
+ // scope's own variable list: a useRenderHits call sitting inside a nested
99
+ // block or a nested component reaches its memoized declaration through an
100
+ // enclosing scope, and reading one scope's `variables` would miss it and
101
+ // demand a useCallback around a value that already has one.
102
+ const variable = utils_1.ASTUtils.findVariable(context.getScope(), node);
43
103
  if (!variable)
44
104
  return false;
45
105
  // Check if the variable is initialized with a memoized call
@@ -55,6 +115,45 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
55
115
  }
56
116
  return false;
57
117
  };
118
+ /**
119
+ * A prop pointing at a declaration that lives outside every component body.
120
+ *
121
+ * Module scope creates the binding once for the program's lifetime, so its
122
+ * identity is strictly more stable than anything a hook can hand back:
123
+ * demanding a `useCallback` wrapper around it asks for work that can only
124
+ * make the reference less stable, never more.
125
+ *
126
+ * Both `module` and `global` count. Under `sourceType: 'script'` — the
127
+ * parser default, and what a consumer's config may well leave in place — a
128
+ * top-level declaration binds to the *global* scope and no module scope
129
+ * exists at all (issue #1578), so keying on `module` alone would silently
130
+ * drop the carve-out for exactly the consumers who never opted into module
131
+ * parsing.
132
+ *
133
+ * The shape is not hypothetical:
134
+ * `no-empty-dependency-use-callbacks` — 'error' in the same recommended
135
+ * config, and fixable — hoists a dependency-free callback to module scope
136
+ * and drops the hook, so one `eslint --fix` run rewrites memoized code into
137
+ * exactly this form. Without the carve-out the config demands the very hook
138
+ * its own fixer just removed (issue #1586).
139
+ */
140
+ const isStableOuterScopeBinding = (node) => {
141
+ if (node.type !== utils_1.AST_NODE_TYPES.Identifier)
142
+ return false;
143
+ // The scope chain has to be walked rather than a single scope's variable
144
+ // list read: the useRenderHits call sits inside the component, so a
145
+ // module-scope declaration is never among the current scope's own
146
+ // variables.
147
+ const variable = utils_1.ASTUtils.findVariable(context.getScope(), node);
148
+ if (!variable)
149
+ return false;
150
+ const scopeType = variable.scope.type;
151
+ if (scopeType !== utils_1.TSESLint.Scope.ScopeType.module &&
152
+ scopeType !== utils_1.TSESLint.Scope.ScopeType.global) {
153
+ return false;
154
+ }
155
+ return variable.defs.some(isStableDeclaration);
156
+ };
58
157
  const isInsideMemoizedCall = (node) => {
59
158
  // Handle the case when node is already a memoized call
60
159
  if (isMemoizedCall(node))
@@ -62,18 +161,13 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
62
161
  // Check if the node is a reference to a memoized variable
63
162
  if (isMemoizedVariable(node))
64
163
  return true;
164
+ // A declaration outside every component body needs no memoization: it is
165
+ // already as stable as a reference can be.
166
+ if (isStableOuterScopeBinding(node))
167
+ return true;
65
168
  // Check if the node is inside a memoization hook call
66
- let current = node;
67
- while (current?.parent) {
68
- if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
69
- const callee = current.parent.callee;
70
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
71
- (callee.name === 'useCallback' || callee.name === 'useMemo')) {
72
- return true;
73
- }
74
- }
75
- current = current.parent;
76
- }
169
+ if (isWithinMemoizationCall(node))
170
+ return true;
77
171
  // Check if the node is a reference to a memoized value
78
172
  const scope = context.getScope();
79
173
  // Make sure node is an Identifier before accessing name property
@@ -88,44 +182,22 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
88
182
  for (const def of variable.defs) {
89
183
  const parent = def.node.parent;
90
184
  if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
91
- parent.init?.type === utils_1.AST_NODE_TYPES.CallExpression) {
92
- const callee = parent.init.callee;
93
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
94
- (callee.name === 'useCallback' || callee.name === 'useMemo')) {
95
- return true;
96
- }
185
+ parent.init?.type === utils_1.AST_NODE_TYPES.CallExpression &&
186
+ isMemoizationCallee(parent.init.callee)) {
187
+ return true;
97
188
  }
98
189
  }
99
190
  // Check if any reference is inside a memoized call
100
191
  for (const ref of variable.references) {
101
- let current = ref.identifier;
102
- while (current?.parent) {
103
- if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
104
- const callee = current.parent.callee;
105
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
106
- (callee.name === 'useCallback' || callee.name === 'useMemo')) {
107
- return true;
108
- }
109
- }
110
- current = current.parent;
111
- }
192
+ if (isWithinMemoizationCall(ref.identifier))
193
+ return true;
112
194
  }
113
195
  // Check if the node is a property of an object that is memoized
114
196
  const parent = node.parent;
115
197
  if (parent?.type === utils_1.AST_NODE_TYPES.Property &&
116
198
  parent.parent?.type === utils_1.AST_NODE_TYPES.ObjectExpression) {
117
- const objectExpression = parent.parent;
118
- let current = objectExpression;
119
- while (current?.parent) {
120
- if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
121
- const callee = current.parent.callee;
122
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
123
- (callee.name === 'useCallback' || callee.name === 'useMemo')) {
124
- return true;
125
- }
126
- }
127
- current = current.parent;
128
- }
199
+ if (isWithinMemoizationCall(parent.parent))
200
+ return true;
129
201
  }
130
202
  return false;
131
203
  };
@@ -133,6 +205,23 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
133
205
  let renderHitsName = 'renderHits';
134
206
  return {
135
207
  ImportDeclaration(node) {
208
+ // The module's sole export is the hook, so its DEFAULT specifier binds
209
+ // it under whatever local name the file chose — a shape a set of bare
210
+ // hook names cannot see. `use-latest-callback`'s own fixer picks that
211
+ // name, falling back to `useLatestCallback2` when `useLatestCallback`
212
+ // is already taken in the file, so the alias is not hypothetical.
213
+ if (node.source.value === LATEST_CALLBACK_MODULE &&
214
+ (!node.importKind || node.importKind === 'value')) {
215
+ for (const specifier of node.specifiers) {
216
+ if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
217
+ (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
218
+ specifier.importKind !== 'type' &&
219
+ specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
220
+ specifier.imported.name === LATEST_CALLBACK_HOOK)) {
221
+ memoizationCallees.add(specifier.local.name);
222
+ }
223
+ }
224
+ }
136
225
  if (node.source.value.endsWith('useRenderHits')) {
137
226
  for (const specifier of node.specifiers) {
138
227
  if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
@@ -160,43 +249,19 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
160
249
  const options = node.arguments[0];
161
250
  if (options.type !== utils_1.AST_NODE_TYPES.ObjectExpression)
162
251
  return;
163
- // Variable to track if we need to check properties (check both by default)
164
- const checkProps = {
165
- transformBefore: true,
166
- render: true,
167
- };
168
- // First pass: Check if transformBefore or render properties exist as shorthand
169
- for (const prop of options.properties) {
170
- if (prop.type !== utils_1.AST_NODE_TYPES.Property)
171
- continue;
172
- if (prop.key.type !== utils_1.AST_NODE_TYPES.Identifier)
173
- continue;
174
- // If it's shorthand property syntax like { transformBefore } and already a memoized variable
175
- if (prop.key.name === 'transformBefore' &&
176
- prop.shorthand &&
177
- prop.key.type === utils_1.AST_NODE_TYPES.Identifier) {
178
- checkProps.transformBefore = !isMemoizedVariable(prop.key);
179
- }
180
- else if (prop.key.name === 'render' &&
181
- prop.shorthand &&
182
- prop.key.type === utils_1.AST_NODE_TYPES.Identifier) {
183
- checkProps.render = !isMemoizedVariable(prop.key);
184
- }
185
- }
186
- // Second pass: Check non-shorthand properties
252
+ // Shorthand props are checked exactly like written-out ones. `{ render }`
253
+ // and `render: render` describe the same value, and the config's own
254
+ // fixable `object-shorthand: ['error', 'always']` rewrites the second
255
+ // into the first, so exempting the shorthand form would let a single
256
+ // `eslint --fix` erase every report this rule makes about a prop whose
257
+ // variable happens to share the API's name the shape idiomatic code
258
+ // reaches for first (issue #1588).
187
259
  for (const prop of options.properties) {
188
260
  if (prop.type !== utils_1.AST_NODE_TYPES.Property)
189
261
  continue;
190
262
  if (prop.key.type !== utils_1.AST_NODE_TYPES.Identifier)
191
263
  continue;
192
- // Skip shorthand properties that we already checked
193
- if (prop.shorthand)
194
- continue;
195
- if (prop.key.name === 'transformBefore' &&
196
- checkProps.transformBefore) {
197
- // Skip if the value is already a memoized call
198
- if (isMemoizedCall(prop.value))
199
- continue;
264
+ if (prop.key.name === 'transformBefore') {
200
265
  if (!isInsideMemoizedCall(prop.value)) {
201
266
  context.report({
202
267
  node: prop.value,
@@ -204,7 +269,7 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
204
269
  });
205
270
  }
206
271
  }
207
- else if (prop.key.name === 'render' && checkProps.render) {
272
+ else if (prop.key.name === 'render') {
208
273
  if (isReactComponent(prop.value)) {
209
274
  context.report({
210
275
  node: prop.value,
@@ -222,17 +287,8 @@ exports.enforceRenderHitsMemoization = (0, createRule_1.createRule)({
222
287
  }
223
288
  if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
224
289
  node.callee.name === renderHitsName) {
225
- let current = node;
226
- while (current?.parent) {
227
- if (current.parent.type === utils_1.AST_NODE_TYPES.CallExpression) {
228
- const callee = current.parent.callee;
229
- if (callee.type === utils_1.AST_NODE_TYPES.Identifier &&
230
- (callee.name === 'useCallback' || callee.name === 'useMemo')) {
231
- return;
232
- }
233
- }
234
- current = current.parent;
235
- }
290
+ if (isWithinMemoizationCall(node))
291
+ return;
236
292
  context.report({
237
293
  node,
238
294
  messageId: 'requireMemoizedRenderHits',
@@ -4,6 +4,8 @@ exports.enforceTransformMemoization = 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 LATEST_CALLBACK_MODULE = 'use-latest-callback';
8
+ const LATEST_CALLBACK_HOOK = 'useLatestCallback';
7
9
  exports.enforceTransformMemoization = (0, createRule_1.createRule)({
8
10
  name: 'enforce-transform-memoization',
9
11
  meta: {
@@ -26,7 +28,14 @@ exports.enforceTransformMemoization = (0, createRule_1.createRule)({
26
28
  const scopeManager = sourceCode.scopeManager;
27
29
  const adaptValueNames = new Set(['adaptValue']);
28
30
  const memoizingHooks = new Set(['useMemo', 'useCallback']);
29
- const stabilizingUtilities = new Set(['useEvent']);
31
+ // Hooks that hand back a reference stable for the component's whole life and
32
+ // take no dependency array, so there is none to audit. `useLatestCallback`
33
+ // belongs here because `use-latest-callback` — 'error' in the same
34
+ // recommended config, and fixable — rewrites every `useCallback` into it and
35
+ // drops the array. ESLint re-lints until the output settles, so one
36
+ // `eslint --fix` run does both steps: without this entry, correctly
37
+ // memoized code goes in and a demand to memoize it comes out (issue #1584).
38
+ const stabilizingUtilities = new Set(['useEvent', LATEST_CALLBACK_HOOK]);
30
39
  const getPropertyName = (key) => {
31
40
  if (key.type === utils_1.AST_NODE_TYPES.Identifier) {
32
41
  return key.name;
@@ -378,6 +387,23 @@ exports.enforceTransformMemoization = (0, createRule_1.createRule)({
378
387
  return {
379
388
  ImportDeclaration(node) {
380
389
  const sourceValue = typeof node.source.value === 'string' ? node.source.value : '';
390
+ // The module's sole export is the hook, so its DEFAULT specifier binds
391
+ // it under whatever local name the file chose — a shape a set of bare
392
+ // hook names cannot see. `use-latest-callback`'s own fixer picks that
393
+ // name, and falls back to `useLatestCallback2` when `useLatestCallback`
394
+ // is already taken in the file, so the alias is not hypothetical.
395
+ if (sourceValue === LATEST_CALLBACK_MODULE &&
396
+ (!node.importKind || node.importKind === 'value')) {
397
+ for (const specifier of node.specifiers) {
398
+ if (specifier.type === utils_1.AST_NODE_TYPES.ImportDefaultSpecifier ||
399
+ (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
400
+ specifier.importKind !== 'type' &&
401
+ specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
402
+ specifier.imported.name === LATEST_CALLBACK_HOOK)) {
403
+ stabilizingUtilities.add(specifier.local.name);
404
+ }
405
+ }
406
+ }
381
407
  for (const specifier of node.specifiers) {
382
408
  if (specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier &&
383
409
  specifier.imported.type === utils_1.AST_NODE_TYPES.Identifier &&
@@ -101,6 +101,12 @@ function isPrimitiveTypeNode(node) {
101
101
  * operands: comparisons and arithmetic yield numbers/strings/booleans, and `!`,
102
102
  * `typeof` and friends yield booleans/strings/numbers.
103
103
  */
104
+ /** `as const` — a type reference whose name is the `const` contextual keyword. */
105
+ function isConstAssertion(typeNode) {
106
+ return (typeNode.type === utils_1.AST_NODE_TYPES.TSTypeReference &&
107
+ typeNode.typeName.type === utils_1.AST_NODE_TYPES.Identifier &&
108
+ typeNode.typeName.name === 'const');
109
+ }
104
110
  function isPrimitiveExpression(node) {
105
111
  if (!node) {
106
112
  return false;
@@ -120,7 +126,19 @@ function isPrimitiveExpression(node) {
120
126
  case utils_1.AST_NODE_TYPES.BinaryExpression:
121
127
  return true;
122
128
  case utils_1.AST_NODE_TYPES.TSAsExpression:
123
- return isPrimitiveTypeNode(node.typeAnnotation);
129
+ // `as const` narrows rather than retypes, so the asserted expression
130
+ // decides. Recursing keeps `{ a: 1 } as const` an object while accepting
131
+ // `0 as const` — the form global-const-style and
132
+ // enforce-object-literal-as-const rewrite bare constants into, so without
133
+ // this the plugin's own fixers manufacture the report (#1581).
134
+ return isConstAssertion(node.typeAnnotation)
135
+ ? isPrimitiveExpression(node.expression)
136
+ : isPrimitiveTypeNode(node.typeAnnotation);
137
+ // `satisfies` never changes the value, only checks it.
138
+ case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
139
+ return isPrimitiveExpression(node.expression);
140
+ case utils_1.AST_NODE_TYPES.TSNonNullExpression:
141
+ return isPrimitiveExpression(node.expression);
124
142
  default:
125
143
  return false;
126
144
  }
@@ -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',
@@ -54,17 +54,28 @@ function isNameInScope(scope, name) {
54
54
  * between consecutive lines. Reading it from the source keeps emitted code in
55
55
  * the author's units instead of assuming a two-space, space-indented file.
56
56
  */
57
- function indentUnitOf(text) {
57
+ function indentUnitOf(sourceCode) {
58
+ const text = sourceCode.getText();
59
+ const blockComments = sourceCode
60
+ .getAllComments()
61
+ .filter((comment) => comment.type === utils_1.AST_TOKEN_TYPES.Block)
62
+ .map((comment) => comment.range);
63
+ // A block comment's interior lines carry whatever alignment the comment uses
64
+ // — the `*` one column in from its own indentation, or, for commented-out
65
+ // code, the original code's depths. Neither is a nesting step of the file, and
66
+ // counting them makes a JSDoc-heavy file look 1-space indented. Keying on the
67
+ // comment's range rather than a leading `*` also covers a body without them.
68
+ const continuesBlockComment = (offset) => blockComments.some(([start, end]) => start < offset && offset < end);
58
69
  const frequencies = new Map();
59
70
  let previous = '';
71
+ let offset = 0;
60
72
  for (const line of text.split('\n')) {
73
+ const lineStart = offset;
74
+ offset += line.length + 1;
61
75
  if (line.trim() === '') {
62
76
  continue;
63
77
  }
64
- // A block comment's continuation lines align on the `*` one column in from
65
- // the comment's own indentation. That is comment alignment, not a nesting
66
- // step, and counting it makes any JSDoc-heavy file look 1-space indented.
67
- if (line.trimStart().startsWith('*')) {
78
+ if (continuesBlockComment(lineStart)) {
68
79
  continue;
69
80
  }
70
81
  const match = /^[ \t]*/.exec(line);
@@ -121,7 +132,7 @@ exports.preferUnionFromConstArray = (0, createRule_1.createRule)({
121
132
  let indentUnit = null;
122
133
  const fileIndentUnit = () => {
123
134
  if (indentUnit === null) {
124
- indentUnit = indentUnitOf(sourceCode.getText());
135
+ indentUnit = indentUnitOf(sourceCode);
125
136
  }
126
137
  return indentUnit;
127
138
  };
@@ -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;
@@ -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
  },
@@ -17,9 +17,11 @@ type ParserOptionsCarrier = {
17
17
  * and its rule silently drops out of the fixer, convergence, collision and
18
18
  * crash sweeps. `src/tests/no-local-rule-tester.test.ts` enforces that.
19
19
  *
20
- * A file needing parser options the shared instances do not declare — ES module
21
- * scope analysis, a type-aware `project` — attaches them per case rather than
22
- * forking a tester. RuleTester deep merges each case's config over the tester's,
20
+ * A file needing parser options the shared instances do not declare — a
21
+ * type-aware `project`, a non-default `ecmaVersion` — attaches them per case
22
+ * rather than forking a tester. The shared instances already declare ES module
23
+ * scope analysis, so a case restating `sourceType: 'module'` is redundant but
24
+ * harmless. RuleTester deep merges each case's config over the tester's,
23
25
  * and options a case declares itself still win over the ones applied here, so a
24
26
  * case keeps any override it already had. Carrying the options on the case also
25
27
  * means the harvested corpus records the parser configuration a snippet was
@@ -3,12 +3,31 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withParserOptions = exports.ruleTesterMarkdown = exports.ruleTesterJson = exports.ruleTesterJsx = exports.ruleTesterTs = void 0;
4
4
  const utils_1 = require("@typescript-eslint/utils");
5
5
  const eslint_1 = require("eslint");
6
+ /**
7
+ * `sourceType: 'module'` matches how the linted codebase actually parses: every
8
+ * consumer source file is an ES module, so its top-level declarations live in a
9
+ * module scope nested inside the global one.
10
+ *
11
+ * `@typescript-eslint/parser` defaults to `'script'`, under which a top-level
12
+ * `import`/`const` still binds — the binding hangs off the *global* scope rather
13
+ * than a module scope. References therefore still resolve either way, but the
14
+ * scope shape differs: under `'script'` `globalScope.variables` carries the
15
+ * file's own declarations and there is no module scope at all. A rule that
16
+ * discriminates on `scope.type`, walks `globalScope.childScopes`, or reads
17
+ * `globalScope.variables` would otherwise be exercised against a shape no real
18
+ * file has. `src/tests/rule-tester-parse-mode.test.ts` pins this.
19
+ */
20
+ const SHARED_PARSER_OPTIONS = {
21
+ sourceType: 'module',
22
+ };
6
23
  exports.ruleTesterTs = new utils_1.ESLintUtils.RuleTester({
7
24
  parser: '@typescript-eslint/parser',
25
+ parserOptions: { ...SHARED_PARSER_OPTIONS },
8
26
  });
9
27
  exports.ruleTesterJsx = new utils_1.ESLintUtils.RuleTester({
10
28
  parser: '@typescript-eslint/parser',
11
29
  parserOptions: {
30
+ ...SHARED_PARSER_OPTIONS,
12
31
  ecmaFeatures: {
13
32
  jsx: true,
14
33
  },
@@ -32,9 +51,11 @@ exports.ruleTesterMarkdown = new eslint_1.RuleTester({
32
51
  * and its rule silently drops out of the fixer, convergence, collision and
33
52
  * crash sweeps. `src/tests/no-local-rule-tester.test.ts` enforces that.
34
53
  *
35
- * A file needing parser options the shared instances do not declare — ES module
36
- * scope analysis, a type-aware `project` — attaches them per case rather than
37
- * forking a tester. RuleTester deep merges each case's config over the tester's,
54
+ * A file needing parser options the shared instances do not declare — a
55
+ * type-aware `project`, a non-default `ecmaVersion` — attaches them per case
56
+ * rather than forking a tester. The shared instances already declare ES module
57
+ * scope analysis, so a case restating `sourceType: 'module'` is redundant but
58
+ * harmless. RuleTester deep merges each case's config over the tester's,
38
59
  * and options a case declares itself still win over the ones applied here, so a
39
60
  * case keeps any override it already had. Carrying the options on the case also
40
61
  * means the harvested corpus records the parser configuration a snippet was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blumintinc/eslint-plugin-blumint",
3
- "version": "1.20.67",
3
+ "version": "1.20.69",
4
4
  "description": "Custom eslint rules for use within BluMint",
5
5
  "author": {
6
6
  "name": "Brodie McGuire",
@@ -1,4 +1,74 @@
1
1
  [
2
+ {
3
+ "version": "1.20.69",
4
+ "date": "2026-08-01T20:48:40.484Z",
5
+ "rules": [
6
+ {
7
+ "name": "enforce-early-destructuring",
8
+ "changeType": "fix",
9
+ "issues": [
10
+ 1580
11
+ ],
12
+ "summary": "stop parenthesizing a source that already binds tighter than ?? (closes #1580)"
13
+ },
14
+ {
15
+ "name": "enforce-render-hits-memoization",
16
+ "changeType": "fix",
17
+ "issues": [
18
+ 1585,
19
+ 1586,
20
+ 1588
21
+ ],
22
+ "summary": "report shorthand props (closes #1588); accept a module-scope function as already stable (closes #1586); recognise useLatestCallback as a memoization boundary (closes #1585)"
23
+ },
24
+ {
25
+ "name": "enforce-transform-memoization",
26
+ "changeType": "fix",
27
+ "issues": [
28
+ 1584
29
+ ],
30
+ "summary": "recognise useLatestCallback as a stabilizing wrapper (closes #1584)"
31
+ },
32
+ {
33
+ "name": "optimize-object-boolean-conditions",
34
+ "changeType": "fix",
35
+ "issues": [
36
+ 1581
37
+ ],
38
+ "summary": "see through `as const` when judging a primitive (closes #1581)"
39
+ },
40
+ {
41
+ "name": "prefer-type-over-interface",
42
+ "changeType": "fix",
43
+ "issues": [
44
+ 1583
45
+ ],
46
+ "summary": "exempt merged interface declarations (closes #1583)"
47
+ },
48
+ {
49
+ "name": "use-latest-callback",
50
+ "changeType": "fix",
51
+ "issues": [
52
+ 1579
53
+ ],
54
+ "summary": "keep the call broken open when collapsing overflows the print width (closes #1579)"
55
+ }
56
+ ]
57
+ },
58
+ {
59
+ "version": "1.20.68",
60
+ "date": "2026-08-01T18:03:14.950Z",
61
+ "rules": [
62
+ {
63
+ "name": "prefer-union-from-const-array",
64
+ "changeType": "fix",
65
+ "issues": [
66
+ 1577
67
+ ],
68
+ "summary": "exclude block comments by range when inferring the indent unit (closes #1577)"
69
+ }
70
+ ]
71
+ },
2
72
  {
3
73
  "version": "1.20.67",
4
74
  "date": "2026-08-01T17:17:30.817Z",