diff --git a/README.md b/README.md index 7293e13..546ed78 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ export default defineConfig({ ### Generic rules - `no-array-filter-map` — rejects adjacent eager array filter/map passes while allowing lazy iterator pipelines. -- `no-reduce-accumulator-copy` — rejects non-spread accumulator copies inside reducers; complements native `oxc/no-accumulating-spread`. +- `no-reduce-accumulator-copy` — rejects non-spread accumulator copies inside reducers; complements native `oxc/no-accumulating-spread`. By default, this uses the `reduce`/`reduceRight` method name as a heuristic, including unknown and custom receivers. Configure `["error", { "requireArrayReceiver": true }]` to report only receivers with local array evidence (literals, array annotations, or recognized array chains), avoiding warnings on custom one-call APIs. - `no-chained-type-assertions` — rejects nested `as` and angle-bracket assertions that fabricate evidence; chains made only of `as const` remain valid. - `no-conditional-empty-object-spread` — reports object spreads that use a conditional `{}` branch to omit fields. It intentionally has no autofix because omission is not equivalent to assigning `undefined`. - `no-known-value-widening` — rejects known expressions flowing into explicit `unknown`, `object`, anonymous-object, or open-dictionary targets, including known arguments passed to local `unknown` type predicates. Empty dictionary accumulators and finite-key `Record` targets remain valid. diff --git a/skills/install-anti-slop/assets/anti-slop/rules/no-reduce-accumulator-copy.ts b/skills/install-anti-slop/assets/anti-slop/rules/no-reduce-accumulator-copy.ts index 47d8e2c..1bff1df 100644 --- a/skills/install-anti-slop/assets/anti-slop/rules/no-reduce-accumulator-copy.ts +++ b/skills/install-anti-slop/assets/anti-slop/rules/no-reduce-accumulator-copy.ts @@ -27,7 +27,7 @@ function enclosingReducer(node: ESTree.Node) { const firstParameter = callback.params[0]; const accumulator = firstParameter?.type === "AssignmentPattern" ? firstParameter.left : firstParameter; if (accumulator?.type !== "Identifier") return null; - return { callback, accumulator, initialValue: owner.arguments[1] }; + return { callback, accumulator, receiver: method.object, initialValue: owner.arguments[1] }; } parent = parent.parent; } @@ -72,6 +72,12 @@ export const noReduceAccumulatorCopyRule = defineRule({ messages: { accumulatorCopy: "Do not copy the reducer accumulator on every iteration; growing copies can cause quadratic work. Mutate a fresh, locally owned accumulator and return it, or use an iterator pipeline/flatMap.", }, + schema: [{ + type: "object", + properties: { requireArrayReceiver: { type: "boolean" } }, + additionalProperties: false, + }], + defaultOptions: [{ requireArrayReceiver: false }], }, createOnce(context) { return { @@ -80,6 +86,12 @@ export const noReduceAccumulatorCopyRule = defineRule({ if (method === null) return; const reducer = enclosingReducer(node); if (reducer === null) return; + const option = context.options?.[0]; + if ( + typeof option === "object" && option !== null && !Array.isArray(option) && + option.requireArrayReceiver === true && + !isKnownArrayExpression(context.sourceCode, reducer.receiver) + ) return; const accumulator = context.sourceCode.getDeclaredVariables(reducer.callback).find(variable => variable.identifiers.some(identifier => identifier.start === reducer.accumulator.start), ); diff --git a/src/rules/no-reduce-accumulator-copy.test.ts b/src/rules/no-reduce-accumulator-copy.test.ts index 48f8944..5451133 100644 --- a/src/rules/no-reduce-accumulator-copy.test.ts +++ b/src/rules/no-reduce-accumulator-copy.test.ts @@ -26,6 +26,22 @@ tester.run("anti-slop/no-reduce-accumulator-copy", noReduceAccumulatorCopyRule, "items.reduce((acc, item) => { let alias = acc; alias = item; return Object.assign({}, alias); }, {});", "items.reduce((acc, item) => [...acc, item], []);", // Owned by the native rule. "items.reduce((acc, item) => ({ ...acc, [item.id]: item }), {});", + { + code: "const once = { reduce(fn, initial) { return fn(initial, { id: 1 }); } }; once.reduce((acc, item) => Object.assign({}, acc, item), {});", + options: [{ requireArrayReceiver: true }], + }, + { + code: "items.reduce((acc, item) => Object.assign({}, acc, item), {});", + options: [{ requireArrayReceiver: true }], + }, + { + code: "const custom = { reduceRight(fn, initial) { return fn(initial); } }; custom.reduceRight(acc => Array.from(acc), []);", + options: [{ requireArrayReceiver: true }], + }, + { + code: "const items = [1, 2]; items.reduce((acc, item) => { acc.push(item); return acc; }, []);", + options: [{ requireArrayReceiver: true }], + }, ], invalid: [ { code: "items.reduce((acc, item) => Object.assign({}, acc, { [item.id]: item }), {});", errors: [error] }, @@ -48,5 +64,17 @@ tester.run("anti-slop/no-reduce-accumulator-copy", noReduceAccumulatorCopyRule, { code: "items.reduce((acc, item) => acc.toSorted(), []);", errors: [error] }, { code: "items.reduce((acc, item) => acc.toReversed(), []);", errors: [error] }, { code: "items.reduce((acc, item) => acc.with(0, item), []);", errors: [error] }, + { + code: "[1, 2].reduce((acc, item) => Object.assign({}, acc, { [item]: item }), {});", + options: [{ requireArrayReceiver: true }], errors: [error], + }, + { + code: "const source = [1, 2]; const alias = source; alias.reduceRight((acc, item) => Array.from(acc), []);", + options: [{ requireArrayReceiver: true }], errors: [error], + }, + { + code: "function run(items: readonly string[]) { return items.filter(Boolean).reduce((acc, item) => acc.concat(item), []); }", + options: [{ requireArrayReceiver: true }], errors: [error], + }, ], }); diff --git a/src/rules/no-reduce-accumulator-copy.ts b/src/rules/no-reduce-accumulator-copy.ts index 47d8e2c..1bff1df 100644 --- a/src/rules/no-reduce-accumulator-copy.ts +++ b/src/rules/no-reduce-accumulator-copy.ts @@ -27,7 +27,7 @@ function enclosingReducer(node: ESTree.Node) { const firstParameter = callback.params[0]; const accumulator = firstParameter?.type === "AssignmentPattern" ? firstParameter.left : firstParameter; if (accumulator?.type !== "Identifier") return null; - return { callback, accumulator, initialValue: owner.arguments[1] }; + return { callback, accumulator, receiver: method.object, initialValue: owner.arguments[1] }; } parent = parent.parent; } @@ -72,6 +72,12 @@ export const noReduceAccumulatorCopyRule = defineRule({ messages: { accumulatorCopy: "Do not copy the reducer accumulator on every iteration; growing copies can cause quadratic work. Mutate a fresh, locally owned accumulator and return it, or use an iterator pipeline/flatMap.", }, + schema: [{ + type: "object", + properties: { requireArrayReceiver: { type: "boolean" } }, + additionalProperties: false, + }], + defaultOptions: [{ requireArrayReceiver: false }], }, createOnce(context) { return { @@ -80,6 +86,12 @@ export const noReduceAccumulatorCopyRule = defineRule({ if (method === null) return; const reducer = enclosingReducer(node); if (reducer === null) return; + const option = context.options?.[0]; + if ( + typeof option === "object" && option !== null && !Array.isArray(option) && + option.requireArrayReceiver === true && + !isKnownArrayExpression(context.sourceCode, reducer.receiver) + ) return; const accumulator = context.sourceCode.getDeclaredVariables(reducer.callback).find(variable => variable.identifiers.some(identifier => identifier.start === reducer.accumulator.start), );