Skip to content

Commit

Permalink
build(eslint): add custom eslint rules and eslint rule schematic
Browse files Browse the repository at this point in the history
BREAKING CHANGE: Switch linting from `tslint` to `eslint`.
  • Loading branch information
MaxKless committed Jan 17, 2022
1 parent c19c99c commit 3cb52b4
Show file tree
Hide file tree
Showing 52 changed files with 3,567 additions and 0 deletions.
3 changes: 3 additions & 0 deletions eslint-rules/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/node_modules
/package-lock.json
/dist
12 changes: 12 additions & 0 deletions eslint-rules/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "eslint-plugin-ish-custom-rules",
"description": "A plugin with Intershop's custom eslint rules",
"version": "0.0.1",
"main": "dist/index.js",
"files": [
"dist/**"
],
"scripts": {
"build": "tsc"
}
}
58 changes: 58 additions & 0 deletions eslint-rules/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';

/**
* Checks whether the given class declaration is of type 'Component'
*
* @param node the class declaration, which need to be checked
* @returns the result of the check
*/
export const isComponent = (node: TSESTree.ClassDeclaration): boolean => isType(node, ['Component']);

/**
* check the given class declaration to be from given types
*
* @param node the class declaration, which need to be checked
* @param types the wanted types (e.g. 'Component', 'Pipe', ...)
* @returns the result of the check
*/
export const isType = (node: TSESTree.ClassDeclaration, types: string[]): boolean => {
const decorator = node.decorators?.[0];
if (!decorator) {
return false;
}
return (
decorator.expression.type === AST_NODE_TYPES.CallExpression &&
decorator.expression.callee.type === AST_NODE_TYPES.Identifier &&
types.includes(decorator.expression.callee.name)
);
};

/**
* look for the first node in the currently-traversed node, which matches the searched node type
*
* @param context the context for search
* @param type the wanted node type
* @returns the matching node
*/
export function getClosestAncestorByKind(
context: TSESLint.RuleContext<string, readonly unknown[]>,
type: AST_NODE_TYPES
): TSESTree.Node {
const ancestors = context.getAncestors();
for (let i = ancestors.length - 1; i >= 0; i--) {
if (ancestors[i].type === type) {
return ancestors[i];
}
}
}

export const objectContainsProperty = (node: TSESTree.ObjectExpression, propName: string): boolean =>
node.properties.find(
prop =>
prop.type === AST_NODE_TYPES.Property && prop.key.type === AST_NODE_TYPES.Identifier && prop.key.name === propName
) !== undefined;

/**
* normalize path to avoid problems with different operating systems
*/
export const normalizePath = (filePath: string): string => filePath.replace(/\\/g, '/');
47 changes: 47 additions & 0 deletions eslint-rules/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { banImportsFilePatternRule } from './rules/ban-imports-file-pattern';
import { componentCreationTestRule } from './rules/component-creation-test';
import { newlineBeforeRootMembersRule } from './rules/newline-before-root-members';
import { noAssignmentToInputsRule } from './rules/no-assignment-to-inputs';
import { noCollapsibleIfRule } from './rules/no-collapsible-if';
import { noInitializeObservablesDirectlyRule } from './rules/no-initialize-observables-directly';
import { noIntelligenceInArtifactsRule } from './rules/no-intelligence-in-artifacts';
import { noObjectLiteralTypeAssertionRule } from './rules/no-object-literal-type-assertion';
import { noReturnUndefinedRule } from './rules/no-return-undefined';
import { noStarImportsInStoreRule } from './rules/no-star-imports-in-store';
import { noTestbedWithThenRule } from './rules/no-testbed-with-then';
import { noVarBeforeReturnRule } from './rules/no-var-before-return';
import { orderedImportsRule } from './rules/ordered-imports';
import { privateDestroyFieldRule } from './rules/private-destroy-field';
import { projectStructureRule } from './rules/project-structure';
import { useAliasImportsRule } from './rules/use-alias-imports';
import { useAsyncSynchronizationInTestsRule } from './rules/use-async-synchronization-in-tests';
import { useCamelCaseEnvironmentPropertiesRule } from './rules/use-camel-case-environment-properties';
import { useComponentChangeDetectionRule } from './rules/use-component-change-detection';
import { useJestExtendedMatchersInTestsRule } from './rules/use-jest-extended-matchers-in-tests';

