Skip to content

Commit 211e217

Browse files
authored
Merge pull request #35 from Expensify/marcaaron-custom-rules
Add Custom Rules
2 parents 4906303 + 72b6494 commit 211e217

33 files changed

+6671
-874
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
node_modules
2+
.DS_Store

eslint-plugin-expensify/CONST.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = {
2+
MESSAGE: {
3+
NO_API_IN_VIEWS: 'Do not call API directly outside of actions methods. Only actions should make API requests.',
4+
NO_INLINE_NAMED_EXPORT: 'Do not inline named exports.',
5+
NO_NEGATED_VARIABLES: 'Do not use negated variable names.',
6+
NO_THENABLE_ACTIONS_IN_VIEWS: 'Calling .then() on action method {{method}} is forbidden in React views. Relocate this logic into the actions file and pass values via Onyx.',
7+
NO_USELESS_COMPOSE: 'compose() is not necessary when passed a single argument',
8+
PREFER_ACTIONS_SET_DATA: 'Only actions should directly set or modify Onyx data. Please move this logic into a suitable action.',
9+
PREFER_EARLY_RETURN: 'Prefer an early return to a conditionally-wrapped function body',
10+
PREFER_IMPORT_MODULE_CONTENTS: 'Do not import individual exports from local modules. Prefer \'import * as\' syntax.',
11+
PREFER_ONYX_CONNECT_IN_LIBS: 'Only call Onyx.connect() from inside a /src/libs/** file. React components and non-library code should not use Onyx.connect()',
12+
PREFER_UNDERSCORE_METHOD: 'Prefer \'_.{{method}}\' over the native function.',
13+
},
14+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const {isInActionFile, isInTestFile} = require('./utils');
2+
const message = require('./CONST').MESSAGE.NO_API_IN_VIEWS;
3+
4+
module.exports = {
5+
create: context => ({
6+
Identifier(node) {
7+
if (isInActionFile(context.getFilename())) {
8+
return;
9+
}
10+
11+
if (isInTestFile(context.getFilename())) {
12+
return;
13+
}
14+
15+
if (node.name !== 'API') {
16+
return;
17+
}
18+
19+
context.report({
20+
node,
21+
message,
22+
});
23+
},
24+
}),
25+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const message = require('./CONST').MESSAGE.NO_INLINE_NAMED_EXPORT;
2+
3+
module.exports = {
4+
create: context => ({
5+
ExportNamedDeclaration(node) {
6+
if (!node.declaration) {
7+
return;
8+
}
9+
10+
context.report({
11+
node,
12+
message,
13+
});
14+
},
15+
}),
16+
};
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
const _ = require('underscore');
2+
const lodashGet = require('lodash/get');
3+
const message = require('./CONST').MESSAGE.NO_NEGATED_VARIABLES;
4+
5+
/**
6+
* @param {String} string
7+
* @returns {Boolean}
8+
*/
9+
function isFalsePositive(string) {
10+
return _.some(['notification', 'notch'], falsePositive => string.toLowerCase().includes(falsePositive));
11+
}
12+
13+
/**
14+
* @param {String} name
15+
* @returns {Boolean}
16+
*/
17+
function isNegatedVariableName(name) {
18+
if (!name) {
19+
return;
20+
}
21+
22+
return (name.includes('Not') && !isFalsePositive(name))
23+
|| name.includes('isNot' && !isFalsePositive(name))
24+
|| name.includes('cannot')
25+
|| name.includes('shouldNot')
26+
|| name.includes('cant')
27+
|| name.includes('dont');
28+
}
29+
30+
module.exports = {
31+
create: context => ({
32+
FunctionDeclaration(node) {
33+
const name = lodashGet(node, 'id.name');
34+
if (!name) {
35+
return;
36+
}
37+
38+
if (!isNegatedVariableName(name)) {
39+
return;
40+
}
41+
42+
context.report({
43+
node,
44+
message,
45+
});
46+
},
47+
VariableDeclarator(node) {
48+
const name = lodashGet(node, 'id.name');
49+
if (!name) {
50+
return;
51+
}
52+
53+
if (!isNegatedVariableName(node.id.name)) {
54+
return;
55+
}
56+
57+
context.report({
58+
node,
59+
message,
60+
});
61+
},
62+
}),
63+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
const _ = require('underscore');
2+
const lodashGet = require('lodash/get');
3+
const path = require('path');
4+
const isReactViewFile = require('./utils').isReactViewFile;
5+
const message = require('./CONST').MESSAGE.NO_THENABLE_ACTIONS_IN_VIEWS;
6+
7+
module.exports = {
8+
create: (context) => {
9+
const actionsNamespaces = [];
10+
return {
11+
// Using import declaration to create a map of all the imports for this file and which ones are "actions"
12+
ImportDeclaration(node) {
13+
const pathName = path.resolve(lodashGet(node, 'source.value'));
14+
if (!pathName || !pathName.includes('/actions/')) {
15+
return;
16+
}
17+
18+
actionsNamespaces.push(_.last(pathName.split('/')));
19+
},
20+
MemberExpression(node) {
21+
if (!isReactViewFile(context.getFilename())) {
22+
return;
23+
}
24+
25+
if (lodashGet(node, 'property.name') !== 'then') {
26+
return;
27+
}
28+
29+
const actionModuleName = lodashGet(node, 'object.callee.object.name');
30+
if (!_.includes(actionsNamespaces, actionModuleName)) {
31+
return;
32+
}
33+
34+
const actionMethodName = lodashGet(node, 'object.callee.property.name');
35+
context.report({
36+
node,
37+
message,
38+
data: {
39+
method: `${actionModuleName}.${actionMethodName}()`,
40+
},
41+
});
42+
},
43+
};
44+
},
45+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const lodashGet = require('lodash/get');
2+
const message = require('./CONST').MESSAGE.NO_USELESS_COMPOSE;
3+
4+
module.exports = {
5+
create: context => ({
6+
CallExpression(node) {
7+
const name = lodashGet(node, 'callee.name');
8+
if (!name) {
9+
return;
10+
}
11+
12+
if (name !== 'compose') {
13+
return;
14+
}
15+
16+
if (node.arguments.length !== 1) {
17+
return;
18+
}
19+
20+
context.report({
21+
node,
22+
message,
23+
});
24+
},
25+
}),
26+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
const _ = require('underscore');
2+
const lodashGet = require('lodash/get');
3+
const {isOnyxMethodCall, isInActionFile, isInTestFile} = require('./utils');
4+
const message = require('./CONST').MESSAGE.PREFER_ACTIONS_SET_DATA;
5+
6+
/**
7+
* @param {String} methodName
8+
* @returns {Boolean}
9+
*/
10+
function isDataSettingMethod(methodName) {
11+
return _.includes(['set', 'merge', 'mergeCollection'], methodName);
12+
}
13+
14+
module.exports = {
15+
create: context => ({
16+
MemberExpression(node) {
17+
const filename = context.getFilename();
18+
19+
if (!isOnyxMethodCall(node)) {
20+
return;
21+
}
22+
23+
if (isInTestFile(context.getFilename())) {
24+
return;
25+
}
26+
27+
const methodName = lodashGet(node, 'property.name');
28+
if (!isDataSettingMethod(methodName) || isInActionFile(context.getFilename(filename))) {
29+
return;
30+
}
31+
32+
context.report({
33+
node,
34+
message,
35+
});
36+
},
37+
}),
38+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Adapted from https://github.com/Shopify/web-configs/blob/84c180fb08968276198faade21fa6918b104804c/packages/eslint-plugin/lib/rules/prefer-early-return.js#L1-L78
3+
*/
4+
const defaultMaximumStatements = 0;
5+
const message = require('./CONST').MESSAGE.PREFER_EARLY_RETURN;
6+
7+
module.exports = {
8+
create(context) {
9+
const options = context.options[0] || {
10+
maximumStatements: defaultMaximumStatements,
11+
};
12+
const maxStatements = options.maximumStatements;
13+
14+
function isLonelyIfStatement(statement) {
15+
return statement.type === 'IfStatement' && statement.alternate == null;
16+
}
17+
18+
function isOffendingConsequent(consequent) {
19+
return (
20+
(consequent.type === 'ExpressionStatement' && maxStatements === 0)
21+
|| (consequent.type === 'BlockStatement'
22+
&& consequent.body.length > maxStatements)
23+
);
24+
}
25+
26+
function isOffendingIfStatement(statement) {
27+
return (
28+
isLonelyIfStatement(statement)
29+
&& isOffendingConsequent(statement.consequent)
30+
);
31+
}
32+
33+
function hasSimplifiableConditionalBody(functionBody) {
34+
const body = functionBody.body;
35+
return (
36+
functionBody.type === 'BlockStatement'
37+
&& body.length === 1
38+
&& isOffendingIfStatement(body[0])
39+
);
40+
}
41+
42+
function checkFunctionBody(functionNode) {
43+
const body = functionNode.body;
44+
45+
if (hasSimplifiableConditionalBody(body)) {
46+
context.report(
47+
body,
48+
message,
49+
);
50+
}
51+
}
52+
53+
return {
54+
FunctionDeclaration: checkFunctionBody,
55+
FunctionExpression: checkFunctionBody,
56+
ArrowFunctionExpression: checkFunctionBody,
57+
};
58+
},
59+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const _ = require('underscore');
2+
const lodashGet = require('lodash/get');
3+
const message = require('./CONST').MESSAGE.PREFER_IMPORT_MODULE_CONTENTS;
4+
5+
/**
6+
* @param {String} source
7+
* @returns {Boolean}
8+
*/
9+
function isFromNodeModules(source) {
10+
return !source.startsWith('.') && !source.startsWith('..');
11+
}
12+
13+
/**
14+
* @param {Array} specifiers
15+
* @returns {Boolean}
16+
*/
17+
function isEverySpecifierImport(specifiers = []) {
18+
return _.every(specifiers, specifier => specifier.type === 'ImportSpecifier');
19+
}
20+
21+
module.exports = {
22+
create: context => ({
23+
ImportDeclaration(node) {
24+
const sourceValue = lodashGet(node, 'source.value');
25+
if (!sourceValue) {
26+
return;
27+
}
28+
29+
if (isFromNodeModules(sourceValue)) {
30+
return;
31+
}
32+
33+
if (!node.specifiers || !node.specifiers.length) {
34+
return;
35+
}
36+
37+
if (!isEverySpecifierImport(node.specifiers)) {
38+
return;
39+
}
40+
41+
context.report({
42+
node,
43+
message,
44+
});
45+
},
46+
}),
47+
};

0 commit comments

Comments
 (0)