Skip to content

Commit

Permalink
Merge pull request #35 from Expensify/marcaaron-custom-rules
Browse files Browse the repository at this point in the history
Add Custom Rules
  • Loading branch information
marcaaron authored Nov 5, 2021
2 parents 4906303 + 72b6494 commit 211e217
Show file tree
Hide file tree
Showing 33 changed files with 6,671 additions and 874 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
node_modules
.DS_Store
14 changes: 14 additions & 0 deletions eslint-plugin-expensify/CONST.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module.exports = {
MESSAGE: {
NO_API_IN_VIEWS: 'Do not call API directly outside of actions methods. Only actions should make API requests.',
NO_INLINE_NAMED_EXPORT: 'Do not inline named exports.',
NO_NEGATED_VARIABLES: 'Do not use negated variable names.',
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.',
NO_USELESS_COMPOSE: 'compose() is not necessary when passed a single argument',
PREFER_ACTIONS_SET_DATA: 'Only actions should directly set or modify Onyx data. Please move this logic into a suitable action.',
PREFER_EARLY_RETURN: 'Prefer an early return to a conditionally-wrapped function body',
PREFER_IMPORT_MODULE_CONTENTS: 'Do not import individual exports from local modules. Prefer \'import * as\' syntax.',
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()',
PREFER_UNDERSCORE_METHOD: 'Prefer \'_.{{method}}\' over the native function.',
},
};
25 changes: 25 additions & 0 deletions eslint-plugin-expensify/no-api-in-views.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const {isInActionFile, isInTestFile} = require('./utils');
const message = require('./CONST').MESSAGE.NO_API_IN_VIEWS;

module.exports = {
create: context => ({
Identifier(node) {
if (isInActionFile(context.getFilename())) {
return;
}

if (isInTestFile(context.getFilename())) {
return;
}

if (node.name !== 'API') {
return;
}

context.report({
node,
message,
});
},
}),
};
16 changes: 16 additions & 0 deletions eslint-plugin-expensify/no-inline-named-export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const message = require('./CONST').MESSAGE.NO_INLINE_NAMED_EXPORT;

module.exports = {
create: context => ({
ExportNamedDeclaration(node) {
if (!node.declaration) {
return;
}

context.report({
node,
message,
});
},
}),
};
63 changes: 63 additions & 0 deletions eslint-plugin-expensify/no-negated-variables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const _ = require('underscore');
const lodashGet = require('lodash/get');
const message = require('./CONST').MESSAGE.NO_NEGATED_VARIABLES;

/**
* @param {String} string
* @returns {Boolean}
*/
function isFalsePositive(string) {
return _.some(['notification', 'notch'], falsePositive => string.toLowerCase().includes(falsePositive));
}

/**
* @param {String} name
* @returns {Boolean}
*/
function isNegatedVariableName(name) {
if (!name) {
return;
}

return (name.includes('Not') && !isFalsePositive(name))
|| name.includes('isNot' && !isFalsePositive(name))
|| name.includes('cannot')
|| name.includes('shouldNot')
|| name.includes('cant')
|| name.includes('dont');
}

module.exports = {
create: context => ({
FunctionDeclaration(node) {
const name = lodashGet(node, 'id.name');
if (!name) {
return;
}

if (!isNegatedVariableName(name)) {
return;
}

context.report({
node,
message,
});
},
VariableDeclarator(node) {
const name = lodashGet(node, 'id.name');
if (!name) {
return;
}

if (!isNegatedVariableName(node.id.name)) {
return;
}

context.report({
node,
message,
});
},
}),
};
45 changes: 45 additions & 0 deletions eslint-plugin-expensify/no-thenable-actions-in-views.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const _ = require('underscore');
const lodashGet = require('lodash/get');
const path = require('path');
const isReactViewFile = require('./utils').isReactViewFile;
const message = require('./CONST').MESSAGE.NO_THENABLE_ACTIONS_IN_VIEWS;

module.exports = {
create: (context) => {
const actionsNamespaces = [];
return {
// Using import declaration to create a map of all the imports for this file and which ones are "actions"
ImportDeclaration(node) {
const pathName = path.resolve(lodashGet(node, 'source.value'));
if (!pathName || !pathName.includes('/actions/')) {
return;
}

actionsNamespaces.push(_.last(pathName.split('/')));
},
MemberExpression(node) {
if (!isReactViewFile(context.getFilename())) {
return;
}

if (lodashGet(node, 'property.name') !== 'then') {
return;
}

const actionModuleName = lodashGet(node, 'object.callee.object.name');
if (!_.includes(actionsNamespaces, actionModuleName)) {
return;
}

const actionMethodName = lodashGet(node, 'object.callee.property.name');
context.report({
node,
message,
data: {
method: `${actionModuleName}.${actionMethodName}()`,
},
});
},
};
},
};
26 changes: 26 additions & 0 deletions eslint-plugin-expensify/no-useless-compose.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const lodashGet = require('lodash/get');
const message = require('./CONST').MESSAGE.NO_USELESS_COMPOSE;