const rules = {
'no-return-undefined': noReturnUndefinedRule,
'no-assignment-to-inputs': noAssignmentToInputsRule,
'use-component-change-detection': useComponentChangeDetectionRule,
'no-initialize-observables-directly': noInitializeObservablesDirectlyRule,
'no-intelligence-in-artifacts': noIntelligenceInArtifactsRule,
'use-async-synchronization-in-tests': useAsyncSynchronizationInTestsRule,
'no-testbed-with-then': noTestbedWithThenRule,
'component-creation-test': componentCreationTestRule,
'private-destroy-field': privateDestroyFieldRule,
'use-jest-extended-matchers-in-tests': useJestExtendedMatchersInTestsRule,
'ordered-imports': orderedImportsRule,
'use-camel-case-environment-properties': useCamelCaseEnvironmentPropertiesRule,
'no-star-imports-in-store': noStarImportsInStoreRule,
'use-alias-imports': useAliasImportsRule,
'no-collapsible-if': noCollapsibleIfRule,
'no-object-literal-type-assertion': noObjectLiteralTypeAssertionRule,
'ban-imports-file-pattern': banImportsFilePatternRule,
'project-structure': projectStructureRule,
'newline-before-root-members': newlineBeforeRootMembersRule,
'no-var-before-return': noVarBeforeReturnRule,
};

module.exports = {
rules,
};
83 changes: 83 additions & 0 deletions eslint-rules/src/rules/ban-imports-file-pattern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';

import { normalizePath } from '../helpers';

export interface RuleSetting {
filePattern: string;
name: string;
importNamePattern?: string;
starImport?: boolean;
message: string;
}

/**
* Allows you to specify imports that you don't want to use in files specified by a pattern.
*
* filePattern RegExp pattern to specify the file, which needs to be validated
* name RegExp pattern for the import source (path)
* importNamePattern RegExp pattern for the import specifier
* starImport validate the no import * as from given name are contained
* message error message, which should be displayed, when the validation failed
*/
export const banImportsFilePatternRule: TSESLint.RuleModule<string, RuleSetting[][]> = {
meta: {
messages: {
banImportsFilePatternError: `{{message}}`,
},
type: 'problem',
schema: [
{
type: 'array',
items: {
type: 'object',
properties: {
filePattern: { type: 'string' },
name: { type: 'string' },
importNames: { type: 'array' },
starImport: { type: 'boolean' },
message: { type: 'string' },
},
},
},
],
},
create: context => ({
ImportDeclaration(node: TSESTree.ImportDeclaration) {
const rules = context.options[0];
rules.forEach(rule => {
if (
new RegExp(rule.filePattern).test(normalizePath(context.getFilename())) &&
node.source.value === rule.name &&
checkValidityOfSpecifiers(node.specifiers, rule.importNamePattern, rule.starImport)
) {
context.report({
node,
data: {
message: `${rule.message}`,
},
messageId: 'banImportsFilePatternError',
});
} else {
return {};
}
});
},
}),
};

/**
* validate given rules against the specifiers of the current import declaration
*
* @param specifiers all specifiers of the import declaration
* @param importName the pattern to check
* @param starImport flag to check for star import
* @returns the validity of the specifiers
*/
const checkValidityOfSpecifiers = (
specifiers: TSESTree.ImportClause[],
importName: string,
starImport: boolean
): boolean =>
(!importName && !starImport) ||
(importName && specifiers.some(specifier => new RegExp(importName).test(specifier.local.name))) ||
(starImport && specifiers.some(specifier => specifier.type === TSESTree.AST_NODE_TYPES.ImportNamespaceSpecifier));
129 changes: 129 additions & 0 deletions eslint-rules/src/rules/component-creation-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';

import { normalizePath } from '../helpers';

const SHOULD_BE_CREATED_NAME = 'should be created';

