eslint-plugin-ts-data-forge 0.6.0 → 0.7.0

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.
Files changed (77) hide show
  1. package/README.md +1 -0
  2. package/dist/index.mjs +2 -2
  3. package/dist/index.mjs.map +1 -1
  4. package/dist/plugin.d.mts +2 -0
  5. package/dist/plugin.d.mts.map +1 -1
  6. package/dist/plugin.mjs +45 -41
  7. package/dist/plugin.mjs.map +1 -1
  8. package/dist/rule-types.d.mts +15 -0
  9. package/dist/rule-types.d.mts.map +1 -1
  10. package/dist/rule-types.mjs +6 -2
  11. package/dist/rule-types.mjs.map +1 -1
  12. package/dist/rules/ast-utils.mjs +107 -50
  13. package/dist/rules/ast-utils.mjs.map +1 -1
  14. package/dist/rules/branded-number-types.mjs +41 -37
  15. package/dist/rules/branded-number-types.mjs.map +1 -1
  16. package/dist/rules/constants.mjs +2 -4
  17. package/dist/rules/constants.mjs.map +1 -1
  18. package/dist/rules/import-utils.mjs +80 -56
  19. package/dist/rules/import-utils.mjs.map +1 -1
  20. package/dist/rules/index.mjs +2 -2
  21. package/dist/rules/index.mjs.map +1 -1
  22. package/dist/rules/no-side-effect-import.mjs +46 -34
  23. package/dist/rules/no-side-effect-import.mjs.map +1 -1
  24. package/dist/rules/no-unnecessary-type-guard.mjs +376 -265
  25. package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
  26. package/dist/rules/prefer-arr-is-array.mjs +64 -52
  27. package/dist/rules/prefer-arr-is-array.mjs.map +1 -1
  28. package/dist/rules/prefer-arr-is-bounded-length-array.mjs +91 -85
  29. package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
  30. package/dist/rules/prefer-arr-is-fixed-length-array.mjs +92 -83
  31. package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
  32. package/dist/rules/prefer-arr-is-max-length-array.mjs +92 -80
  33. package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
  34. package/dist/rules/prefer-arr-is-min-length-array.mjs +92 -80
  35. package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
  36. package/dist/rules/prefer-arr-is-non-empty.mjs +85 -77
  37. package/dist/rules/prefer-arr-is-non-empty.mjs.map +1 -1
  38. package/dist/rules/prefer-arr-scan.d.mts +96 -0
  39. package/dist/rules/prefer-arr-scan.d.mts.map +1 -0
  40. package/dist/rules/prefer-arr-scan.mjs +482 -0
  41. package/dist/rules/prefer-arr-scan.mjs.map +1 -0
  42. package/dist/rules/prefer-arr-sum.mjs +176 -132
  43. package/dist/rules/prefer-arr-sum.mjs.map +1 -1
  44. package/dist/rules/prefer-as-int.mjs +77 -71
  45. package/dist/rules/prefer-as-int.mjs.map +1 -1
  46. package/dist/rules/prefer-canonical-array-slicing.mjs +503 -343
  47. package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
  48. package/dist/rules/prefer-canonical-length-cast.mjs +217 -144
  49. package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -1
  50. package/dist/rules/prefer-canonical-length-guard.mjs +160 -109
  51. package/dist/rules/prefer-canonical-length-guard.mjs.map +1 -1
  52. package/dist/rules/prefer-comparison-over-nullish-guard.mjs +89 -71
  53. package/dist/rules/prefer-comparison-over-nullish-guard.mjs.map +1 -1
  54. package/dist/rules/prefer-is-non-null-object.mjs +91 -72
  55. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  56. package/dist/rules/prefer-is-record-and-has-key.mjs +148 -105
  57. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  58. package/dist/rules/prefer-num-safe-parse-float.mjs +101 -77
  59. package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
  60. package/dist/rules/prefer-num-safe-parse-int.mjs +103 -81
  61. package/dist/rules/prefer-num-safe-parse-int.mjs.map +1 -1
  62. package/dist/rules/prefer-obj-over-entries-round-trip.mjs +299 -194
  63. package/dist/rules/prefer-obj-over-entries-round-trip.mjs.map +1 -1
  64. package/dist/rules/prefer-range-for-loop.mjs +121 -99
  65. package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
  66. package/dist/rules/rules.d.mts +1 -0
  67. package/dist/rules/rules.d.mts.map +1 -1
  68. package/dist/rules/rules.mjs +19 -20
  69. package/dist/rules/rules.mjs.map +1 -1
  70. package/dist/types.mjs +2 -2
  71. package/dist/types.mjs.map +1 -1
  72. package/package.json +6 -7
  73. package/src/plugin.mts +1 -0
  74. package/src/rule-types.mts +16 -0
  75. package/src/rules/prefer-arr-scan.mts +693 -0
  76. package/src/rules/prefer-arr-scan.test.mts +295 -0
  77. package/src/rules/rules.mts +2 -0