module.exports = {
create: context => ({
CallExpression(node) {
const name = lodashGet(node, 'callee.name');
if (!name) {
return;
}

if (name !== 'compose') {
return;
}

if (node.arguments.length !== 1) {
return;
}

context.report({
node,
message,
});
},
}),
};
38 changes: 38 additions & 0 deletions eslint-plugin-expensify/prefer-actions-set-data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const _ = require('underscore');
const lodashGet = require('lodash/get');
const {isOnyxMethodCall, isInActionFile, isInTestFile} = require('./utils');
const message = require('./CONST').MESSAGE.PREFER_ACTIONS_SET_DATA;

/**
* @param {String} methodName
* @returns {Boolean}
*/
function isDataSettingMethod(methodName) {
return _.includes(['set', 'merge', 'mergeCollection'], methodName);
}

module.exports = {
create: context => ({
MemberExpression(node) {
const filename = context.getFilename();

if (!isOnyxMethodCall(node)) {
return;
}

if (isInTestFile(context.getFilename())) {
return;
}

const methodName = lodashGet(node, 'property.name');
if (!isDataSettingMethod(methodName) || isInActionFile(context.getFilename(filename))) {
return;
}

context.report({
node,
message,
});
},
}),
};
59 changes: 59 additions & 0 deletions eslint-plugin-expensify/prefer-early-return.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Adapted from https://github.com/Shopify/web-configs/blob/84c180fb08968276198faade21fa6918b104804c/packages/eslint-plugin/lib/rules/prefer-early-return.js#L1-L78
*/
const defaultMaximumStatements = 0;
const message = require('./CONST').MESSAGE.PREFER_EARLY_RETURN;

module.exports = {
create(context) {
const options = context.options[0] || {
maximumStatements: defaultMaximumStatements,
};
const maxStatements = options.maximumStatements;

function isLonelyIfStatement(statement) {
return statement.type === 'IfStatement' && statement.alternate == null;
}

function isOffendingConsequent(consequent) {
return (
(consequent.type === 'ExpressionStatement' && maxStatements === 0)
|| (consequent.type === 'BlockStatement'
&& consequent.body.length > maxStatements)
);
}

function isOffendingIfStatement(statement) {
return (
isLonelyIfStatement(statement)
&& isOffendingConsequent(statement.consequent)
);
}

function hasSimplifiableConditionalBody(functionBody) {
const body = functionBody.body;
return (
functionBody.type === 'BlockStatement'
&& body.length === 1
&& isOffendingIfStatement(body[0])
);
}

function checkFunctionBody(functionNode) {
const body = functionNode.body;

if (hasSimplifiableConditionalBody(body)) {
context.report(
body,
message,
);
}
}

return {
FunctionDeclaration: checkFunctionBody,
FunctionExpression: checkFunctionBody,
ArrowFunctionExpression: checkFunctionBody,
};
},
};
47 changes: 47 additions & 0 deletions eslint-plugin-expensify/prefer-import-module-contents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const _ = require('underscore');
const lodashGet = require('lodash/get');
const message = require('./CONST').MESSAGE.PREFER_IMPORT_MODULE_CONTENTS;

/**
* @param {String} source
* @returns {Boolean}
*/
function isFromNodeModules(source) {
return !source.startsWith('.') && !source.startsWith('..');
}

/**
* @param {Array} specifiers
* @returns {Boolean}
*/
function isEverySpecifierImport(specifiers = []) {
return _.every(specifiers, specifier => specifier.type === 'ImportSpecifier');
}

module.exports = {
create: context => ({
ImportDeclaration(node) {
const sourceValue = lodashGet(node, 'source.value');
if (!sourceValue) {
return;
}

if (isFromNodeModules(sourceValue)) {
return;
}

if (!node.specifiers || !node.specifiers.length) {
return;
}

if (!isEverySpecifierImport(node.specifiers)) {
return;
}

context.report({
node,
message,
});
},
}),
};
38 changes: 38 additions & 0 deletions eslint-plugin-expensify/prefer-onyx-connect-in-libs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const lodashGet = require('lodash/get');
const {isOnyxMethodCall, isInTestFile} = require('./utils');

const message = require('./CONST').MESSAGE.PREFER_ONYX_CONNECT_IN_LIBS;

/**
* @param {String} filename
* @returns {Boolean}
*/
function isInLibs(filename) {
return filename.includes('/src/libs/');
}

module.exports = {
create: context => ({
MemberExpression(node) {
const filename = context.getFilename();

if (!isOnyxMethodCall(node)) {
return;
}

if (isInTestFile(context.getFilename())) {
return;
}

const methodName = lodashGet(node, 'property.name');
if (methodName !== 'connect' || isInLibs(filename)) {
return;
}

context.report({
node,
message,
});
},
}),
};
Loading

0 comments on commit 211e217

Please sign in to comment.