eslint-plugin-big-o 1.0.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.
- package/README.md +143 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +25 -0
- package/dist/rules/no-array-lookup-in-loop.d.ts +3 -0
- package/dist/rules/no-array-lookup-in-loop.js +53 -0
- package/dist/rules/no-nested-array-spread.d.ts +3 -0
- package/dist/rules/no-nested-array-spread.js +74 -0
- package/dist/rules/no-quadratic-dedup.d.ts +3 -0
- package/dist/rules/no-quadratic-dedup.js +71 -0
- package/package.json +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# eslint-plugin-big-o
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/eslint-plugin-big-o)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
ESLint rules that catch accidental O(n²) patterns before they reach production.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- ESLint ≥ 9.0.0 (flat config)
|
|
11
|
+
- Node.js ≥ 24.0.0
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pnpm add -D eslint-plugin-big-o
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
Add to your `eslint.config.js` (or `.ts`):
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import bigO from 'eslint-plugin-big-o'
|
|
25
|
+
|
|
26
|
+
export default [
|
|
27
|
+
bigO.configs.recommended,
|
|
28
|
+
]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or configure rules individually:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
import bigO from 'eslint-plugin-big-o'
|
|
35
|
+
|
|
36
|
+
export default [
|
|
37
|
+
{
|
|
38
|
+
plugins: { 'big-o': bigO },
|
|
39
|
+
rules: {
|
|
40
|
+
'big-o/no-array-lookup-in-loop': 'warn',
|
|
41
|
+
'big-o/no-quadratic-dedup': 'warn',
|
|
42
|
+
'big-o/no-nested-array-spread': 'warn',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Rules
|
|
49
|
+
|
|
50
|
+
| Rule | Description | Complexity |
|
|
51
|
+
|------|-------------|------------|
|
|
52
|
+
| [`no-array-lookup-in-loop`](#no-array-lookup-in-loop) | Disallow linear search inside iteration callbacks | O(n) → O(1) |
|
|
53
|
+
| [`no-quadratic-dedup`](#no-quadratic-dedup) | Disallow filter+findIndex deduplication pattern | O(n²) → O(n) |
|
|
54
|
+
| [`no-nested-array-spread`](#no-nested-array-spread) | Disallow spreading recursive results inside reduce | O(n²) → O(n) |
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
### `no-array-lookup-in-loop`
|
|
59
|
+
|
|
60
|
+
Warns when `.find()`, `.findIndex()`, `.includes()`, or `.indexOf()` is called inside an iteration callback (`.map()`, `.filter()`, `.reduce()`, `.forEach()`, `.flatMap()`, `.some()`, `.every()`). Each outer iteration triggers a full inner scan — O(n²) total.
|
|
61
|
+
|
|
62
|
+
**Bad**
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
const result = items.map(item =>
|
|
66
|
+
other.find(o => o.id === item.id) // O(n²)
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Good**
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
const otherMap = new Map(other.map(o => [o.id, o]))
|
|
74
|
+
const result = items.map(item => otherMap.get(item.id)) // O(n)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
### `no-quadratic-dedup`
|
|
80
|
+
|
|
81
|
+
Warns on the common but quadratic deduplication idiom:
|
|
82
|
+
`arr.filter((item, i) => arr.findIndex(x => ...) === i)`
|
|
83
|
+
|
|
84
|
+
Each `.filter` iteration calls `.findIndex`, which scans from the start — O(n²).
|
|
85
|
+
|
|
86
|
+
**Bad**
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
const unique = arr.filter((item, i) =>
|
|
90
|
+
arr.findIndex(x => x.id === item.id) === i // O(n²)
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Good**
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
const seen = new Set()
|
|
98
|
+
const unique = arr.filter(item => {
|
|
99
|
+
if (seen.has(item.id)) return false
|
|
100
|
+
seen.add(item.id)
|
|
101
|
+
return true
|
|
102
|
+
}) // O(n)
|
|
103
|
+
|
|
104
|
+
// or simply:
|
|
105
|
+
const unique = [...new Map(arr.map(x => [x.id, x])).values()] // O(n)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
### `no-nested-array-spread`
|
|
111
|
+
|
|
112
|
+
Warns when a recursive function call is spread inside a `.reduce()` callback. Each recursive call creates a new array copy — O(n²) total allocations.
|
|
113
|
+
|
|
114
|
+
**Bad**
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
function flatten(arr) {
|
|
118
|
+
return arr.reduce((acc, item) =>
|
|
119
|
+
Array.isArray(item)
|
|
120
|
+
? [...acc, ...flatten(item)] // O(n²) — new array each iteration
|
|
121
|
+
: [...acc, item],
|
|
122
|
+
[]
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Good**
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
function flatten(arr) {
|
|
131
|
+
return arr.reduce((acc, item) => {
|
|
132
|
+
if (Array.isArray(item)) acc.push(...flatten(item))
|
|
133
|
+
else acc.push(item)
|
|
134
|
+
return acc // mutate in-place — O(n)
|
|
135
|
+
}, [])
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[ISC](LICENSE) © Adrian Elton-Browning
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import noArrayLookupInLoop from "./rules/no-array-lookup-in-loop.js";
|
|
2
|
+
import noQuadraticDedup from "./rules/no-quadratic-dedup.js";
|
|
3
|
+
import noNestedArraySpread from "./rules/no-nested-array-spread.js";
|
|
4
|
+
const plugin = {
|
|
5
|
+
meta: {
|
|
6
|
+
name: 'eslint-plugin-big-o',
|
|
7
|
+
},
|
|
8
|
+
rules: {
|
|
9
|
+
'no-array-lookup-in-loop': noArrayLookupInLoop,
|
|
10
|
+
'no-quadratic-dedup': noQuadraticDedup,
|
|
11
|
+
'no-nested-array-spread': noNestedArraySpread,
|
|
12
|
+
},
|
|
13
|
+
configs: {},
|
|
14
|
+
};
|
|
15
|
+
plugin.configs = {
|
|
16
|
+
recommended: {
|
|
17
|
+
plugins: { 'big-o': plugin },
|
|
18
|
+
rules: {
|
|
19
|
+
'big-o/no-array-lookup-in-loop': 'warn',
|
|
20
|
+
'big-o/no-quadratic-dedup': 'warn',
|
|
21
|
+
'big-o/no-nested-array-spread': 'warn',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export default plugin;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const LOOP_METHODS = new Set(['map', 'filter', 'reduce', 'forEach', 'flatMap', 'some', 'every']);
|
|
2
|
+
const LOOKUP_METHODS = new Set(['find', 'findIndex', 'includes', 'indexOf']);
|
|
3
|
+
const rule = {
|
|
4
|
+
meta: {
|
|
5
|
+
type: 'suggestion',
|
|
6
|
+
schema: [],
|
|
7
|
+
messages: {
|
|
8
|
+
arrayLookupInLoop: 'O(n²): .{{method}}() inside .{{outer}}() callback. Use a Map or Set for O(1) lookup.',
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
create(context) {
|
|
12
|
+
return {
|
|
13
|
+
CallExpression(node) {
|
|
14
|
+
if (node.callee.type !== 'MemberExpression' ||
|
|
15
|
+
node.callee.property.type !== 'Identifier' ||
|
|
16
|
+
!LOOKUP_METHODS.has(node.callee.property.name))
|
|
17
|
+
return;
|
|
18
|
+
const method = node.callee.property.name;
|
|
19
|
+
const ancestors = context.sourceCode.getAncestors(node);
|
|
20
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
21
|
+
const ancestor = ancestors[i];
|
|
22
|
+
if (!ancestor)
|
|
23
|
+
continue;
|
|
24
|
+
if (ancestor.type === 'CallExpression' &&
|
|
25
|
+
ancestor.callee.type === 'MemberExpression' &&
|
|
26
|
+
ancestor.callee.property.type === 'Identifier' &&
|
|
27
|
+
LOOP_METHODS.has(ancestor.callee.property.name)) {
|
|
28
|
+
const loopMethod = ancestor.callee.property.name;
|
|
29
|
+
const callback = ancestor.arguments[0];
|
|
30
|
+
if (callback && isDescendant(node, callback)) {
|
|
31
|
+
context.report({
|
|
32
|
+
node,
|
|
33
|
+
messageId: 'arrayLookupInLoop',
|
|
34
|
+
data: { method, outer: loopMethod },
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
export default rule;
|
|
45
|
+
function isDescendant(node, ancestor) {
|
|
46
|
+
let current = node;
|
|
47
|
+
while (current) {
|
|
48
|
+
if (current === ancestor)
|
|
49
|
+
return true;
|
|
50
|
+
current = current.parent;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const rule = {
|
|
2
|
+
meta: {
|
|
3
|
+
type: 'suggestion',
|
|
4
|
+
schema: [],
|
|
5
|
+
messages: {
|
|
6
|
+
nestedArraySpread: 'O(n²): spreading a recursive call result inside reduce creates quadratic copies. Pass accumulator by reference instead.',
|
|
7
|
+
},
|
|
8
|
+
},
|
|
9
|
+
create(context) {
|
|
10
|
+
return {
|
|
11
|
+
SpreadElement(node) {
|
|
12
|
+
if (node.argument.type !== 'CallExpression')
|
|
13
|
+
return;
|
|
14
|
+
const call = node.argument;
|
|
15
|
+
const calleeName = getCalleeName(call);
|
|
16
|
+
if (!calleeName)
|
|
17
|
+
return;
|
|
18
|
+
if (!isInsideReduceCallback(node))
|
|
19
|
+
return;
|
|
20
|
+
if (isRecursiveCall(node, calleeName)) {
|
|
21
|
+
context.report({ node, messageId: 'nestedArraySpread' });
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
export default rule;
|
|
28
|
+
function getCalleeName(call) {
|
|
29
|
+
if (call.callee.type === 'Identifier')
|
|
30
|
+
return call.callee.name;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function isInsideReduceCallback(node) {
|
|
34
|
+
let current = node.parent;
|
|
35
|
+
while (current) {
|
|
36
|
+
if (current.type === 'CallExpression' &&
|
|
37
|
+
current.callee.type === 'MemberExpression' &&
|
|
38
|
+
current.callee.property.type === 'Identifier' &&
|
|
39
|
+
current.callee.property.name === 'reduce') {
|
|
40
|
+
const callback = current.arguments[0];
|
|
41
|
+
if (callback && isAncestor(callback, node))
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
current = current.parent;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
function isAncestor(ancestor, node) {
|
|
49
|
+
let current = node;
|
|
50
|
+
while (current) {
|
|
51
|
+
if (current === ancestor)
|
|
52
|
+
return true;
|
|
53
|
+
current = current.parent;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
function isRecursiveCall(node, calleeName) {
|
|
58
|
+
let current = node.parent;
|
|
59
|
+
while (current) {
|
|
60
|
+
if ((current.type === 'FunctionDeclaration' || current.type === 'FunctionExpression') &&
|
|
61
|
+
current.id?.name === calleeName) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
// arrow function: const name = (...) => ...
|
|
65
|
+
if (current.type === 'ArrowFunctionExpression' &&
|
|
66
|
+
current.parent?.type === 'VariableDeclarator' &&
|
|
67
|
+
current.parent.id?.type === 'Identifier' &&
|
|
68
|
+
current.parent.id.name === calleeName) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
current = current.parent;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const rule = {
|
|
2
|
+
meta: {
|
|
3
|
+
type: 'suggestion',
|
|
4
|
+
schema: [],
|
|
5
|
+
messages: {
|
|
6
|
+
quadraticDedup: 'O(n²): arr.filter((item, i) => arr.findIndex(...) === i) is quadratic. Use a Map or Set for O(n) dedup.',
|
|
7
|
+
},
|
|
8
|
+
},
|
|
9
|
+
create(context) {
|
|
10
|
+
return {
|
|
11
|
+
'CallExpression[callee.property.name="filter"]'(node) {
|
|
12
|
+
const callback = node.arguments[0];
|
|
13
|
+
if (!callback)
|
|
14
|
+
return;
|
|
15
|
+
const params = 'params' in callback ? callback.params : [];
|
|
16
|
+
if (!params || params.length < 2)
|
|
17
|
+
return;
|
|
18
|
+
const indexParam = params[1];
|
|
19
|
+
if (!indexParam || indexParam.type !== 'Identifier')
|
|
20
|
+
return;
|
|
21
|
+
const indexName = indexParam.name;
|
|
22
|
+
const body = ('body' in callback ? callback.body : null);
|
|
23
|
+
findInNode(body, (n) => {
|
|
24
|
+
if (n.type === 'BinaryExpression' &&
|
|
25
|
+
n.operator === '===' &&
|
|
26
|
+
((isFindIndexCall(n.left) && isIdent(n.right, indexName)) ||
|
|
27
|
+
(isFindIndexCall(n.right) && isIdent(n.left, indexName)))) {
|
|
28
|
+
context.report({ node, messageId: 'quadraticDedup' });
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
export default rule;
|
|
38
|
+
function isFindIndexCall(node) {
|
|
39
|
+
return (node.type === 'CallExpression' &&
|
|
40
|
+
node.callee.type === 'MemberExpression' &&
|
|
41
|
+
node.callee.property.type === 'Identifier' &&
|
|
42
|
+
node.callee.property.name === 'findIndex');
|
|
43
|
+
}
|
|
44
|
+
function isIdent(node, name) {
|
|
45
|
+
return node.type === 'Identifier' && node.name === name;
|
|
46
|
+
}
|
|
47
|
+
function findInNode(node, visitor) {
|
|
48
|
+
if (!node || typeof node !== 'object')
|
|
49
|
+
return false;
|
|
50
|
+
if (Array.isArray(node)) {
|
|
51
|
+
for (const child of node) {
|
|
52
|
+
if (findInNode(child, visitor))
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (!('type' in node))
|
|
58
|
+
return false;
|
|
59
|
+
if (visitor(node))
|
|
60
|
+
return true;
|
|
61
|
+
for (const key of Object.keys(node)) {
|
|
62
|
+
if (key === 'parent')
|
|
63
|
+
continue;
|
|
64
|
+
const child = node[key];
|
|
65
|
+
if (child && typeof child === 'object') {
|
|
66
|
+
if (findInNode(child, visitor))
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eslint-plugin-big-o",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "ESLint rules for detecting algorithmic complexity issues (Big-O)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"eslint",
|
|
7
|
+
"eslint-plugin",
|
|
8
|
+
"big-o",
|
|
9
|
+
"complexity",
|
|
10
|
+
"performance"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/adrianbrowning/eslint-plugin-big-o.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/adrianbrowning/eslint-plugin-big-o/issues"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/adrianbrowning/eslint-plugin-big-o#readme",
|
|
20
|
+
"author": "Adrian Elton-Browning",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"zshy": {
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./index.ts"
|
|
26
|
+
},
|
|
27
|
+
"cjs": false
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "zshy",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"lint:ts": "tsc --noEmit",
|
|
33
|
+
"lint:esl": "eslint --config eslint.config.ts . --cache --max-warnings=0",
|
|
34
|
+
"lint:s": "eslint --config eslint.config.style.ts . --cache --max-warnings=0",
|
|
35
|
+
"lint:fix": "pnpm lint:s --fix",
|
|
36
|
+
"lint:knip": "knip",
|
|
37
|
+
"lint:jscpd": "jscpd .",
|
|
38
|
+
"lint": "pnpm lint:ts && pnpm lint:fix",
|
|
39
|
+
"lint:e18e": "pnpm dlx @e18e/cli analyze"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"eslint": ">=9.0.0"
|
|
43
|
+
},
|
|
44
|
+
"packageManager": "pnpm@10.33.4",
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@commitlint/cli": "20.0.0",
|
|
47
|
+
"@commitlint/config-conventional": "20.0.0",
|
|
48
|
+
"@gingacodemonkey/config": "^0.0.32",
|
|
49
|
+
"@types/eslint": "^9.6.1",
|
|
50
|
+
"@types/estree": "^1.0.9",
|
|
51
|
+
"@types/node": "^24.13.2",
|
|
52
|
+
"eslint": "^9.39.4",
|
|
53
|
+
"husky": "9.0.6",
|
|
54
|
+
"jscpd": "^4.2.5",
|
|
55
|
+
"knip": "5.70.1",
|
|
56
|
+
"lint-staged": "15.2.10",
|
|
57
|
+
"typescript": "^6.0.3",
|
|
58
|
+
"vitest": "^4.1.8",
|
|
59
|
+
"zshy": "latest"
|
|
60
|
+
},
|
|
61
|
+
"pnpm": {
|
|
62
|
+
"minimumReleaseAge": 4320
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=24.0.0",
|
|
66
|
+
"pnpm": ">=10.0.0"
|
|
67
|
+
},
|
|
68
|
+
"files": [
|
|
69
|
+
"dist"
|
|
70
|
+
],
|
|
71
|
+
"module": "./dist/index.js",
|
|
72
|
+
"types": "./dist/index.d.ts",
|
|
73
|
+
"exports": {
|
|
74
|
+
".": {
|
|
75
|
+
"types": "./dist/index.d.ts",
|
|
76
|
+
"default": "./dist/index.js"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|