/**
* Checks whether component tests contain at least a few standard test cases.
*/
export const componentCreationTestRule: TSESLint.RuleModule<string, []> = {
meta: {
messages: {
noDescribe: 'All component test files have to include a describe block.',
noCreationTest: `The component does not have an active '${SHOULD_BE_CREATED_NAME}' test`,
noComponentTruthyTest: `'${SHOULD_BE_CREATED_NAME}' block does not test if component is truthy`,
noElementTruthyTest: `'${SHOULD_BE_CREATED_NAME}' block does not test if html element is truthy`,
noFixtureDetectChangesTest: `'${SHOULD_BE_CREATED_NAME}' block does not test if feature.detectChanges does not throw`,
},
fixable: 'code',
type: 'problem',
schema: [],
},
create: context => {
// helper functions
function findComponentTruthy(node: TSESTree.Node) {
return (
context
.getSourceCode()
.getText(node)
.search(/.*component.*toBeTruthy.*/) >= 0
);
}
function findElementTruthy(node: TSESTree.Node) {
return (
context
.getSourceCode()
.getText(node)
.search(/.*lement.*toBeTruthy.*/) >= 0
);
}
function findFixtureDetectChanges(node: TSESTree.Node) {
return (
context
.getSourceCode()
.getText(node)
.search(/[\s\S]*fixture[\s\S]*detectChanges[\s\S]*not\.toThrow[\s\S]*/) >= 0
);
}

if (!(normalizePath(context.getFilename()).search(/.(component|container).spec.ts/) > 0)) {
return {};
}

let firstDescribe: TSESTree.CallExpression;

let creationTestNode: TSESTree.CallExpression;
let hasComponentTruthy = false;
let hasElementTruthy = false;
let hasFixtureDetectChanges = false;

return {
// find correct 'should be created' test that fulfills requirements
'CallExpression[callee.name="it"]'(node: TSESTree.CallExpression) {
// check if should be created test exists
if (
node.arguments.filter(arg => arg.type === AST_NODE_TYPES.Literal && arg.value === SHOULD_BE_CREATED_NAME)
.length > 0
) {
creationTestNode = { ...node };
// check if test fulfills all requirements
const body = (
node.arguments.find(
arg => arg.type === AST_NODE_TYPES.ArrowFunctionExpression
) as TSESTree.ArrowFunctionExpression
).body;
if (body.type === AST_NODE_TYPES.BlockStatement) {
hasComponentTruthy = body.body.some(findComponentTruthy);
hasElementTruthy = body.body.some(findElementTruthy);
hasFixtureDetectChanges = body.body.some(findFixtureDetectChanges);
}
}
},
// find first describe block to display the error at a relevant position
'CallExpression[callee.name="describe"]:exit'(node: TSESTree.CallExpression) {
if (!firstDescribe) {
firstDescribe = node;
}
},
// report errors after everything has been parsed
'Program:exit'(node: TSESTree.Program) {
// report missing describe block in empty test
if (!firstDescribe) {
context.report({
node,
messageId: 'noDescribe',
});
return;
}
// report missing test error at describe
if (!creationTestNode) {
context.report({
node: firstDescribe.callee,
messageId: 'noCreationTest',
});
return;
}
// report specific missing statement errors at creationTest
if (!hasComponentTruthy) {
context.report({
node: creationTestNode.callee,
messageId: 'noComponentTruthyTest',
});
}
if (!hasElementTruthy) {
context.report({
node: creationTestNode.callee,
messageId: 'noElementTruthyTest',
});
}
if (!hasFixtureDetectChanges) {
context.report({
node: creationTestNode.callee,
messageId: 'noFixtureDetectChangesTest',
});
}
},
};
},
};
49 changes: 49 additions & 0 deletions eslint-rules/src/rules/newline-before-root-members.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';

/**
* Checks whether root members of a typescript file (except for imports) are preceded by an empty line.
*/
export const newlineBeforeRootMembersRule: TSESLint.RuleModule<string, []> = {
meta: {
messages: {
newLineBeforeRootMembers: `New line missing`,
},
type: 'problem',
fixable: 'code',
schema: [],
},
create: context => ({
'Program:exit'(node: TSESTree.Program) {
const rootMembers = [...node.body];
for (let i = 0; i < rootMembers.length - 1; i++) {
const current = rootMembers[i];

// newline after the last import but not between imports themselves
if (isNewlineException(current) && rootMembers[i + 1].type === current.type) {
continue;
}

const lines = context.getSourceCode().getLines();
const currentLastLine = current.loc.end.line;

if (lines[currentLastLine] === '') {
continue;
}
context.report({
node: current,
messageId: 'newLineBeforeRootMembers',
fix: fixer => fixer.insertTextAfter(current, '\n'),
});
}
},
}),
};

function isNewlineException(node: TSESTree.Node) {
return (
node.type === AST_NODE_TYPES.ImportDeclaration ||
node.type === AST_NODE_TYPES.ExportNamedDeclaration ||
node.type === AST_NODE_TYPES.ExportDefaultDeclaration ||
node.type === AST_NODE_TYPES.ExportAllDeclaration
);
}
Loading

0 comments on commit 3cb52b4

Please sign in to comment.