Skip to content

Commit 74a2952

Browse files
committed
Add no-ava-in-dependencies rule
Fixes #322
1 parent be9d321 commit 74a2952

9 files changed

Lines changed: 348 additions & 14 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# ava/no-ava-in-dependencies
2+
3+
📝 Disallow AVA in `dependencies`.
4+
5+
💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/avajs/eslint-plugin-ava#recommended-config).
6+
7+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
8+
9+
<!-- end auto-generated rule header -->
10+
11+
AVA is a test runner and should be in `devDependencies`, not `dependencies`. Having it in `dependencies` means it will be installed by consumers of your package, which is unnecessary and increases install size.
12+
13+
### Fail
14+
15+
```json
16+
{
17+
"dependencies": {
18+
"ava": "^6.0.0"
19+
}
20+
}
21+
```
22+
23+
### Pass
24+
25+
```json
26+
{
27+
"devDependencies": {
28+
"ava": "^6.0.0"
29+
}
30+
}
31+
```

index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type {ESLint, Linter} from 'eslint';
22

33
declare const eslintPluginAva: ESLint.Plugin & {
44
configs: {
5-
recommended: Linter.Config;
5+
recommended: Linter.Config[];
66
};
77
};
88

index.js

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {createRequire} from 'node:module';
2+
import json from '@eslint/json';
23
import assertionArguments from './rules/assertion-arguments.js';
34
import hooksOrder from './rules/hooks-order.js';
45
import maxAsserts from './rules/max-asserts.js';
6+
import noAvaInDependencies from './rules/no-ava-in-dependencies.js';
57
import noAsyncFnWithoutAwait from './rules/no-async-fn-without-await.js';
68
import noDuplicateModifiers from './rules/no-duplicate-modifiers.js';
79
import noIdenticalTitle from './rules/no-identical-title.js';
@@ -34,6 +36,7 @@ const rules = {
3436
'assertion-arguments': assertionArguments,
3537
'hooks-order': hooksOrder,
3638
'max-asserts': maxAsserts,
39+
'no-ava-in-dependencies': noAvaInDependencies,
3740
'no-async-fn-without-await': noAsyncFnWithoutAwait,
3841
'no-duplicate-modifiers': noDuplicateModifiers,
3942
'no-identical-title': noIdenticalTitle,
@@ -86,9 +89,9 @@ const recommendedRules = {
8689
'ava/prefer-t-regex': 'error',
8790
'ava/test-title': 'error',
8891
'ava/test-title-format': 'off',
89-
'ava/use-t-well': 'error',
9092
'ava/use-t': 'error',
9193
'ava/use-t-throws-async-well': 'error',
94+
'ava/use-t-well': 'error',
9295
'ava/use-test': 'error',
9396
'ava/use-true-false': 'error',
9497
};
@@ -103,14 +106,24 @@ const plugin = {
103106
};
104107

105108
Object.assign(plugin.configs, {
106-
recommended: {
107-
plugins: {
108-
ava: plugin,
109+
recommended: [
110+
{
111+
plugins: {
112+
ava: plugin,
113+
},
114+
rules: {
115+
...recommendedRules,
116+
},
109117
},
110-
rules: {
111-
...recommendedRules,
118+
{
119+
files: ['**/package.json'],
120+
language: 'json/json',
121+
plugins: {json, ava: plugin},
122+
rules: {
123+
'ava/no-ava-in-dependencies': 'error',
124+
},
112125
},
113-
},
126+
],
114127
});
115128

116129
export default plugin;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
],
4444
"dependencies": {
4545
"@eslint-community/eslint-utils": "^4.9.1",
46+
"@eslint/json": "^1.0.0",
4647
"enhance-visitors": "^1.0.0",
4748
"espree": "^11.1.0",
4849
"espurify": "^3.2.0",

readme.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ The rules will only activate in test files.
5555
| [hooks-order](docs/rules/hooks-order.md) | Enforce test hook ordering. || | | 🔧 | |
5656
| [max-asserts](docs/rules/max-asserts.md) | Limit the number of assertions in a test. | | || | |
5757
| [no-async-fn-without-await](docs/rules/no-async-fn-without-await.md) | Require async tests to use `await`. || | | | 💡 |
58+
| [no-ava-in-dependencies](docs/rules/no-ava-in-dependencies.md) | Disallow AVA in `dependencies`. || | | 🔧 | |
5859
| [no-duplicate-modifiers](docs/rules/no-duplicate-modifiers.md) | Disallow duplicate test modifiers. || | | 🔧 | |
5960
| [no-identical-title](docs/rules/no-identical-title.md) | Disallow identical test titles. || | | | |
6061
| [no-ignored-test-files](docs/rules/no-ignored-test-files.md) | Disallow tests in ignored files. || | | | |
@@ -89,6 +90,6 @@ This plugin exports a [`recommended` config](index.js) that enforces good practi
8990
import eslintPluginAva from 'eslint-plugin-ava';
9091

9192
export default [
92-
eslintPluginAva.configs.recommended,
93+
...eslintPluginAva.configs.recommended,
9394
];
9495
```

rules/no-ava-in-dependencies.js

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import util from '../util.js';
2+
3+
const MESSAGE_ID = 'no-ava-in-dependencies';
4+
5+
function findMember(objectNode, key) {
6+
return objectNode.members.find(member => member.name.value === key);
7+
}
8+
9+
function detectIndent(source, members) {
10+
if (members.length === 0) {
11+
return '\t';
12+
}
13+
14+
const firstMember = members[0];
15+
const lineStart = source.lastIndexOf('\n', firstMember.loc.start.offset) + 1;
16+
return source.slice(lineStart, firstMember.loc.start.offset);
17+
}
18+
19+
function removeMember(fixer, source, objectNode, memberIndex) {
20+
const {members} = objectNode;
21+
const member = members[memberIndex];
22+
23+
if (members.length === 1) {
24+
// Only member - empty the object but keep braces
25+
return fixer.replaceTextRange(
26+
[objectNode.loc.start.offset, objectNode.loc.end.offset],
27+
'{}',
28+
);
29+
}
30+
31+
if (memberIndex === members.length - 1) {
32+
// Last member - remove from end of previous member to end of this member
33+
const previousMember = members[memberIndex - 1];
34+
return fixer.removeRange([previousMember.loc.end.offset, member.loc.end.offset]);
35+
}
36+
37+
// First or middle member - remove from start of line to start of next member's line
38+
const nextMember = members[memberIndex + 1];
39+
const removeStart = source.lastIndexOf('\n', member.loc.start.offset - 1);
40+
const removeEnd = source.lastIndexOf('\n', nextMember.loc.start.offset);
41+
return fixer.removeRange([
42+
removeStart === -1 ? member.loc.start.offset : removeStart,
43+
removeEnd === -1 ? nextMember.loc.start.offset : removeEnd,
44+
]);
45+
}
46+
47+
const create = context => ({
48+
Document(node) {
49+
const root = node.body;
50+
if (!root || root.type !== 'Object') {
51+
return;
52+
}
53+
54+
const depsMember = findMember(root, 'dependencies');
55+
if (!depsMember || depsMember.value.type !== 'Object') {
56+
return;
57+
}
58+
59+
const avaMember = findMember(depsMember.value, 'ava');
60+
if (!avaMember) {
61+
return;
62+
}
63+
64+
const devDepsMember = findMember(root, 'devDependencies');
65+
const alreadyInDevDeps = devDepsMember
66+
&& devDepsMember.value.type === 'Object'
67+
&& findMember(devDepsMember.value, 'ava');
68+
69+
context.report({
70+
loc: avaMember.name.loc,
71+
messageId: MESSAGE_ID,
72+
fix(fixer) {
73+
const source = context.sourceCode.getText();
74+
const fixes = [];
75+
const isOnlyDependency = depsMember.value.members.length === 1;
76+
77+
if (isOnlyDependency && !alreadyInDevDeps && !devDepsMember) {
78+
// Replace "dependencies" key with "devDependencies" - simplest case
79+
fixes.push(fixer.replaceTextRange(
80+
[depsMember.name.loc.start.offset, depsMember.name.loc.end.offset],
81+
'"devDependencies"',
82+
));
83+
return fixes;
84+
}
85+
86+
if (isOnlyDependency) {
87+
// Remove entire dependencies member from root
88+
const depsIndex = root.members.indexOf(depsMember);
89+
fixes.push(removeMember(fixer, source, root, depsIndex));
90+
} else {
91+
// Remove just the ava entry from dependencies
92+
const avaIndex = depsMember.value.members.indexOf(avaMember);
93+
fixes.push(removeMember(fixer, source, depsMember.value, avaIndex));
94+
}
95+
96+
if (!alreadyInDevDeps) {
97+
const avaEntry = `"ava": ${source.slice(avaMember.value.loc.start.offset, avaMember.value.loc.end.offset)}`;
98+
99+
if (devDepsMember && devDepsMember.value.type === 'Object') {
100+
// Add to existing devDependencies
101+
const devMembers = devDepsMember.value.members;
102+
const innerIndent = devMembers.length > 0
103+
? detectIndent(source, devMembers)
104+
: detectIndent(source, depsMember.value.members);
105+
106+
if (devMembers.length === 0) {
107+
// Empty devDependencies - insert between braces
108+
const closingIndent = detectIndent(source, root.members);
109+
fixes.push(fixer.replaceTextRange(
110+
[devDepsMember.value.loc.start.offset, devDepsMember.value.loc.end.offset],
111+
`{\n${innerIndent}${avaEntry}\n${closingIndent}}`,
112+
));
113+
} else {
114+
// Append after last member
115+
const lastMember = devMembers.at(-1);
116+
fixes.push(fixer.insertTextAfterRange(
117+
[lastMember.loc.end.offset - 1, lastMember.loc.end.offset],
118+
`,\n${innerIndent}${avaEntry}`,
119+
));
120+
}
121+
} else if (devDepsMember) {
122+
// Existing devDependencies is invalid (non-object), replace its value.
123+
const indent = detectIndent(source, root.members);
124+
const innerIndent = detectIndent(source, depsMember.value.members);
125+
fixes.push(fixer.replaceTextRange(
126+
[devDepsMember.value.loc.start.offset, devDepsMember.value.loc.end.offset],
127+
`{\n${innerIndent}${avaEntry}\n${indent}}`,
128+
));
129+
} else {
130+
// Create devDependencies section after dependencies
131+
const indent = detectIndent(source, root.members);
132+
const innerIndent = detectIndent(source, depsMember.value.members);
133+
const devDepsBlock = `"devDependencies": {\n${innerIndent}${avaEntry}\n${indent}}`;
134+
135+
fixes.push(fixer.insertTextAfterRange(
136+
[depsMember.loc.end.offset - 1, depsMember.loc.end.offset],
137+
`,\n${indent}${devDepsBlock}`,
138+
));
139+
}
140+
}
141+
142+
return fixes;
143+
},
144+
});
145+
},
146+
});
147+
148+
export default {
149+
create,
150+
meta: {
151+
type: 'problem',
152+
docs: {
153+
description: 'Disallow AVA in `dependencies`.',
154+
recommended: true,
155+
url: util.getDocsUrl(import.meta.filename),
156+
},
157+
fixable: 'code',
158+
schema: [],
159+
messages: {
160+
[MESSAGE_ID]: '`ava` should be in `devDependencies` instead of `dependencies`.',
161+
},
162+
},
163+
};

test/integration/eslint-config-ava-tester/eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import avaPlugin from 'eslint-plugin-ava';
22
import typescriptParser from '@typescript-eslint/parser';
33

44
export default [
5-
avaPlugin.configs.recommended,
5+
...avaPlugin.configs.recommended,
66
{
77
files: ['**/*.ts'],
88
languageOptions: {

0 commit comments

Comments
 (0)