@@ -0,0 +1,693 @@
1
+ import {
2
+ AST_NODE_TYPES,
3
+ type TSESLint,
4
+ type TSESTree,
5
+ } from '@typescript-eslint/utils';
6
+ import { Arr } from 'ts-data-forge';
7
+ import { type FixedLengthTuple } from 'ts-type-forge';
8
+ import * as ts from 'typescript';
9
+ import {
10
+ buildImportFixes,
11
+ getNamedImports,
12
+ getTsDataForgeImport,
13
+ } from './import-utils.mjs';
14
+
15
+ type Options = readonly [];
16
+
17
+ type MessageIds = 'preferArrScanPrefix' | 'preferArrScanAccumulate';
18
+
19
+ /** Array methods that cut a prefix or a suffix out of the array they are called on. */
20
+ const SLICING_METHODS: ReadonlySet<string> = new Set(['slice', 'subarray']);
21
+
22
+ /** A `map` call whose callback ignores the element it is handed. */
23
+ type PrefixCandidate = Readonly<{
24
+ call: TSESTree.CallExpression;
25
+ /** Source text of the array being mapped, as written. */
26
+ arrayText: string;
27
+ body: TSESTree.Node;
28
+ /** Every place the callback reads its index parameter. */
29
+ indexReads: readonly TSESTree.Node[];
30
+ }>;
31
+
32
+ /** A `slice` / `subarray` call, wherever it appears. */
33
+ type SliceCall = Readonly<{
34
+ call: TSESTree.CallExpression;
35
+ /** Source text of the array being sliced, as written. */
36
+ arrayText: string;
37
+ }>;
38
+
39
+ /** A `reduce` call that spreads its accumulator into a new array. */
40
+ type AccumulationCandidate = Readonly<{
41
+ call: TSESTree.CallExpression;
42
+ /**
43
+ * What the call is replaced with, or `undefined` when the rewrite could not
44
+ * be derived and the report carries no fix.
45
+ */
46
+ replacement: string | undefined;
47
+ }>;
48
+
49
+ /**
50
+ * A `reduce` callback that appends one value to its accumulator, in either of
51
+ * the two spellings this repository's rules produce.
52
+ */
53
+ type Accumulation = Readonly<{
54
+ accumulatorParam: TSESTree.Identifier;
55
+ /** The reference to the accumulator that the rewrite drops. */
56
+ accumulatorRef: TSESTree.Node;
57
+ /** The value appended on this step. */
58
+ element: TSESTree.Expression;
59
+ }>;
60
+
61
+ /** One source range and the text that takes its place. */
62
+ type Splice = Readonly<{ range: FixedLengthTuple<2, number>; text: string }>;
63
+
64
+ /**
65
+ * Report an accumulated value that is rebuilt from scratch for every element,
66
+ * and point at `Arr.scan` from ts-data-forge.
67
+ *
68
+ * ## Why
69
+ *
70
+ * Two shapes compute what `Arr.scan` computes — a value carried from one
71
+ * element to the next — by re-deriving it from the whole prefix each time.
72
+ *
73
+ * ```ts
74
+ * // (1) the prefix rebuilt per element: `slice` allocates, and the combining
75
+ * // function runs over the whole prefix again. O(n²).
76
+ * segments.map((_, index) =>
77
+ * path.resolve(root, ...segments.slice(0, index + 1)),
78
+ * );
79
+ *
80
+ * // one pass — and `scan` puts the initial value at the head of its result,
81
+ * // which here is exactly "the root, then each ancestor below it"
82
+ * Arr.scan(
83
+ * segments,
84
+ * (ancestor, segment) => path.resolve(ancestor, segment),
85
+ * root,
86
+ * );
87
+ * ```
88
+ *
89
+ * ```ts
90
+ * // (2) the accumulator copied on every step, for the same O(n²)
91
+ * xs.reduce<readonly number[]>((acc, x) => [...acc, (acc.at(-1) ?? 0) + x], [0]);
92
+ *
93
+ * Arr.scan(xs, (total, x) => total + x, 0);
94
+ * ```
95
+ *
96
+ * The complexity is the smaller half of it. What the rebuilt form loses is the
97
+ * name of what is being accumulated: (1) says "for each index, combine
98
+ * everything up to it", and a reader has to turn that back into "carry a value
99
+ * along" before the code says what it does.
100
+ *
101
+ * ## Why only one of the two is fixed
102
+ *
103
+ * **(1) cannot be fixed at all**, and the obstacle is not difficulty. Turning
104
+ * `f(seed, ...xs.slice(0, i + 1))` into a `scan` is only valid if
105
+ * `f(seed, a, b, c) === f(f(f(seed, a), b), c)` — that the variadic callee is a
106
+ * left fold. That is a semantic property of the callee, invisible in the AST
107
+ * and in the type: `path.resolve` and `Math.max` have it, `Array.of` and
108
+ * `console.log` do not. Nothing static licenses the rewrite, so this shape is
109
+ * reported and left to a reader. The direction is a second obstacle —
110
+ * `slice(0, i + 1)` runs forwards while `slice(0, xs.length - i)` runs
111
+ * backwards and needs the result reversed — and the length a third, `scan`
112
+ * putting the initial value at the head where `map` has n elements.
113
+ *
114
+ * **(2) is fixed**, because none of those three apply to it: the combining
115
+ * function is the callback body itself, there is no slice and so no direction
116
+ * to infer, and the lengths already agree — a `reduce` seeded with `[init]`
117
+ * yields n+1 elements and so does `Arr.scan(xs, f, init)`.
118
+ *
119
+ * The fix is withheld unless every one of these holds, since each is what makes
120
+ * the two forms equal rather than merely similar:
121
+ *
122
+ * - the initial value is a one-element array literal `[E]`, so `scan`'s initial
123
+ * value is `E`;
124
+ * - the body is exactly `[...acc, ELEMENT]` — one spread of the accumulator and
125
+ * one new element, since `scan` appends exactly one value per step;
126
+ * - every other mention of the accumulator in `ELEMENT` is `acc.at(-1)`, and
127
+ * there is at least one. `scan` hands its callback the previous *value*, not
128
+ * the prefix, so `acc.length`, `acc[0]` or `acc.filter(...)` cannot be
129
+ * expressed and block the fix. `at(-1)` is the only spelling substituted:
130
+ * `Arr.last` returns an `Optional`, not the element, so it is not a
131
+ * substitution at all;
132
+ * - the parameters are plain identifiers with no type annotations — an
133
+ * annotation on the accumulator describes the array, and would be wrong on
134
+ * `scan`'s value parameter — and there are at most three of them, `reduce`'s
135
+ * fourth parameter (the whole array) having no counterpart in `scan`.
136
+ *
137
+ * `acc.at(-1)` is typed `T | undefined` while `scan`'s accumulator is `S`, so a
138
+ * `?? fallback` guarding it becomes redundant after the fix. It is left in
139
+ * place rather than removed: it evaluates identically either way, and deciding
140
+ * that it is dead needs to know that `S` excludes `undefined`, which is type
141
+ * information this rule does not take.
142
+ *
143
+ * ## What is not reported
144
+ *
145
+ * A `map` callback that reads its element parameter. Rebuilding a prefix *and*
146
+ * using the current element is not an accumulation spelled out the long way:
147
+ * `scan` hands its callback the accumulated value and the element, never the
148
+ * prefix, so such a callback is not one it can express.
149
+ *
150
+ * The array has to be spelled the same way in the `map` and in the `slice`.
151
+ * Deciding that two differently written expressions denote one array needs type
152
+ * information and would still be a guess about aliasing.
153
+ */
154
+ export const preferArrScan: TSESLint.RuleModule<MessageIds, Options> = {
155
+ meta: {
156
+ type: 'suggestion',
157
+ docs: {
158
+ description:
159
+ 'Report an accumulated value rebuilt from scratch for every element — `xs.map((_, i) => f(...xs.slice(0, i + 1)))`, or a `reduce` that spreads its accumulator into a new array — and point at `Arr.scan` from ts-data-forge, which does the same in one pass',
160
+ },
161
+ fixable: 'code',
162
+ schema: [],
163
+ messages: {
164
+ preferArrScanPrefix:
165
+ 'This rebuilds a prefix of `{{array}}` for every element, so the combining function runs over the whole prefix again each time. `Arr.scan` from ts-data-forge carries the value from one element to the next in a single pass, with the initial value at the head of the result.',
166
+ preferArrScanAccumulate:
167
+ 'Rebuilding the accumulator array on every step copies it each time. `Arr.scan` from ts-data-forge accumulates in one pass and returns the initial value followed by every intermediate one.',
168
+ },
169
+ },
170
+
171
+ create: (context) => {
172
+ const sourceCode = context.sourceCode;
173
+
174
+ const program = sourceCode.ast;
175
+
176
+ const tsDataForgeImport = getTsDataForgeImport(program);
177
+
178
+ const services = sourceCode.parserServices;
179
+
180
+ const checker = services?.program?.getTypeChecker();
181
+
182
+ /**
183
+ * Whether the value at `node` can be `null` or `undefined`. Answers `true`
184
+ * without type information, which is the conservative direction: it only
185
+ * ever keeps a `?? fallback` that could have been removed.
186
+ */
187
+ const isNullish = (
188
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
189
+ node: TSESTree.Node,
190
+ ): boolean => {
191
+ const tsNode = services?.esTreeNodeToTSNodeMap?.get(node);
192
+
193
+ if (checker === undefined || tsNode === undefined) return true;
194
+
195
+ const type = checker.getTypeAtLocation(tsNode);
196
+
197
+ return (type.isUnion() ? type.types : [type]).some(
198
+ (constituent) =>
199
+ (constituent.flags &
200
+ (ts.TypeFlags.Null |
201
+ ts.TypeFlags.Undefined |
202
+ ts.TypeFlags.Void |
203
+ ts.TypeFlags.Any |
204
+ ts.TypeFlags.Unknown)) !==
205
+ 0,
206
+ );
207
+ };
208
+
209
+ const mut_candidates: PrefixCandidate[] = [];
210
+
211
+ const mut_slices: SliceCall[] = [];
212
+
213
+ const mut_accumulations: AccumulationCandidate[] = [];
214
+
215
+ /**
216
+ * The text `xs.reduce(...)` is replaced with, or `undefined` when one of the
217
+ * conditions that make the two forms equal does not hold. See the rule
218
+ * documentation for what each of them rules out.
219
+ */
220
+ const scanReplacementFor = (
221
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
222
+ call: TSESTree.CallExpression,
223
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
224
+ callback: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
225
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
226
+ initial: TSESTree.Node,
227
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
228
+ accumulation: Accumulation,
229
+ ): string | undefined => {
230
+ if (callback.async || callback.generator) return undefined;
231
+
232
+ // `reduce`'s fourth parameter is the whole array, which `scan` never
233
+ // hands its callback; an annotation on the accumulator describes that
234
+ // array, and would be wrong on `scan`'s value parameter.
235
+ if (
236
+ callback.params.length < 2 ||
237
+ callback.params.length > 3 ||
238
+ callback.params.some(
239
+ (param) =>
240
+ param.type !== AST_NODE_TYPES.Identifier ||
241
+ param.typeAnnotation !== undefined,
242
+ )
243
+ ) {
244
+ return undefined;
245
+ }
246
+
247
+ const { accumulatorParam, accumulatorRef, element } = accumulation;
248
+
249
+ // `scan`'s initial value is the single element the reduce was seeded with.
250
+ if (
251
+ initial.type !== AST_NODE_TYPES.ArrayExpression ||
252
+ !Arr.isFixedLengthArray(1, initial.elements)
253
+ ) {
254
+ return undefined;
255
+ }
256
+
257
+ const [seed] = initial.elements;
258
+
259
+ if (seed === null || seed.type === AST_NODE_TYPES.SpreadElement) {
260
+ return undefined;
261
+ }
262
+
263
+ const scope = sourceCode.getScope(callback);
264
+
265
+ const accumulator = scope.variables.find(
266
+ (variable) => variable.name === accumulatorParam.name,
267
+ );
268
+
269
+ if (accumulator === undefined) return undefined;
270
+
271
+ // `acc.at(-1)` is `T | undefined` only because `at` can go out of bounds,
272
+ // which a seeded accumulator never does. So once the value `scan` carries
273
+ // is known not to be nullish, a `?? fallback` guarding the read is dead
274
+ // and the fix takes it with the read — leaving it would hand the author an
275
+ // unnecessary condition that no rule can fix for them.
276
+ const carriesNullish = isNullish(seed) || isNullish(element);
277
+
278
+ const mut_lastReads: Splice[] = [];
279
+
280
+ for (const reference of accumulator.references) {
281
+ const identifier = reference.identifier;
282
+
283
+ // The `...acc` / `Arr.toPushed(acc, …)` target the rewrite drops.
284
+ if (identifier === accumulatorRef) continue;
285
+
286
+ if (identifier.type !== AST_NODE_TYPES.Identifier) return undefined;
287
+
288
+ const lastRead = enclosingLastRead(identifier);
289
+
290
+ if (lastRead === undefined) return undefined;
291
+
292
+ const guard = carriesNullish ? undefined : nullishGuardAround(lastRead);
293
+
294
+ mut_lastReads.push({
295
+ range: (guard ?? lastRead).range,
296
+ text: accumulatorParam.name,
297
+ });
298
+ }
299
+
300
+ // Two reads inside one `?? ` — `acc.at(-1) ?? acc.at(-1)` — would give
301
+ // ranges that contain one another, which no ordering of splices resolves.
302
+ if (overlaps(mut_lastReads)) return undefined;
303
+
304
+ // With no read of the previous value this is a `map` with a seed rather
305
+ // than a scan, and the accumulator parameter would be left unused.
306
+ if (mut_lastReads.length === 0) return undefined;
307
+
308
+ const parameterText = callback.params
309
+ .map((param) => sourceCode.getText(param))
310
+ .join(', ');
311
+
312
+ const elementText = applySplices(
313
+ sourceCode.getText(element),
314
+ element.range[0],
315
+ mut_lastReads,
316
+ );
317
+
318
+ const arrayText = sourceCode.getText(
319
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
320
+ (call.callee as TSESTree.MemberExpression).object,
321
+ );
322
+
323
+ return `Arr.scan(${arrayText}, (${parameterText}) => ${elementText}, ${sourceCode.getText(seed)})`;
324
+ };
325
+
326
+ /**
327
+ * The places a `map` callback reads its index parameter, or `undefined`
328
+ * when the callback is not of the shape this rule looks for — the wrong
329
+ * arity, a destructured parameter, or an element parameter it actually
330
+ * reads.
331
+ */
332
+ const indexReadsOf = (
333
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
334
+ callback: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
335
+ ): readonly TSESTree.Node[] | undefined => {
336
+ if (!Arr.isFixedLengthArray(2, callback.params)) return undefined;
337
+
338
+ const [elementParam, indexParam] = callback.params;
339
+
340
+ if (
341
+ elementParam.type !== AST_NODE_TYPES.Identifier ||
342
+ indexParam.type !== AST_NODE_TYPES.Identifier
343
+ ) {
344
+ return undefined;
345
+ }
346
+
347
+ const scope = sourceCode.getScope(callback);
348
+
349
+ const elementVariable = scope.variables.find(
350
+ (variable) => variable.name === elementParam.name,
351
+ );
352
+
353
+ const indexVariable = scope.variables.find(
354
+ (variable) => variable.name === indexParam.name,
355
+ );
356
+
357
+ if (elementVariable === undefined || indexVariable === undefined) {
358
+ return undefined;
359
+ }
360
+
361
+ if (elementVariable.references.length > 0) return undefined;
362
+
363
+ return indexVariable.references.map(
364
+ (reference) => reference.identifier as TSESTree.Node,
365
+ );
366
+ };
367
+
368
+ return {
369
+ CallExpression: (node) => {
370
+ if (
371
+ node.callee.type !== AST_NODE_TYPES.MemberExpression ||
372
+ node.callee.computed ||
373
+ node.callee.property.type !== AST_NODE_TYPES.Identifier
374
+ ) {
375
+ return;
376
+ }
377
+
378
+ const methodName = node.callee.property.name;
379
+
380
+ const arrayText = sourceCode.getText(node.callee.object);
381
+
382
+ if (SLICING_METHODS.has(methodName)) {
383
+ mut_slices.push({ call: node, arrayText });
384
+ }
385
+
386
+ const [callback, ...rest] = node.arguments;
387
+
388
+ if (
389
+ callback === undefined ||
390
+ (callback.type !== AST_NODE_TYPES.ArrowFunctionExpression &&
391
+ callback.type !== AST_NODE_TYPES.FunctionExpression)
392
+ ) {
393
+ return;
394
+ }
395
+
396
+ switch (methodName) {
397
+ case 'map': {
398
+ if (rest.length > 0) break;
399
+
400
+ const indexReads = indexReadsOf(callback);
401
+
402
+ if (indexReads === undefined) break;
403
+
404
+ mut_candidates.push({
405
+ call: node,
406
+ arrayText,
407
+ body: callback.body,
408
+ indexReads,
409
+ });
410
+
411
+ break;
412
+ }
413
+
414
+ case 'reduce': {
415
+ // Two arguments, so that the initial value is explicit: a `reduce`
416
+ // without one starts from the first element and has no `scan`
417
+ // spelling at all.
418
+ if (rest.length !== 1 || !appendsToItsAccumulator(callback)) break;
419
+
420
+ const [initial] = rest;
421
+
422
+ const accumulation = accumulationOf(callback);
423
+
424
+ mut_accumulations.push({
425
+ call: node,
426
+ replacement:
427
+ initial === undefined || accumulation === undefined
428
+ ? undefined
429
+ : scanReplacementFor(node, callback, initial, accumulation),
430
+ });
431
+
432
+ break;
433
+ }
434
+
435
+ default:
436
+ break;
437
+ }
438
+ },
439
+
440
+ // Deferred to the end so that a `slice` nested inside the callback has
441
+ // been seen by the time its `map` is judged, whichever order the traversal
442
+ // reached them in.
443
+ 'Program:exit': () => {
444
+ const hasArrImport = getNamedImports(tsDataForgeImport).includes('Arr');
445
+
446
+ // The import is inserted once per pass: two fixes writing the same
447
+ // insertion point would otherwise produce it twice.
448
+ const mut_importNeededBy = hasArrImport
449
+ ? undefined
450
+ : mut_accumulations.find(
451
+ (accumulation) => accumulation.replacement !== undefined,
452
+ )?.call;
453
+
454
+ for (const accumulation of mut_accumulations) {
455
+ const { call, replacement } = accumulation;
456
+
457
+ context.report({
458
+ node: call,
459
+ messageId: 'preferArrScanAccumulate',
460
+ fix:
461
+ replacement === undefined
462
+ ? undefined
463
+ : (fixer) => [
464
+ ...(call === mut_importNeededBy
465
+ ? buildImportFixes(fixer, program, tsDataForgeImport, [
466
+ 'Arr',
467
+ ])
468
+ : []),
469
+ fixer.replaceText(call, replacement),
470
+ ],
471
+ });
472
+ }
473
+
474
+ for (const candidate of mut_candidates) {
475
+ const rebuild = mut_slices.find(
476
+ (slice) =>
477
+ slice.arrayText === candidate.arrayText &&
478
+ encloses(candidate.body, slice.call) &&
479
+ candidate.indexReads.some((read) =>
480
+ slice.call.arguments.some((argument) =>
481
+ encloses(argument, read),
482
+ ),
483
+ ),
484
+ );
485
+
486
+ if (rebuild === undefined) continue;
487
+
488
+ context.report({
489
+ node: candidate.call,
490
+ messageId: 'preferArrScanPrefix',
491
+ data: { array: candidate.arrayText },
492
+ });
493
+ }
494
+ },
495
+ };
496
+ },
497
+ defaultOptions: [],
498
+ } as const;
499
+
500
+ /**
501
+ * The `acc.at(-1)` call an accumulator reference sits at the head of, or
502
+ * `undefined` when the reference is used in any other way — which is what
503
+ * withholds the fix, `scan` having no prefix to offer.
504
+ */
505
+ const enclosingLastRead = (
506
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
507
+ identifier: TSESTree.Identifier,
508
+ ): TSESTree.CallExpression | undefined => {
509
+ const member = identifier.parent;
510
+
511
+ if (
512
+ member.type !== AST_NODE_TYPES.MemberExpression ||
513
+ member.object !== identifier ||
514
+ member.computed ||
515
+ member.property.type !== AST_NODE_TYPES.Identifier ||
516
+ member.property.name !== 'at'
517
+ ) {
518
+ return undefined;
519
+ }
520
+
521
+ const call = member.parent;
522
+
523
+ if (
524
+ call.type !== AST_NODE_TYPES.CallExpression ||
525
+ call.callee !== member ||
526
+ !Arr.isFixedLengthArray(1, call.arguments)
527
+ ) {
528
+ return undefined;
529
+ }
530
+
531
+ const [argument] = call.arguments;
532
+
533
+ // `at(-1)` only: any other index is not the previous value.
534
+ return argument.type === AST_NODE_TYPES.UnaryExpression &&
535
+ argument.operator === '-' &&
536
+ argument.argument.type === AST_NODE_TYPES.Literal &&
537
+ argument.argument.value === 1
538
+ ? call
539
+ : undefined;
540
+ };
541
+
542
+ /**
543
+ * The `X ?? fallback` that `read` is the left-hand side of, if it is one.
544
+ */
545
+ const nullishGuardAround = (
546
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
547
+ read: TSESTree.CallExpression,
548
+ ): TSESTree.LogicalExpression | undefined => {
549
+ const parent = read.parent;
550
+
551
+ return parent.type === AST_NODE_TYPES.LogicalExpression &&
552
+ parent.operator === '??' &&
553
+ parent.left === read
554
+ ? parent
555
+ : undefined;
556
+ };
557
+
558
+ /** Whether any two of the splices cover overlapping source. */
559
+ const overlaps = (splices: readonly Splice[]): boolean =>
560
+ splices
561
+ .toSorted((a, b) => a.range[0] - b.range[0])
562
+ .some(
563
+ (splice, index, sorted) =>
564
+ index > 0 && splice.range[0] < (sorted[index - 1]?.range[1] ?? 0),
565
+ );
566
+
567
+ /**
568
+ * `text`, which starts at `offset` in the file, with each splice applied.
569
+ * Right to left, so that an earlier replacement does not move a later range.
570
+ */
571
+ const applySplices = (
572
+ text: string,
573
+ offset: number,
574
+ splices: readonly Splice[],
575
+ ): string =>
576
+ splices
577
+ .toSorted((a, b) => b.range[0] - a.range[0])
578
+ .reduce(
579
+ (mut_text, splice) =>
580
+ `${mut_text.slice(0, splice.range[0] - offset)}${splice.text}${mut_text.slice(splice.range[1] - offset)}`,
581
+ text,
582
+ );
583
+
584
+ /**
585
+ * Whether `inner` lies inside `outer`. Ranges rather than parent pointers,
586
+ * because the two nodes are reached from different visitor calls.
587
+ */
588
+ const encloses = (
589
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
590
+ outer: TSESTree.Node,
591
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
592
+ inner: TSESTree.Node,
593
+ ): boolean =>
594
+ outer.range[0] <= inner.range[0] && inner.range[1] <= outer.range[1];
595
+
596
+ /**
597
+ * Whether the callback's body appends to its own accumulator at all — the
598
+ * O(n²) shape this rule reports, in either spelling and whatever it appends.
599
+ * Deliberately looser than {@link accumulationOf}: appending two values per
600
+ * step is still worth reporting, it is only the rewrite that cannot express it.
601
+ */
602
+ const appendsToItsAccumulator = (
603
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
604
+ callback: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
605
+ ): boolean => {
606
+ const [accumulatorParam] = callback.params;
607
+
608
+ if (accumulatorParam?.type !== AST_NODE_TYPES.Identifier) return false;
609
+
610
+ const { body } = callback;
611
+
612
+ const spelling =
613
+ body.type === AST_NODE_TYPES.ArrayExpression
614
+ ? body.elements[0]
615
+ : body.type === AST_NODE_TYPES.CallExpression &&
616
+ body.callee.type === AST_NODE_TYPES.MemberExpression &&
617
+ !body.callee.computed &&
618
+ body.callee.property.type === AST_NODE_TYPES.Identifier &&
619
+ body.callee.property.name === 'toPushed'
620
+ ? body.arguments[0]
621
+ : undefined;
622
+
623
+ const target =
624
+ spelling?.type === AST_NODE_TYPES.SpreadElement
625
+ ? spelling.argument
626
+ : spelling;
627
+
628
+ return (
629
+ target?.type === AST_NODE_TYPES.Identifier &&
630
+ target.name === accumulatorParam.name
631
+ );
632
+ };
633
+
634
+ /**
635
+ * The append this `reduce` callback performs, or `undefined` when its body is
636
+ * not one. Two spellings mean the same thing here, and both have to be
637
+ * recognized: `[...acc, x]` as written, and `Arr.toPushed(acc, x)`, which is
638
+ * what `ts-data-forge/prefer-canonical-array-slicing` rewrites the first into.
639
+ * Seeing only the first would leave this rule blind after a single `--fix`
640
+ * pass, with the O(n²) accumulation still there under the other spelling.
641
+ */
642
+ const accumulationOf = (
643
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
644
+ callback: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
645
+ ): Accumulation | undefined => {
646
+ const [accumulatorParam] = callback.params;
647
+
648
+ if (accumulatorParam?.type !== AST_NODE_TYPES.Identifier) return undefined;
649
+
650
+ const { body } = callback;
651
+
652
+ // `[...acc, ELEMENT]` — exactly one spread and one new value, since `scan`
653
+ // appends exactly one per step.
654
+ if (body.type === AST_NODE_TYPES.ArrayExpression) {
655
+ if (!Arr.isFixedLengthArray(2, body.elements)) return undefined;
656
+
657
+ const [spread, element] = body.elements;
658
+
659
+ return element === null ||
660
+ element.type === AST_NODE_TYPES.SpreadElement ||
661
+ spread?.type !== AST_NODE_TYPES.SpreadElement ||
662
+ spread.argument.type !== AST_NODE_TYPES.Identifier ||
663
+ spread.argument.name !== accumulatorParam.name
664
+ ? undefined
665
+ : { accumulatorParam, accumulatorRef: spread.argument, element };
666
+ }
667
+
668
+ // `Arr.toPushed(acc, ELEMENT)`, matched on the method name as the rest of
669
+ // this rule matches `slice` and `at`.
670
+ if (body.type === AST_NODE_TYPES.CallExpression) {
671
+ const { callee } = body;
672
+
673
+ if (
674
+ callee.type !== AST_NODE_TYPES.MemberExpression ||
675
+ callee.computed ||
676
+ callee.property.type !== AST_NODE_TYPES.Identifier ||
677
+ callee.property.name !== 'toPushed' ||
678
+ !Arr.isFixedLengthArray(2, body.arguments)
679
+ ) {
680
+ return undefined;
681
+ }
682
+
683
+ const [target, element] = body.arguments;
684
+
685
+ return element.type === AST_NODE_TYPES.SpreadElement ||
686
+ target.type !== AST_NODE_TYPES.Identifier ||
687
+ target.name !== accumulatorParam.name
688
+ ? undefined
689
+ : { accumulatorParam, accumulatorRef: target, element };
690
+ }
691
+
692
+ return undefined;
693
+ };