Skip to content

Commit 24109be

Browse files
committed
Add no-negated-assertion rule
1 parent 8ba861c commit 24109be

5 files changed

Lines changed: 301 additions & 0 deletions

File tree

docs/rules/no-negated-assertion.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# ava/no-negated-assertion
2+
3+
📝 Disallow negated assertions.
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+
Using negated arguments in assertions like `t.true(!x)` is harder to read than using the opposite assertion `t.false(x)`. This rule enforces using the positive assertion method instead of negating the argument.
12+
13+
## Examples
14+
15+
```js
16+
import test from 'ava';
17+
18+
test('foo', t => {
19+
t.true(!value); //
20+
t.false(value); //
21+
22+
t.false(!value); //
23+
t.true(value); //
24+
25+
t.truthy(!value); //
26+
t.falsy(value); //
27+
28+
t.falsy(!value); //
29+
t.truthy(value); //
30+
31+
t.true(!!value); // ❌ (double negation is unnecessary)
32+
t.true(value); //
33+
});
34+
```

index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import noImportTestFiles from './rules/no-import-test-files.js';
1616
import noIncorrectDeepEqual from './rules/no-incorrect-deep-equal.js';
1717
import noInlineAssertions from './rules/no-inline-assertions.js';
1818
import noInvalidModifierChain from './rules/no-invalid-modifier-chain.js';
19+
import noNegatedAssertion from './rules/no-negated-assertion.js';
1920
import noNestedAssertions from './rules/no-nested-assertions.js';
2021
import noNestedTests from './rules/no-nested-tests.js';
2122
import noOnlyTest from './rules/no-only-test.js';
@@ -58,6 +59,7 @@ const rules = {
5859
'no-incorrect-deep-equal': noIncorrectDeepEqual,
5960
'no-inline-assertions': noInlineAssertions,
6061
'no-invalid-modifier-chain': noInvalidModifierChain,
62+
'no-negated-assertion': noNegatedAssertion,
6163
'no-nested-assertions': noNestedAssertions,
6264
'no-nested-tests': noNestedTests,
6365
'no-only-test': noOnlyTest,
@@ -100,6 +102,7 @@ const recommendedRules = {
100102
'ava/no-incorrect-deep-equal': 'error',
101103
'ava/no-inline-assertions': 'error',
102104
'ava/no-invalid-modifier-chain': 'error',
105+
'ava/no-negated-assertion': 'error',
103106
'ava/no-nested-assertions': 'error',
104107
'ava/no-nested-tests': 'error',
105108
'ava/no-only-test': 'error',

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ The rules will only activate in test files.
6868
| [no-incorrect-deep-equal](docs/rules/no-incorrect-deep-equal.md) | Disallow using `deepEqual` with primitives. || | | 🔧 | | |
6969
| [no-inline-assertions](docs/rules/no-inline-assertions.md) | Disallow inline assertions. || | | 🔧 | | |
7070
| [no-invalid-modifier-chain](docs/rules/no-invalid-modifier-chain.md) | Disallow invalid modifier chains. || | | 🔧 | 💡 | |
71+
| [no-negated-assertion](docs/rules/no-negated-assertion.md) | Disallow negated assertions. || | | 🔧 | | |
7172
| [no-nested-assertions](docs/rules/no-nested-assertions.md) | Disallow nested assertions. || | | | | |
7273
| [no-nested-tests](docs/rules/no-nested-tests.md) | Disallow nested tests. || | | | | |
7374
| [no-only-test](docs/rules/no-only-test.md) | Disallow `test.only()`. || | | | 💡 | |

rules/no-negated-assertion.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {visitIf} from 'enhance-visitors';
2+
import util from '../util.js';
3+
import createAvaRule from '../create-ava-rule.js';
4+
5+
const MESSAGE_ID = 'no-negated-assertion';
6+
7+
const negatedPairs = {
8+
true: 'falsy',
9+
false: 'truthy',
10+
truthy: 'falsy',
11+
falsy: 'truthy',
12+
};
13+
14+
const doubleNegatedPairs = {
15+
true: 'truthy',
16+
false: 'falsy',
17+
truthy: 'truthy',
18+
falsy: 'falsy',
19+
};
20+
21+
const getArgumentText = (source, argument) => argument.type === 'SequenceExpression' ? `(${source.getText(argument)})` : source.getText(argument);
22+
23+
const create = context => {
24+
const ava = createAvaRule();
25+
26+
return ava.merge({
27+
CallExpression: visitIf([
28+
ava.isInTestFile,
29+
ava.isInTestNode,
30+
])(node => {
31+
if (node.callee.type !== 'MemberExpression') {
32+
return;
33+
}
34+
35+
const root = util.getRootNode(node.callee);
36+
37+
if (!util.isTestObject(root.object.name)) {
38+
return;
39+
}
40+
41+
const members = util.getMembers(node.callee);
42+
const methodName = members[0];
43+
44+
if (!negatedPairs[methodName]) {
45+
return;
46+
}
47+
48+
if (!members.slice(1).every(member => member === 'skip')) {
49+
return;
50+
}
51+
52+
const argument = node.arguments[0];
53+
54+
if (!argument || argument.type !== 'UnaryExpression' || argument.operator !== '!') {
55+
return;
56+
}
57+
58+
// Double negation: `t.true(!!x)` → `t.truthy(x)` to preserve behavior for non-boolean values.
59+
if (argument.argument.type === 'UnaryExpression' && argument.argument.operator === '!') {
60+
const replacement = doubleNegatedPairs[methodName];
61+
62+
context.report({
63+
node,
64+
messageId: MESSAGE_ID,
65+
data: {
66+
assertion: methodName,
67+
replacement,
68+
},
69+
fix(fixer) {
70+
const source = context.sourceCode;
71+
return [
72+
fixer.replaceText(root.property, replacement),
73+
fixer.replaceText(argument, getArgumentText(source, argument.argument.argument)),
74+
];
75+
},
76+
});
77+
return;
78+
}
79+
80+
const replacement = negatedPairs[methodName];
81+
82+
context.report({
83+
node,
84+
messageId: MESSAGE_ID,
85+
data: {
86+
assertion: methodName,
87+
replacement,
88+
},
89+
fix(fixer) {
90+
const source = context.sourceCode;
91+
return [
92+
fixer.replaceText(root.property, replacement),
93+
fixer.replaceText(argument, getArgumentText(source, argument.argument)),
94+
];
95+
},
96+
});
97+
}),
98+
});
99+
};
100+
101+
export default {
102+
create,
103+
meta: {
104+
type: 'suggestion',
105+
docs: {
106+
description: 'Disallow negated assertions.',
107+
recommended: true,
108+
url: util.getDocsUrl(import.meta.filename),
109+
},
110+
fixable: 'code',
111+
schema: [],
112+
messages: {
113+
[MESSAGE_ID]: 'Prefer `t.{{replacement}}()` over negating the argument of `t.{{assertion}}()`.',
114+
},
115+
},
116+
};

test/no-negated-assertion.js

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import RuleTester, {testCase} from './helpers/rule-tester.js';
2+
import rule from '../rules/no-negated-assertion.js';
3+
4+
const ruleTester = new RuleTester();
5+
6+
const errors = [{messageId: 'no-negated-assertion'}];
7+
8+
ruleTester.run('no-negated-assertion', rule, {
9+
valid: [
10+
testCase('t.true(x)'),
11+
testCase('t.false(x)'),
12+
testCase('t.truthy(x)'),
13+
testCase('t.falsy(x)'),
14+
testCase('t.is(!x, y)'),
15+
testCase('t.assert(!x)'),
16+
testCase('t.true(x, "message")'),
17+
testCase('t.true()'),
18+
// Not a test object
19+
testCase('foo.true(!x)'),
20+
// `t.context` is not an assertion
21+
testCase('t.context.true(!x)'),
22+
// Invalid modifier order; handled by other rules
23+
testCase('t.skip.true(!x)'),
24+
// Invalid trailing member; handled by other rules
25+
testCase('t.true.context(!x)'),
26+
// Shouldn't be triggered since it's not a test file
27+
{code: testCase('t.true(!x)'), noHeader: true},
28+
],
29+
invalid: [
30+
{
31+
code: testCase('t.true(!x)'),
32+
output: testCase('t.falsy(x)'),
33+
errors,
34+
},
35+
{
36+
code: testCase('t.false(!x)'),
37+
output: testCase('t.truthy(x)'),
38+
errors,
39+
},
40+
{
41+
code: testCase('t.truthy(!x)'),
42+
output: testCase('t.falsy(x)'),
43+
errors,
44+
},
45+
{
46+
code: testCase('t.falsy(!x)'),
47+
output: testCase('t.truthy(x)'),
48+
errors,
49+
},
50+
// With message argument
51+
{
52+
code: testCase('t.true(!x, "msg")'),
53+
output: testCase('t.falsy(x, "msg")'),
54+
errors,
55+
},
56+
// Complex expressions
57+
{
58+
code: testCase('t.true(!foo.bar)'),
59+
output: testCase('t.falsy(foo.bar)'),
60+
errors,
61+
},
62+
{
63+
code: testCase('t.true(!foo())'),
64+
output: testCase('t.falsy(foo())'),
65+
errors,
66+
},
67+
{
68+
code: testCase('t.true(!(a === b))'),
69+
output: testCase('t.falsy(a === b)'),
70+
errors,
71+
},
72+
{
73+
code: testCase('t.true(!(a, b))'),
74+
output: testCase('t.falsy((a, b))'),
75+
errors,
76+
},
77+
// With .skip
78+
{
79+
code: testCase('t.true.skip(!x)'),
80+
output: testCase('t.falsy.skip(x)'),
81+
errors,
82+
},
83+
{
84+
code: testCase('t.truthy.skip(!x)'),
85+
output: testCase('t.falsy.skip(x)'),
86+
errors,
87+
},
88+
{
89+
code: testCase('t.true.skip.skip(!x)'),
90+
output: testCase('t.falsy.skip.skip(x)'),
91+
errors,
92+
},
93+
// Double negation
94+
{
95+
code: testCase('t.true(!!x)'),
96+
output: testCase('t.truthy(x)'),
97+
errors,
98+
},
99+
{
100+
code: testCase('t.false(!!x)'),
101+
output: testCase('t.falsy(x)'),
102+
errors,
103+
},
104+
{
105+
code: testCase('t.truthy(!!x)'),
106+
output: testCase('t.truthy(x)'),
107+
errors,
108+
},
109+
{
110+
code: testCase('t.true.skip(!!x)'),
111+
output: testCase('t.truthy.skip(x)'),
112+
errors,
113+
},
114+
{
115+
code: testCase('t.true(!!(a, b))'),
116+
output: testCase('t.truthy((a, b))'),
117+
errors,
118+
},
119+
// Non-boolean values
120+
{
121+
code: testCase('t.true(!0)'),
122+
output: testCase('t.falsy(0)'),
123+
errors,
124+
},
125+
{
126+
code: testCase('t.false(!0)'),
127+
output: testCase('t.truthy(0)'),
128+
errors,
129+
},
130+
{
131+
code: testCase('t.true(!!1)'),
132+
output: testCase('t.truthy(1)'),
133+
errors,
134+
},
135+
{
136+
code: testCase('t.false(!!1)'),
137+
output: testCase('t.falsy(1)'),
138+
errors,
139+
},
140+
// Alternative test object names
141+
{
142+
code: testCase('tt.true(!x)'),
143+
output: testCase('tt.falsy(x)'),
144+
errors,
145+
},
146+
],
147+
});

0 commit comments

Comments
 (0)