-
Notifications
You must be signed in to change notification settings - Fork 2
feat!: add no-reduce-accumulator-copy rule #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
14d1c89
feat: add no-reduce-accumulator-copy rule
davidding f4f0d30
refactor: extract hs-web-team plugin, named exports, drop slice, expa…
davidding c5e014f
refactor: expose hsWebTeamPlugin.configs.recommended, remove duplicat…
davidding ede91b4
refactor: co-locate plugin rules under plugins/hs-web-team/
davidding 240b84a
docs: add custom-rules usage guide and files field to package.json
davidding File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,3 +22,4 @@ jobs: | |
| node-version: ${{ matrix.node }} | ||
| - run: npm install | ||
| - run: npm run lint | ||
| - run: npm test | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import wtConfig from './index.js'; | ||
|
|
||
| export default [ | ||
| ...wtConfig, | ||
| // Cypress utility files use globals (Cypress, cy) injected by the Cypress | ||
| // runtime — declare them so no-undef doesn't false-positive on this directory. | ||
| { | ||
| files: ['cypress/**'], | ||
| languageOptions: { | ||
| globals: { | ||
| Cypress: 'readonly', | ||
| cy: 'readonly', | ||
| }, | ||
| }, | ||
| }, | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| # Custom Rules (`hs-web-team` plugin) | ||
|
|
||
| This package bundles a small set of custom ESLint rules under the `hs-web-team` plugin. They are active automatically when you spread `wtConfig` or `wtBrowserConfig` — no extra configuration required. | ||
|
|
||
| --- | ||
|
|
||
| ## `hs-web-team/no-reduce-accumulator-copy` ❌ error | ||
|
|
||
| **Disallows O(n²) accumulator copies inside `reduce`/`reduceRight` callbacks.** | ||
|
|
||
| Each iteration of `reduce` creates an entirely new object or array, making the total work proportional to n². This is rarely intentional and degrades quickly as the input grows. | ||
|
|
||
| ### What triggers it | ||
|
|
||
| ```js | ||
| // ❌ Object spread — copies every key on every iteration | ||
| const byId = items.reduce((acc, item) => ({ ...acc, [item.id]: item }), {}); | ||
|
|
||
| // ❌ Array spread — copies every element on every iteration | ||
| const doubled = items.reduce((acc, n) => [...acc, n * 2], []); | ||
|
|
||
| // ❌ acc.concat() — same problem, different syntax | ||
| const flat = items.reduce((acc, arr) => acc.concat(arr), []); | ||
| ``` | ||
|
|
||
| ### What to do instead | ||
|
|
||
| ```js | ||
| // ✅ Object.groupBy / Object.fromEntries for grouping/indexing | ||
| const byId = Object.fromEntries(items.map(item => [item.id, item])); | ||
|
|
||
| // ✅ for...of with in-place mutation for accumulation | ||
| const byId = {}; | ||
| for (const item of items) { | ||
| byId[item.id] = item; | ||
| } | ||
|
|
||
| // ✅ flatMap for flat-map patterns | ||
| const doubled = items.flatMap(n => [n * 2]); | ||
|
|
||
| // ✅ flat() for flattening | ||
| const flat = items.flat(); | ||
| ``` | ||
|
|
||
| ### When `reduce` is fine | ||
|
|
||
| The rule only fires when the **accumulator itself** is copied. Reads from the accumulator, or spreading non-accumulator values into it, are allowed: | ||
|
|
||
| ```js | ||
| // ✅ Spreading a non-accumulator value into the result | ||
| const merged = items.reduce((acc, item) => ({ ...acc, ...item.overrides }), base); | ||
| // ^^^^^^^^^^^^^^ not the accumulator — fine | ||
|
|
||
| // ✅ Accumulator mutation with no copy | ||
| const counts = items.reduce((acc, item) => { | ||
| acc[item.type] = (acc[item.type] ?? 0) + 1; | ||
| return acc; | ||
| }, {}); | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { noReduceAccumulatorCopy } from './rules/no-reduce-accumulator-copy.js'; | ||
|
|
||
| export const hsWebTeamPlugin = { | ||
| rules: { | ||
| 'no-reduce-accumulator-copy': noReduceAccumulatorCopy, | ||
| }, | ||
| }; | ||
|
|
||
| // Self-reference allows the plugin to register itself via configs.recommended, | ||
| // following the same pattern as typescript-eslint and eslint-plugin-react. | ||
| hsWebTeamPlugin.configs = { | ||
| recommended: { | ||
| plugins: { 'hs-web-team': hsWebTeamPlugin }, | ||
| rules: { | ||
| 'hs-web-team/no-reduce-accumulator-copy': 'error', | ||
| }, | ||
| }, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| const RECOMMENDATION = | ||
| 'Prefer a for...of loop with in-place mutation, or a purpose-built method (flatMap, Object.groupBy, flat) if it fits your pattern.'; | ||
|
|
||
| function isReduceCall(node) { | ||
| if (!node) return false; | ||
|
|
||
| const callee = node.callee; | ||
| if (!callee || !callee.property || !callee.property.name) { | ||
| return false; | ||
| } | ||
|
|
||
| return ( | ||
| callee.property.name === 'reduce' || callee.property.name === 'reduceRight' | ||
| ); | ||
| } | ||
|
|
||
| function isReduceAccumulator(def) { | ||
| return ( | ||
| def && | ||
| def.type === 'Parameter' && | ||
| def.node.parent && | ||
| isReduceCall(def.node.parent) && | ||
| def.node.params && | ||
| def.node.params.length && | ||
| // def.name is the Identifier AST node for the definition; this identity | ||
| // check confirms it is the first parameter (the accumulator), not a later one. | ||
| def.name === def.node.params[0] | ||
| ); | ||
| } | ||
|
|
||
| export const noReduceAccumulatorCopy = { | ||
| meta: { | ||
| type: 'problem', | ||
| docs: { | ||
| description: | ||
| 'Disallow O(n²) accumulator copies (spread, concat) inside reduce/reduceRight callbacks', | ||
| recommended: true, | ||
| }, | ||
| messages: { | ||
| AccumulatorSpread: `Spreading the reduce accumulator is O(n²). ${RECOMMENDATION}`, | ||
| AccumulatorShallowCopy: `Calling .{{ method }}() on the reduce accumulator is O(n²). ${RECOMMENDATION}`, | ||
| }, | ||
| schema: [], | ||
| }, | ||
|
|
||
| create(context) { | ||
| const { sourceCode } = context; | ||
|
|
||
| function checkIsAccumulatorSpread(node) { | ||
| if (!node.argument || node.argument.type !== 'Identifier') return; | ||
|
|
||
| const scope = sourceCode.getScope(node); | ||
| const spreadee = scope.set.get(node.argument.name); | ||
| const spreadeeDef = | ||
| spreadee && spreadee.defs && spreadee.defs.length && spreadee.defs[0]; | ||
|
|
||
| if (isReduceAccumulator(spreadeeDef)) { | ||
| context.report({ node, messageId: 'AccumulatorSpread' }); | ||
| } | ||
| } | ||
|
|
||
| function checkIsAccumulatorShallowCopy(node) { | ||
| if ( | ||
| node.callee.type !== 'MemberExpression' || | ||
| !node.callee.property || | ||
| !node.callee.object || | ||
| node.callee.object.type !== 'Identifier' | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| const method = node.callee.property.name; | ||
| if (method !== 'concat') return; | ||
|
|
||
| const scope = sourceCode.getScope(node); | ||
| const variable = scope.set.get(node.callee.object.name); | ||
| const definition = | ||
| variable && variable.defs && variable.defs.length && variable.defs[0]; | ||
|
|
||
| if (isReduceAccumulator(definition)) { | ||
| context.report({ node, messageId: 'AccumulatorShallowCopy', data: { method } }); | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| SpreadElement: checkIsAccumulatorSpread, | ||
| CallExpression: checkIsAccumulatorShallowCopy, | ||
| }; | ||
| }, | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.