diff --git a/eslint-rules/.gitignore b/eslint-rules/.gitignore new file mode 100644 index 0000000000..f0d5c24380 --- /dev/null +++ b/eslint-rules/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/package-lock.json +/dist diff --git a/eslint-rules/package.json b/eslint-rules/package.json new file mode 100644 index 0000000000..5cd3b2dfb6 --- /dev/null +++ b/eslint-rules/package.json @@ -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" + } +} diff --git a/eslint-rules/src/helpers.ts b/eslint-rules/src/helpers.ts new file mode 100644 index 0000000000..529d60d01f --- /dev/null +++ b/eslint-rules/src/helpers.ts @@ -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, + 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, '/'); diff --git a/eslint-rules/src/index.ts b/eslint-rules/src/index.ts new file mode 100644 index 0000000000..763521a66b --- /dev/null +++ b/eslint-rules/src/index.ts @@ -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, +}; diff --git a/eslint-rules/src/rules/ban-imports-file-pattern.ts b/eslint-rules/src/rules/ban-imports-file-pattern.ts new file mode 100644 index 0000000000..40e1d2ebee --- /dev/null +++ b/eslint-rules/src/rules/ban-imports-file-pattern.ts @@ -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 = { + 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)); diff --git a/eslint-rules/src/rules/component-creation-test.ts b/eslint-rules/src/rules/component-creation-test.ts new file mode 100644 index 0000000000..046303a446 --- /dev/null +++ b/eslint-rules/src/rules/component-creation-test.ts @@ -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 = { + 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', + }); + } + }, + }; + }, +}; diff --git a/eslint-rules/src/rules/newline-before-root-members.ts b/eslint-rules/src/rules/newline-before-root-members.ts new file mode 100644 index 0000000000..974e5e864a --- /dev/null +++ b/eslint-rules/src/rules/newline-before-root-members.ts @@ -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 = { + 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 + ); +} diff --git a/eslint-rules/src/rules/no-assignment-to-inputs.ts b/eslint-rules/src/rules/no-assignment-to-inputs.ts new file mode 100644 index 0000000000..778c28a191 --- /dev/null +++ b/eslint-rules/src/rules/no-assignment-to-inputs.ts @@ -0,0 +1,61 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +import { normalizePath } from '../helpers'; + +/** Disallows the reassignment of properties marked with angular's `@Input` decorator. + */ +export const noAssignmentToInputsRule: TSESLint.RuleModule = { + meta: { + messages: { + inputAssignmentError: `Assigning to @Input decorated properties is forbidden.`, + }, + type: 'problem', + schema: [], + }, + create: context => { + // only apply to component files + if (normalizePath(context.getFilename()).search(/.(component|container).ts/) < 0) { + return {}; + } + + const inputs: string[] = []; + + /** + * checks whether a ClassProperty is decorated with the @Input() decorator + */ + function checkIsInput(node: TSESTree.ClassProperty): boolean { + const decorators = node.decorators; + if (!decorators || !decorators.length) { + return false; + } + return decorators + .filter(decorator => decorator.expression.type === AST_NODE_TYPES.CallExpression) + .map(decorator => decorator.expression as TSESTree.CallExpression) + .map(callExpression => + callExpression.callee.type === AST_NODE_TYPES.Identifier ? callExpression.callee.name : '' + ) + .reduce((acc, curr) => curr === 'Input' || acc, false); + } + + return { + ClassProperty(node): void { + if (checkIsInput(node)) { + inputs.push(node.key.type === AST_NODE_TYPES.Identifier ? node.key.name : ''); + } + }, + AssignmentExpression(node) { + if ( + node.left.type === AST_NODE_TYPES.MemberExpression && + node.left.object.type === AST_NODE_TYPES.ThisExpression && + node.left.property.type === AST_NODE_TYPES.Identifier && + inputs.includes(node.left.property.name) + ) { + context.report({ + node, + messageId: 'inputAssignmentError', + }); + } + }, + }; + }, +}; diff --git a/eslint-rules/src/rules/no-collapsible-if.ts b/eslint-rules/src/rules/no-collapsible-if.ts new file mode 100644 index 0000000000..8b0ff4077e --- /dev/null +++ b/eslint-rules/src/rules/no-collapsible-if.ts @@ -0,0 +1,77 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +/** + * Finds and merges collapsible if statements. + */ +export const noCollapsibleIfRule: TSESLint.RuleModule = { + meta: { + messages: { + noCollapsibleIfError: `If-statements can be merged.`, + }, + fixable: 'code', + type: 'problem', + schema: [], + }, + create: context => ({ + IfStatement(node: TSESTree.IfStatement) { + const condition2 = collapsibleIfStatement(node); + if (condition2) { + context.report({ + node, + messageId: 'noCollapsibleIfError', + fix: fixer => + fixer.replaceText( + node, + collapseIf({ sourceCode: context.getSourceCode(), node, condition1: node.test, condition2 }) + ), + }); + } + }, + }), +}; + +/** + * check whether the if statement can be merged + * + * @param node the current if statement + * @returns the second condition, when if statement is collapsible, otherwise false + */ +function collapsibleIfStatement(node: TSESTree.IfStatement): TSESTree.Expression | false { + return node.consequent && + !node.alternate && + node.consequent.type === AST_NODE_TYPES.BlockStatement && + node.consequent.body.length === 1 && + node.consequent.body[0].type === AST_NODE_TYPES.IfStatement && + !node.consequent.body[0].alternate + ? node.consequent.body[0].test + : false; +} + +/** + * build the collapsed if statement + * + * @param param sourceCode The source code of the current if statement + * @param param node The current if statement + * @param param condition1 The test expression of the first if + * @param param condition2 The test expression of the second if + * @returns the source code with merged if statements + */ +function collapseIf({ + sourceCode, + node, + condition1, + condition2, +}: { + sourceCode: Readonly; + node: TSESTree.IfStatement; + condition1: TSESTree.Expression; + condition2: TSESTree.Expression; +}): string { + const firstIfCondition: string = sourceCode.getText(condition1); + const secondIfCondition = sourceCode.getText(condition2); + let consequent = sourceCode.getText(node.consequent).replace(/(if( )*\([^\{]*\{)[\r\n]/, ''); + const reg = new RegExp('[\r\n]( )*}', 'g'); + reg.exec(consequent); + consequent = consequent.replace(consequent.substring(reg.lastIndex, consequent.lastIndexOf('}') + 1), ''); + return `if((${firstIfCondition}) && (${secondIfCondition}))${consequent}`; +} diff --git a/eslint-rules/src/rules/no-initialize-observables-directly.ts b/eslint-rules/src/rules/no-initialize-observables-directly.ts new file mode 100644 index 0000000000..ff659486ba --- /dev/null +++ b/eslint-rules/src/rules/no-initialize-observables-directly.ts @@ -0,0 +1,37 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +import { isComponent } from '../helpers'; + +/** + * Forbids the direct initialization of observables . + * Don't initialize observable class properties directly, for example through `new Observable()`. + * Use `ngOnInit` instead. + */ +export const noInitializeObservablesDirectlyRule: TSESLint.RuleModule = { + meta: { + messages: { + wrongInitializeError: 'Observable stream should be initialized in ngOnInit', + }, + type: 'problem', + schema: [], + }, + create: context => ({ + 'ClassProperty[value.type="NewExpression"]'(node: TSESTree.ClassProperty) { + if (!isComponent(node.parent.parent as TSESTree.ClassDeclaration)) { + return; + } + const newExpression = node.value as TSESTree.NewExpression; + if ( + node.key.type === AST_NODE_TYPES.Identifier && + node.key.name.match(/\w*\$$/) && + newExpression.callee.type === AST_NODE_TYPES.Identifier && + !newExpression.callee.name.includes('Subject') + ) { + context.report({ + node, + messageId: 'wrongInitializeError', + }); + } + }, + }), +}; diff --git a/eslint-rules/src/rules/no-intelligence-in-artifacts.ts b/eslint-rules/src/rules/no-intelligence-in-artifacts.ts new file mode 100644 index 0000000000..88ffde956d --- /dev/null +++ b/eslint-rules/src/rules/no-intelligence-in-artifacts.ts @@ -0,0 +1,108 @@ +import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +import { normalizePath } from '../helpers'; + +export type RuleSetting = { + ngrx: string; + service: string; + router: string; + facade: string; +}; + +/** + * Disallows the use of certain code artifacts (ngrx, service, router or facade usage) in certain files. + * + * Each key of the configuration object is a regular expression. + * It will be matched to check for the unwanted code artifacts and provide a specific error message. + */ +export const noIntelligenceInArtifactsRule: TSESLint.RuleModule[]> = { + meta: { + messages: { + noIntelligenceError: `{{ error }}`, + }, + type: 'problem', + schema: [ + { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + ngrx: { type: 'string' }, + service: { type: 'string' }, + router: { type: 'string' }, + facade: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + ], + }, + create: context => { + // helper methods to check artifacts and report errors + function checkService(imp: string, node: TSESTree.ImportDeclaration) { + if (rules.service && /\/services(\/|$)/.test(imp)) { + context.report({ + node, + data: { + error: rules.service, + }, + messageId: 'noIntelligenceError', + }); + } + } + + function checkNgrx(imp: string, node: TSESTree.ImportDeclaration) { + if (rules.ngrx && (/\/store\//.test(imp) || imp.startsWith('@ngrx') || imp.endsWith('/ngrx-testing'))) { + context.report({ + node, + data: { + error: rules.ngrx, + }, + messageId: 'noIntelligenceError', + }); + } + } + + function checkFacade(imp: string, node: TSESTree.ImportDeclaration) { + if (rules.facade && (/\/facades\//.test(imp) || imp.endsWith('.facade'))) { + context.report({ + node, + data: { + error: rules.facade, + }, + messageId: 'noIntelligenceError', + }); + } + } + + function checkRouter(imp: string, node: TSESTree.ImportDeclaration) { + if (rules.router && imp.startsWith('@angular/router')) { + context.report({ + node, + data: { + error: rules.router, + }, + messageId: 'noIntelligenceError', + }); + } + } + + const [options] = context.options; + const ruleMatch = Object.keys(options).find(regexp => + new RegExp(regexp).test(normalizePath(context.getFilename())) + ); + if (!ruleMatch) { + return {}; + } + const rules = options[ruleMatch]; + return { + ImportDeclaration(node) { + const imp = node.source.value.toString(); + checkService(imp, node); + checkNgrx(imp, node); + checkFacade(imp, node); + checkRouter(imp, node); + }, + }; + }, +}; diff --git a/eslint-rules/src/rules/no-object-literal-type-assertion.ts b/eslint-rules/src/rules/no-object-literal-type-assertion.ts new file mode 100644 index 0000000000..fedb04fb40 --- /dev/null +++ b/eslint-rules/src/rules/no-object-literal-type-assertion.ts @@ -0,0 +1,32 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +import { normalizePath } from '../helpers'; + +/** + * Disallows type assertions (`exampleObject as ExampleType`) on object literals. + */ +export const noObjectLiteralTypeAssertionRule: TSESLint.RuleModule = { + meta: { + messages: { + noObjectLiteralTypeAssertionError: `Type assertion on object literals is forbidden, use a type annotation instead.`, + }, + type: 'problem', + schema: [], + }, + create: context => { + const filePattern = /^((?!(\/dev\/|\/eslint-rules\/|spec.ts$)).)*$/; + if (filePattern.test(normalizePath(context.getFilename()))) { + return { + TSAsExpression(node: TSESTree.TSAsExpression) { + if (node.expression.type === AST_NODE_TYPES.ObjectExpression) { + context.report({ + node, + messageId: 'noObjectLiteralTypeAssertionError', + }); + } + }, + }; + } + return {}; + }, +}; diff --git a/eslint-rules/src/rules/no-return-undefined.ts b/eslint-rules/src/rules/no-return-undefined.ts new file mode 100644 index 0000000000..37956e2312 --- /dev/null +++ b/eslint-rules/src/rules/no-return-undefined.ts @@ -0,0 +1,26 @@ +import { AST_NODE_TYPES, TSESLint } from '@typescript-eslint/experimental-utils'; + +/** + * Disallows explicitly returning `undefined`. Use an empty return instead. + */ +export const noReturnUndefinedRule: TSESLint.RuleModule = { + meta: { + messages: { + undefinedError: `Don't return undefined explicitly. Use an empty return instead.`, + }, + fixable: 'code', + type: 'problem', + schema: [], + }, + create: context => ({ + ReturnStatement(node) { + if (node.argument?.type === AST_NODE_TYPES.Identifier && node.argument.name === 'undefined') { + context.report({ + node, + messageId: 'undefinedError', + fix: fixer => fixer.remove(node.argument), + }); + } + }, + }), +}; diff --git a/eslint-rules/src/rules/no-star-imports-in-store.ts b/eslint-rules/src/rules/no-star-imports-in-store.ts new file mode 100644 index 0000000000..01e2c99006 --- /dev/null +++ b/eslint-rules/src/rules/no-star-imports-in-store.ts @@ -0,0 +1,89 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +import { normalizePath } from '../helpers'; + +/** + * Disallows the usage of star/namespace imports (`import * as exampleActions from './example.actions`). + * + * Import what you need individually instead: + * + * `import { firstAction, secondAction } from './example.actions'` + */ +export const noStarImportsInStoreRule: TSESLint.RuleModule = { + meta: { + messages: { + starImportError: `Don't use star imports in store files. Import what you need individually instead. `, + }, + type: 'problem', + fixable: 'code', + schema: [], + }, + create: context => { + // helpers + function reportErrors(starImport: TSESTree.ImportDeclaration) { + const getImportFix = (fixer: TSESLint.RuleFixer) => + fixer.replaceText( + starImport, + `import { ${ + starImportUsageMap[importSpecifier] + ?.map(memberDeclaration => + memberDeclaration.property.type === AST_NODE_TYPES.Identifier ? memberDeclaration.property.name : '' + ) + .filter(v => !!v) + .join(', ') ?? '' + } } from '${starImport.source.value}'` + ); + + const getUsageFixes = (fixer: TSESLint.RuleFixer) => [ + ...starImportUsageMap[importSpecifier]?.map(usage => + fixer.replaceText(usage, context.getSourceCode().getText(usage.property)) + ), + ]; + + const importSpecifier = starImport.specifiers[0].local.name; + context.report({ + node: starImport, + messageId: 'starImportError', + fix: getImportFix, + }); + starImportUsageMap[importSpecifier]?.forEach(usage => { + context.report({ + node: usage, + messageId: 'starImportError', + fix: getUsageFixes, + }); + }); + } + + if (!/^.*\.(effects|reducer|actions|selectors)\.(ts|spec\.ts)$/.test(normalizePath(context.getFilename()))) { + return {}; + } + const starImports: TSESTree.ImportDeclaration[] = []; + const starImportUsageMap: Record = {}; + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + if (node.specifiers.some(spec => spec.type === AST_NODE_TYPES.ImportNamespaceSpecifier)) { + starImports.push(node); + } + }, + MemberExpression(node) { + const usedStarImport = starImports.find( + imp => node.object.type === AST_NODE_TYPES.Identifier && imp.specifiers[0].local.name === node.object.name + ); + if (usedStarImport) { + const importSpecifier = usedStarImport.specifiers[0].local.name; + if (starImportUsageMap[importSpecifier]?.length) { + starImportUsageMap[importSpecifier].push(node); + } else { + starImportUsageMap[importSpecifier] = [node]; + } + } + }, + 'Program:exit'() { + starImports.forEach(starImport => { + reportErrors(starImport); + }); + }, + }; + }, +}; diff --git a/eslint-rules/src/rules/no-testbed-with-then.ts b/eslint-rules/src/rules/no-testbed-with-then.ts new file mode 100644 index 0000000000..dbccf6a63e --- /dev/null +++ b/eslint-rules/src/rules/no-testbed-with-then.ts @@ -0,0 +1,41 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +import { normalizePath } from '../helpers'; + +/** + * Disallows chaining off TestBed.configureTestingModule. + * Use another beforeEach block instead. + */ +export const noTestbedWithThenRule: TSESLint.RuleModule = { + meta: { + messages: { + testbedWithThenError: `Chaining off TestBed.configureTestingModule can be replaced by adding another beforeEach block without async.`, + }, + type: 'problem', + schema: [], + }, + create: context => { + if (!normalizePath(context.getFilename()).endsWith('.spec.ts')) { + return {}; + } + return { + 'MemberExpression[object.name="TestBed"][property.name="configureTestingModule"]'() { + // filter ancestors manually so we can report an error at the 'then' position + const thenNode = context + .getAncestors() + .filter( + ancestor => + ancestor.type === AST_NODE_TYPES.MemberExpression && + ancestor.property.type === AST_NODE_TYPES.Identifier && + ancestor.property.name === 'then' + ); + if (thenNode.length > 0) { + context.report({ + node: (thenNode[0] as TSESTree.MemberExpression).property, + messageId: 'testbedWithThenError', + }); + } + }, + }; + }, +}; diff --git a/eslint-rules/src/rules/no-var-before-return.ts b/eslint-rules/src/rules/no-var-before-return.ts new file mode 100644 index 0000000000..6e3d3c2548 --- /dev/null +++ b/eslint-rules/src/rules/no-var-before-return.ts @@ -0,0 +1,51 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +function getElementBeforeReturnStatement(node: TSESTree.ReturnStatement): TSESTree.Statement { + if (node.parent.type === AST_NODE_TYPES.BlockStatement) { + const index = node.parent.body.findIndex(element => element.type === AST_NODE_TYPES.ReturnStatement); + return node.parent.body[index - 1]; + } +} + +function checkNoVarBeforeReturn(node: TSESTree.ReturnStatement): boolean { + const elementBeforeReturn = getElementBeforeReturnStatement(node); + return ( + elementBeforeReturn?.type === AST_NODE_TYPES.VariableDeclaration && + elementBeforeReturn.declarations[0].id.type === AST_NODE_TYPES.Identifier && + node.argument.type === AST_NODE_TYPES.Identifier && + elementBeforeReturn.declarations[0].id.name === node.argument.name + ); +} + +export const noVarBeforeReturnRule: TSESLint.RuleModule = { + meta: { + messages: { + varError: `Don't return a variable, which is declared right before. Return the variable value instead.`, + }, + fixable: 'code', + type: 'problem', + schema: [], + }, + create: context => ({ + ReturnStatement(node) { + if (checkNoVarBeforeReturn(node)) { + const variable = getElementBeforeReturnStatement(node); + const loc = { + start: variable.loc.start, + end: node.loc.end, + }; + if (variable.type === AST_NODE_TYPES.VariableDeclaration) { + const sourceCode = context.getSourceCode(); + context.report({ + loc, + messageId: 'varError', + fix: fixer => [ + fixer.removeRange([variable.range[0], node.range[0]]), + fixer.replaceText(node.argument, sourceCode.getText(variable.declarations[0].init)), + ], + }); + } + } + }, + }), +}; diff --git a/eslint-rules/src/rules/ordered-imports.ts b/eslint-rules/src/rules/ordered-imports.ts new file mode 100644 index 0000000000..f266dfd762 --- /dev/null +++ b/eslint-rules/src/rules/ordered-imports.ts @@ -0,0 +1,128 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +/** + * Enforces a certain ordering and grouping of imports: + * + * 1. Sort import statements by source. + * 2. Sort import specifiers by name in each import statement. + * 3. Group imports by category and in a fixed order: + * - general/framework imports + * - `ish-` imports + * - relative imports starting with `../` + * - relative imports from the same folder, starting with `./` + */ +export const orderedImportsRule: TSESLint.RuleModule = { + meta: { + messages: { + unorderedImports: `Imports are not ordered correctly.`, + }, + type: 'problem', + fixable: 'code', + schema: [], + }, + create: context => { + // helpers + + function getSortedImportAsString(node: TSESTree.ImportDeclaration): string { + const nodeText = context.getSourceCode().getText(node); + if (isDefaultOrNamespaceOrSideeffectImport(node)) { + return nodeText; + } + const sortedNamedImports = (node.specifiers as TSESTree.ImportSpecifier[]) + .map(imp => context.getSourceCode().getText(imp)) + .sort(); + + return nodeText.replace(/\{.*\}/, `{ ${sortedNamedImports.join(', ')} }`); + } + + const importDeclarations: TSESTree.ImportDeclaration[] = []; + const lineEnding = `\n`; + + return { + ImportDeclaration(node) { + importDeclarations.push(node); + }, + 'Program:exit'(node: TSESTree.Program) { + if (!importDeclarations.length) { + return; + } + + // get location for correct error reporting & replacement + const importSourceLocation = { + start: importDeclarations[0].loc.start, + end: importDeclarations[importDeclarations.length - 1].loc.end, + }; + + // group imports by source + const groups: Record = importDeclarations.reduce((acc, val) => { + const order = getGroupingOrder(val); + acc[order] = [...(acc?.[order] ?? []), val]; + return acc; + }, {}); + + // sort import statements by source & imports per statement + const sorter = (leftImport, rightImport) => getFromString(leftImport).localeCompare(getFromString(rightImport)); + const newImports: string = Object.keys(groups) + .map(orderString => parseInt(orderString, 10)) + .sort() + .filter(order => !!groups[order]) + .map(order => groups[order].sort(sorter).map(getSortedImportAsString).join(lineEnding)) + .join(`${lineEnding}${lineEnding}`); + + // remove old imports and insert new ones + const originalImports: string = context + .getSourceCode() + .getLines() + .filter((_, index) => index >= importSourceLocation.start.line - 1 && index < importSourceLocation.end.line) + .join(lineEnding); + + if (originalImports.trim() !== newImports.trim()) { + context.report({ + loc: importSourceLocation, + messageId: 'unorderedImports', + fix: fixer => [ + // remove everything up to the last import + fixer.removeRange([node.range[0], importDeclarations[importDeclarations.length - 1].range[1]]), + // insert new imports at start of file + fixer.insertTextBefore(node, newImports), + ], + }); + } + }, + }; + }, +}; + +// get value from import statement +function getFromString(node: TSESTree.ImportDeclaration): string { + return node.source.value.toString(); +} + +/** + * get order numbers used for grouping imports: + * 0 - all imports + * 1 - imports starting with `ish-` + * 2 - imports starting with `..` + * 3 . imports starting with `.` + */ +function getGroupingOrder(node: TSESTree.ImportDeclaration): number { + const fromStatement = getFromString(node); + if (!fromStatement.startsWith('.') && !fromStatement.startsWith('ish')) { + return 0; + } else if (fromStatement.startsWith('ish')) { + return 1; + } else if (fromStatement.startsWith('..')) { + return 2; + } else { + return 3; + } +} + +// check whether import is default or namespace import +function isDefaultOrNamespaceOrSideeffectImport(node: TSESTree.ImportDeclaration) { + return ( + !node.specifiers.length || + node.specifiers[0].type === AST_NODE_TYPES.ImportNamespaceSpecifier || + node.specifiers[0].type === AST_NODE_TYPES.ImportDefaultSpecifier + ); +} diff --git a/eslint-rules/src/rules/private-destroy-field.ts b/eslint-rules/src/rules/private-destroy-field.ts new file mode 100644 index 0000000000..a29a9ea542 --- /dev/null +++ b/eslint-rules/src/rules/private-destroy-field.ts @@ -0,0 +1,41 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +import { isType } from '../helpers'; + +/** + * Validates and fixes missing private accessibility for the destroy$ property in components, directives and pipes. + */ +export const privateDestroyFieldRule: TSESLint.RuleModule = { + meta: { + messages: { + privateDestroyError: `Property should be private.`, + }, + fixable: 'code', + type: 'problem', + schema: [], + }, + create: context => ({ + 'ClassProperty[value.type="NewExpression"]'(node: TSESTree.ClassProperty) { + if (!isType(node.parent.parent as TSESTree.ClassDeclaration, ['Component', 'Directive', 'Pipe'])) { + return; + } + + if ( + node.key.type === AST_NODE_TYPES.Identifier && + node.key.name.match(/^destroy(\$|)$/) && + node.accessibility !== 'private' + ) { + // replace access modifier or lack thereof with private + const replaceText = context + .getSourceCode() + .getText(node) + .replace(/^(.*)(?=destroy\$)/, 'private '); + context.report({ + node, + messageId: 'privateDestroyError', + fix: fixer => fixer.replaceText(node, replaceText), + }); + } + }, + }), +}; diff --git a/eslint-rules/src/rules/project-structure.ts b/eslint-rules/src/rules/project-structure.ts new file mode 100644 index 0000000000..8e0d65e904 --- /dev/null +++ b/eslint-rules/src/rules/project-structure.ts @@ -0,0 +1,198 @@ +import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +import { normalizePath } from '../helpers'; + +export interface RuleSetting { + warnUnmatched: boolean; + ignoredFiles: string[]; + pathPatterns: string[]; + reusePatterns: { + name: string; + theme: string; + }; + patterns: { + name: string; + file: string; + }[]; +} + +/** + * Allows you to check file paths, file names and containing class names against specified patterns. + * + * warnUnmatched: enables printing an error when the file and its containing class name have no match. + * reusePatterns: named RegExp patterns which can be used (like: ) in the following patterns. + * pathPatterns: RegExp patterns to check the directory/path of the file. + * patterns: RegExp patterns to check whether the class name matches its file. + * ignoredFiles: RegExp patterns files which should be ignored. + */ +export const projectStructureRule: TSESLint.RuleModule = { + meta: { + messages: { + projectStructureError: `{{message}}`, + }, + type: 'problem', + schema: [ + { + type: 'object', + additionalProperties: { + warnUnmatched: { type: 'boolean' }, + ignoredFiles: { + type: 'array', + items: { + type: 'string', + }, + }, + pathPatterns: { + type: 'array', + items: { + type: 'string', + }, + }, + reusePatterns: { + type: 'object', + properties: { + name: { type: 'string' }, + theme: { type: 'string' }, + }, + additionalProperties: false, + }, + patterns: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + file: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + }, + }, + ], + }, + create: context => { + const config = { ...context.options[0] }; + const filePath = normalizePath(context.getFilename()); + // make sure arrays exist + config.ignoredFiles = config.ignoredFiles ?? []; + config.pathPatterns = config.pathPatterns ?? []; + config.patterns = config.patterns ?? []; + + // don't continue for ignored files + if (isIgnoredFile(config.ignoredFiles, config.reusePatterns, filePath)) { + return {}; + } + return { + 'Program:exit'(node: TSESTree.Program) { + // return error, when configured pattern doesn't match + if (!matchPathPattern(config.pathPatterns, config.reusePatterns, filePath)) { + context.report({ + node: node.body[0], + messageId: 'projectStructureError', + data: { + message: `${filePath} this file path does not match any defined patterns.`, + }, + }); + } + }, + 'ClassDeclaration > Identifier.id'(node: TSESTree.Identifier): void { + const matchPatternError = matchPattern( + config.patterns, + config.warnUnmatched, + config.reusePatterns, + node.name, + filePath + ); + // return error, when the class name doesn't match to the according file pattern + if (matchPatternError !== '') { + context.report({ + node, + data: { + message: matchPatternError, + }, + messageId: 'projectStructureError', + }); + } + }, + }; + }, +}; + +/** + * replace in the pattern string, which is going to be checked reuse pattern like name or theme + */ +function reusePattern(pattern: string, reusePatterns: { [name: string]: string }): string { + return pattern.replace(/<(.*?)>/g, (original, reuse) => reusePatterns?.[reuse] ?? original); +} + +/** + * return true the current file to check matches to the ignored file list + */ +function isIgnoredFile(ignoredFiles: string[], reusePatterns: { [name: string]: string }, filePath: string): boolean { + if (ignoredFiles.length === 0) { + return false; + } + return ignoredFiles.some(ignoredFile => new RegExp(reusePattern(ignoredFile, reusePatterns)).test(filePath)); +} + +/** + * return true the current file to check matches to the path pattern list + */ +function matchPathPattern( + pathPatterns: string[], + reusePatterns: { [name: string]: string }, + filePath: string +): boolean { + return pathPatterns.some(pattern => new RegExp(reusePattern(pattern, reusePatterns)).test(filePath)); +} + +/** + * return error string, when the class name doesn't match the according path pattern for the current file + */ +function matchPattern( + patterns: { name: string; file: string }[], + warnUnmatched: boolean, + reusePatterns: { [name: string]: string }, + className: string, + filePath: string +): string { + const matchingPatterns = patterns + .map(pattern => ({ pattern, match: new RegExp(reusePattern(pattern.name, reusePatterns)).exec(className) })) + .filter(x => !!x.match); + if (matchingPatterns.length >= 1 && matchingPatterns[0].match[1]) { + const config = matchingPatterns[0]; + const matched = config.match[1]; + const pathPattern = config.pattern.file + .replace(//g, kebabCaseFromPascalCase(matched)) + .replace(//g, camelCaseFromPascalCase(matched)); + + if (!new RegExp(reusePattern(pathPattern, reusePatterns)).test(filePath)) { + return `'${className}' is not in the correct file (expected '${new RegExp( + reusePattern(pathPattern, reusePatterns) + )}')`; + } + } else if (matchingPatterns.length === 0 && warnUnmatched) { + return `no pattern match for ${className} in file ${filePath}`; + } + return ''; +} + +/** + * return the string in kebab case format + */ +const kebabCaseFromPascalCase = (input: string): string => + input + .replace(/[A-Z]{2,}$/, m => `${m.substring(0, 1)}${m.substring(1, m.length).toLowerCase()}`) + .replace( + /[A-Z]{3,}/g, + m => `${m.substring(0, 1)}${m.substring(1, m.length - 1).toLowerCase()}${m.substring(m.length - 1, m.length)}` + ) + .replace(/[A-Z]/g, match => `-${match.toLowerCase()}`) + .replace(/^-/, ''); + +/** + * return the string in camel case format + */ +const camelCaseFromPascalCase = (input: string): string => + `${input.substring(0, 1).toLowerCase()}${input.substring(1)}`; diff --git a/eslint-rules/src/rules/use-alias-imports.ts b/eslint-rules/src/rules/use-alias-imports.ts new file mode 100644 index 0000000000..1813ae7065 --- /dev/null +++ b/eslint-rules/src/rules/use-alias-imports.ts @@ -0,0 +1,86 @@ +import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; +import { parse } from 'comment-json'; +import * as fs from 'fs'; + +import { normalizePath } from '../helpers'; + +/** + * Finds and replaces import paths which can be simplified by import aliases. + */ +export const useAliasImportsRule: TSESLint.RuleModule = { + meta: { + messages: { + noAlias: `Import path should rely on {{alias}}`, + }, + type: 'problem', + fixable: 'code', + schema: [], + }, + create: context => { + const filePath = normalizePath(context.getFilename()); + const basePath = filePath.substring(0, filePath.lastIndexOf('/')); + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + const literalString = node.source.value.toString(); + const absPath = calculateAbsolutePath(basePath, literalString); + if (absPath) { + const aliasImports = getAliasImports(); + aliasImports.forEach(({ pattern, alias }) => { + if (new RegExp(pattern).test(absPath)) { + context.report({ + node, + messageId: 'noAlias', + data: { + alias, + }, + fix: fixer => fixer.replaceText(node.source, `'${alias}${absPath.replace(new RegExp(pattern), '')}'`), + }); + } + }); + } + }, + }; + }, +}; + +/** + * calculate the absolute path for the given import + * + * @param basePath path to the current file, without the filename + * @param literal path of the import + * @returns the absolute path of the import + */ +function calculateAbsolutePath(basePath: string, literal: string): string { + if (literal.startsWith('..')) { + const myPath = basePath.split('/'); + const otherPath = literal.split('/').reverse(); + while (otherPath.length && otherPath[otherPath.length - 1] === '..') { + otherPath.pop(); + myPath.pop(); + } + for (const el of otherPath.reverse()) { + myPath.push(el); + } + return myPath.join('/'); + } +} + +/** + * get paths with its alias names from the tsconfig.json + * + * @returns pairs of path patterns and alias strings, which can be resolved by the tsconfig.json + */ +function getAliasImports(): { pattern: string; alias: string }[] { + try { + const config = parse(fs.readFileSync('./tsconfig.json', { encoding: 'utf-8' })); + if (config && config.compilerOptions && config.compilerOptions.paths) { + const paths = config.compilerOptions.paths; + return Object.keys(paths).map(key => ({ + pattern: `.*/${paths[key][0].replace(/\/\*$/, '/')}`, + alias: key.replace(/\/\*$/, '/'), + })); + } + } catch (err) { + console.warn(err); + } +} diff --git a/eslint-rules/src/rules/use-async-synchronization-in-tests.ts b/eslint-rules/src/rules/use-async-synchronization-in-tests.ts new file mode 100644 index 0000000000..a447290d51 --- /dev/null +++ b/eslint-rules/src/rules/use-async-synchronization-in-tests.ts @@ -0,0 +1,104 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +import { normalizePath } from '../helpers'; + +/** + * Enforces the usage of a done() callback in tests that rely on asynchronous logic (subscribe calls). + */ +export const useAsyncSynchronizationInTestsRule: TSESLint.RuleModule = { + meta: { + messages: { + noDoneError: `asynchronous operations in tests should call done callback, see https://facebook.github.io/jest/docs/en/asynchronous.html`, + }, + type: 'problem', + schema: [], + }, + create: context => { + // helper functions + function isDoneCallback(arg: TSESTree.Node): boolean { + const text = context.getSourceCode().getText(arg); + return text === 'done' || text.search(/\sdone\(\)/) >= 0; + } + + function isDoneCallbackPartialSubscriber(arg: TSESTree.CallExpressionArgument): boolean { + return ( + arg.type === AST_NODE_TYPES.ObjectExpression && + arg.properties + .filter(p => p.type === AST_NODE_TYPES.Property) + .filter((p: TSESTree.Property) => + ['complete', 'error', 'next'].includes(context.getSourceCode().getText(p.key)) + ) + .some((p: TSESTree.Property) => isDoneCallback(p.value)) + ); + } + + function isDoneInSetTimeout(statement: TSESTree.Statement): boolean { + return ( + statement.type === AST_NODE_TYPES.ExpressionStatement && + statement.expression.type === AST_NODE_TYPES.CallExpression && + statement.expression.callee.type === AST_NODE_TYPES.Identifier && + statement.expression.callee.name === 'setTimeout' && + isDoneCallback(statement.expression.arguments[0]) + ); + } + + function isDoneCalledExplicitly(statement: TSESTree.Statement): boolean { + return ( + statement.type === AST_NODE_TYPES.ExpressionStatement && + statement.expression.type === AST_NODE_TYPES.CallExpression && + statement.expression.callee.type === AST_NODE_TYPES.Identifier && + statement.expression.callee.name === 'done' + ); + } + + function arrowFunctionBodyContainsDone(body: TSESTree.BlockStatement | TSESTree.Expression): boolean { + return ( + body.type === AST_NODE_TYPES.BlockStatement && + body.body.some(statement => isDoneInSetTimeout(statement) || isDoneCalledExplicitly(statement)) + ); + } + + if (!normalizePath(context.getFilename()).endsWith('.spec.ts')) { + return {}; + } + return { + 'CallExpression > MemberExpression[property.name="subscribe"]'(memberExpNode: TSESTree.MemberExpression) { + // check if arguments contain done callback or partial subscriber with done callback + const callExp = memberExpNode.parent as TSESTree.CallExpression; + if (callExp.arguments.some(arg => isDoneCallback(arg) || isDoneCallbackPartialSubscriber(arg))) { + return; + } + + const ancestors = context.getAncestors(); + + // check if subscribe is contained in fakeAsync + if ( + ancestors.filter( + a => + a.type === AST_NODE_TYPES.CallExpression && + a.callee.type === AST_NODE_TYPES.Identifier && + a.callee.name === 'fakeAsync' + ).length > 0 + ) { + return; + } + // check if done is used in block outside of subscribe + if ( + ancestors.filter( + a => + a.type === AST_NODE_TYPES.ArrowFunctionExpression && + a.params.some(p => context.getSourceCode().getText(p) === 'done') && + arrowFunctionBodyContainsDone(a.body) + ).length > 0 + ) { + return; + } + + context.report({ + node: callExp, + messageId: 'noDoneError', + }); + }, + }; + }, +}; diff --git a/eslint-rules/src/rules/use-camel-case-environment-properties.ts b/eslint-rules/src/rules/use-camel-case-environment-properties.ts new file mode 100644 index 0000000000..e01558d474 --- /dev/null +++ b/eslint-rules/src/rules/use-camel-case-environment-properties.ts @@ -0,0 +1,47 @@ +import { AST_NODE_TYPES, TSESLint } from '@typescript-eslint/experimental-utils'; + +import { normalizePath } from '../helpers'; + +/** + * Validates and fixes the environment.*.ts files to contain only property signatures in camelCase format. + */ +export const useCamelCaseEnvironmentPropertiesRule: TSESLint.RuleModule = { + meta: { + messages: { + camelCaseError: `Property {{property}} is not camelCase formatted.`, + }, + type: 'problem', + fixable: 'code', + schema: [], + }, + create(context) { + if (normalizePath(context.getFilename()).match(/[\\\/\w\-\:]*\/environment[\.\w]*\.ts$/)) { + return { + Identifier(node) { + if ( + node.parent.type === AST_NODE_TYPES.TSPropertySignature && + !node.name.match(/^([a-z][A-Za-z0-9]*|[a-z][a-z]_[A-Z][A-Z])$/) + ) { + context.report({ + node, + messageId: 'camelCaseError', + data: { + property: node.name, + }, + fix: fixer => { + const camelCaseName = node.name + .replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => + index === 0 ? word.toLowerCase() : word.toUpperCase() + ) + .replace(/(?:_[a-zA-Z0-9])/g, word => word.substring(1).toLocaleUpperCase()) + .replace(/\s+/g, ''); + return fixer.replaceText(node, camelCaseName); + }, + }); + } + }, + }; + } + return {}; + }, +}; diff --git a/eslint-rules/src/rules/use-component-change-detection.ts b/eslint-rules/src/rules/use-component-change-detection.ts new file mode 100644 index 0000000000..6dad2bf4ed --- /dev/null +++ b/eslint-rules/src/rules/use-component-change-detection.ts @@ -0,0 +1,46 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +import { normalizePath, objectContainsProperty } from '../helpers'; + +/** + * Enforces the explicit declaration of `changeDetection` in component decorators. + */ +export const useComponentChangeDetectionRule: TSESLint.RuleModule = { + meta: { + messages: { + noChangeDetectionError: `Components should explicitly declare "changeDetection", preferably "ChangeDetectionStrategy.OnPush"`, + }, + type: 'problem', + schema: [], + }, + create: context => { + // only apply to component files + if (normalizePath(context.getFilename()).search(/.(component|container).ts/) < 0) { + return {}; + } + return { + Decorator(node) { + // only test relevant decorators + if ( + node.parent.type === AST_NODE_TYPES.ClassDeclaration && + node.expression.type === AST_NODE_TYPES.CallExpression && + node.expression.callee.type === AST_NODE_TYPES.Identifier && + node.expression.callee.name === 'Component' && + !configurationArgumentsContainChangeDetection(node.expression.arguments) + ) { + // test if decorator argument object includes changeDetection property + context.report({ + node, + messageId: 'noChangeDetectionError', + }); + } + }, + }; + }, +}; + +function configurationArgumentsContainChangeDetection(config: TSESTree.CallExpressionArgument[]): boolean { + return ( + config && config[0].type === AST_NODE_TYPES.ObjectExpression && objectContainsProperty(config[0], 'changeDetection') + ); +} diff --git a/eslint-rules/src/rules/use-jest-extended-matchers-in-tests.ts b/eslint-rules/src/rules/use-jest-extended-matchers-in-tests.ts new file mode 100644 index 0000000000..30bc4e5914 --- /dev/null +++ b/eslint-rules/src/rules/use-jest-extended-matchers-in-tests.ts @@ -0,0 +1,87 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +import { getClosestAncestorByKind, normalizePath } from '../helpers'; + +const REPLACEMENTS = [ + { pattern: /(toBe|toEqual)\(false\)$/, replacement: 'toBeFalse()', text: 'toBeFalse' }, + { pattern: /(toBe|toEqual)\(true\)$/, replacement: 'toBeTrue()', text: 'toBeTrue' }, + { pattern: /(toBe|toEqual)\(undefined\)$/, replacement: 'toBeUndefined()', text: 'toBeUndefined' }, + { pattern: /(toBe|toEqual)\(\'\'\)$/, replacement: 'toBeEmpty()', text: 'toBeEmpty' }, + { pattern: /(toBe|toEqual)\(\[\]\)$/, replacement: 'toBeEmpty()', text: 'toBeEmpty' }, + { pattern: /(toBe|toEqual)\(\{\}\)$/, replacement: 'toBeEmpty()', text: 'toBeEmpty' }, + { pattern: /\.length\)\.(toBe|toEqual)\(([0-9]+)\)$/, replacement: ').toHaveLength($2)', text: 'toHaveLength' }, + { pattern: /(toBe|toEqual)\(NaN\)$/, replacement: 'toBeNaN()', text: 'toBeNaN' }, +]; + +/** + * Enforces a consistent coding style in jest tests by disallowing certain matchers and offering replacements. + * This rule provides a number of default rules but can be configured in the eslint configuration. + * Provide an array of objects with the following properties: + * + * pattern: RegExp defining the matcher pattern you want to prohibit. + * replacement: String that will be used to replace the pattern. + * text: String which will be displayed in the eslint error message. + * + */ +export const useJestExtendedMatchersInTestsRule: TSESLint.RuleModule< + string, + { pattern: string; replacement: string; text: string }[][] +> = { + meta: { + messages: { + alternative: `use {{alternative}}`, + }, + type: 'problem', + fixable: 'code', + schema: [ + { + type: 'array', + items: { + type: 'object', + required: ['pattern', 'replacement', 'text'], + properties: { + pattern: { type: 'string', description: 'The jest matcher pattern to avoid.' }, + replacement: { type: 'string', description: 'What to replace the pattern with.' }, + text: { type: 'string', description: 'The content of the rule error.' }, + }, + additionalProperties: false, + }, + }, + ], + }, + create: context => { + if (!normalizePath(context.getFilename()).endsWith('.spec.ts')) { + return {}; + } + const [options] = context.options; + const mergedReplacements = [ + ...REPLACEMENTS, + ...(options?.map(rep => ({ ...rep, pattern: new RegExp(rep.pattern) })) ?? []), + ]; + return { + 'MemberExpression > Identifier'(node: TSESTree.Identifier) { + const callExpression = getClosestAncestorByKind(context, AST_NODE_TYPES.CallExpression); + const callExpressionText = context.getSourceCode().getText(callExpression); + if (callExpressionText.includes('expect')) { + mergedReplacements.forEach(rep => { + const match = callExpressionText.match(rep.pattern)?.[0]; + if (match) { + context.report({ + node, + messageId: 'alternative', + data: { + alternative: rep.text, + }, + fix: fixer => + fixer.replaceTextRange( + callExpression.range, + callExpressionText.replace(rep.pattern, rep.replacement) + ), + }); + } + }); + } + }, + }; + }, +}; diff --git a/eslint-rules/tests/_execute-tests.ts b/eslint-rules/tests/_execute-tests.ts new file mode 100644 index 0000000000..775f9e5b6b --- /dev/null +++ b/eslint-rules/tests/_execute-tests.ts @@ -0,0 +1,70 @@ +import { TSESLint } from '@typescript-eslint/experimental-utils'; +import { RuleTester, RunTests } from '@typescript-eslint/experimental-utils/dist/ts-eslint'; +import { readdirSync } from 'fs'; +import { join, normalize } from 'path'; +import { throwError } from 'rxjs'; + +export type RuleTestConfig = { + ruleName: string; + rule: TSESLint.RuleModule; + tests: RunTests>; +}; + +/** + * This is a script to execute all eslint tests that are in this directory. + * Note that all tests must `export default` a config of type `RuleTestConfig`. + * To run tests only for a single rule, simply add a rule name (or part of one) as this scripts argument. + * To add an argument while running via npm, use this syntax: `npm run test:eslint-rules -- ` + */ + +// instantiate rule tester (with absolute path to @typescript-eslint/parser via __dirname - https://github.com/eslint/eslint/issues/11728) +const ruleTester = new RuleTester({ + parser: join(normalize(process.cwd()), 'node_modules', '@typescript-eslint', 'parser'), +}); + +// read all existing rule paths & longest file path for formatting +const ruleTestPaths = readdirSync('eslint-rules/tests').filter(f => f !== '_execute-tests.ts'); + +let longestFilePath = ruleTestPaths + .map(path => path.replace('.spec.ts', ' ')) + .reduce((acc, curr) => Math.max(acc, curr.length), 0); + +// extract optional file argument +const fileArgument = process.argv[2]; + +// if file argument exists, only run for one file - otherwise run for all files +if (fileArgument) { + const filePath = ruleTestPaths.find(path => path.includes(fileArgument)); + longestFilePath = filePath.length; + runTests([filePath]); +} else { + runTests(ruleTestPaths); +} + +// recursively run tests after each other +function runTests(paths: string[]) { + if (paths.length === 0) { + return; + } + import(`./${paths[0]}`) + .then(runSingleTest) + .catch(handleError) + .finally(() => runTests(paths.slice(1))); +} + +/* eslint-disable no-console */ +function runSingleTest(ruleFile: { default: RuleTestConfig }) { + const ruleConfig = ruleFile.default; + process.stdout.write(`Testing ${ruleConfig.ruleName}... `); + + ruleTester.run(ruleConfig.ruleName, ruleConfig.rule, ruleConfig.tests); + // colorful aligned checkmark magic + console.log('\x1b[32m%s\x1b[0m', `\u2713`.padStart(longestFilePath - ruleConfig.ruleName.length)); +} + +function handleError(error: unknown) { + // colorful error message + console.log('\x1b[31m%s\x1b[0m', `ERROR`); + console.log(error); + throwError(() => error); +} diff --git a/eslint-rules/tests/ban-imports-file-pattern.spec.ts b/eslint-rules/tests/ban-imports-file-pattern.spec.ts new file mode 100644 index 0000000000..3993f13591 --- /dev/null +++ b/eslint-rules/tests/ban-imports-file-pattern.spec.ts @@ -0,0 +1,84 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { banImportsFilePatternRule } from '../src/rules/ban-imports-file-pattern'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'ban-imports-file-pattern', + rule: banImportsFilePatternRule, + tests: { + valid: [ + { + filename: 'test.component.ts', + options: [ + [ + { + importNamePattern: 'foo|bar|baz', + name: '@foobar', + filePattern: '^.*\\.spec\\.ts*$', + message: 'Test Message', + }, + ], + ], + code: ` + import { foo } from '@foobar' + `, + }, + ], + invalid: [ + { + filename: 'test.component.spec.ts', + options: [ + [ + { + importNamePattern: 'foo|bar|baz', + name: '@foobar', + filePattern: '^.*\\.spec\\.ts*$', + message: 'Test Message', + }, + ], + ], + code: ` + import { foo } from '@foobar' + `, + errors: [ + { + messageId: 'banImportsFilePatternError', + data: { + message: 'Test Message', + }, + type: AST_NODE_TYPES.ImportDeclaration, + }, + ], + }, + { + filename: 'test.component.ts', + options: [ + [ + { + starImport: true, + name: 'foobar', + filePattern: '^.*\\.component\\.ts*$', + message: 'Test Star Import.', + }, + ], + ], + code: ` + import * as foo from 'foobar' + `, + errors: [ + { + messageId: 'banImportsFilePatternError', + data: { + message: 'Test Star Import.', + }, + type: AST_NODE_TYPES.ImportDeclaration, + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/component-creation-test.spec.ts b/eslint-rules/tests/component-creation-test.spec.ts new file mode 100644 index 0000000000..78664f198b --- /dev/null +++ b/eslint-rules/tests/component-creation-test.spec.ts @@ -0,0 +1,121 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { componentCreationTestRule } from '../src/rules/component-creation-test'; + +import { RuleTestConfig } from './_execute-tests'; + +const SHOULD_BE_CREATED_NAME = 'should be created'; + +const config: RuleTestConfig = { + ruleName: 'component-creation-test', + rule: componentCreationTestRule, + tests: { + valid: [ + { + filename: 'test.component.spec.ts', + code: ` + describe('Test Component', () => { + it('${SHOULD_BE_CREATED_NAME}', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + }) + `, + }, + { + filename: 'test.component.ts', + code: ``, + }, + ], + invalid: [ + { + filename: 'test.component.spec.ts', + code: ` + it('${SHOULD_BE_CREATED_NAME}', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + `, + errors: [ + { + messageId: 'noDescribe', + type: AST_NODE_TYPES.Program, + }, + ], + }, + { + filename: 'test.component.spec.ts', + code: ` + describe('Test Component', () => { + it('invalid_name', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + }) + `, + errors: [ + { + messageId: 'noCreationTest', + type: AST_NODE_TYPES.Identifier, + }, + ], + }, + { + filename: 'test.component.spec.ts', + code: ` + describe('Test Component', () => { + it('${SHOULD_BE_CREATED_NAME}', () => { + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + }) + `, + errors: [ + { + messageId: 'noComponentTruthyTest', + type: AST_NODE_TYPES.Identifier, + }, + ], + }, + { + filename: 'test.component.spec.ts', + code: ` + describe('Test Component', () => { + it('${SHOULD_BE_CREATED_NAME}', () => { + expect(component).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + }) + `, + errors: [ + { + messageId: 'noElementTruthyTest', + type: AST_NODE_TYPES.Identifier, + }, + ], + }, + { + filename: 'test.component.spec.ts', + code: ` + describe('Test Component', () => { + it('${SHOULD_BE_CREATED_NAME}', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + }); + }) + `, + errors: [ + { + messageId: 'noFixtureDetectChangesTest', + type: AST_NODE_TYPES.Identifier, + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/newline-before-root-members.spec.ts b/eslint-rules/tests/newline-before-root-members.spec.ts new file mode 100644 index 0000000000..00267a948b --- /dev/null +++ b/eslint-rules/tests/newline-before-root-members.spec.ts @@ -0,0 +1,112 @@ +import { newlineBeforeRootMembersRule } from '../src/rules/newline-before-root-members'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'newline-before-root-members', + rule: newlineBeforeRootMembersRule, + tests: { + valid: [ + { + filename: 'test.ts', + code: ` + const x = 10; + + x++; + `, + }, + { + filename: 'test.ts', + code: ` + import { bla } from '@bla'; + import { blub } from '@blub'; + + const x = 10; + `, + }, + { + filename: 'test.ts', + code: ` + import { bla } from '@bla'; + + export { blub } from '@blub'; + export { blub2 } from '@blub2'; + `, + }, + { + filename: 'test.ts', + code: ` + import { bla } from '@bla'; + + export { blub } from '@blub'; + + export * from './blub2'; + export * from './blub3'; + `, + }, + ], + invalid: [ + { + filename: 'test.ts', + code: ` + const x = 10; + x++; + `, + errors: [ + { + messageId: 'newLineBeforeRootMembers', + }, + ], + output: ` + const x = 10; + + x++; + `, + }, + { + filename: 'test.ts', + code: ` + import { Component } from '@angular'; + @Component({}) + export class TestComponent {} + `, + errors: [ + { + messageId: 'newLineBeforeRootMembers', + }, + ], + output: ` + import { Component } from '@angular'; + + @Component({}) + export class TestComponent {} + `, + }, + { + filename: 'test.ts', + code: ` + import { Component } from '@angular'; + normalCode(); + export { TestExport } from 'test'; + `, + errors: [ + { + messageId: 'newLineBeforeRootMembers', + }, + { + messageId: 'newLineBeforeRootMembers', + }, + ], + output: ` + import { Component } from '@angular'; + + normalCode(); + + export { TestExport } from 'test'; + `, + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/no-assignment-to-inputs.spec.ts b/eslint-rules/tests/no-assignment-to-inputs.spec.ts new file mode 100644 index 0000000000..3a830b1ae0 --- /dev/null +++ b/eslint-rules/tests/no-assignment-to-inputs.spec.ts @@ -0,0 +1,65 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { noAssignmentToInputsRule } from '../src/rules/no-assignment-to-inputs'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'no-assignment-to-inputs', + rule: noAssignmentToInputsRule, + tests: { + valid: [ + { + filename: 'test.component.ts', + code: ` + @Component({}) + export class TestComponent { + @Input() testInput; + } + `, + }, + ], + invalid: [ + { + filename: 'test.component.ts', + code: ` + @Component({}) + export class TestComponent { + @Input() testInput; + + testFunction() { + this.testInput = variable; + } + } + `, + errors: [ + { + messageId: 'inputAssignmentError', + type: AST_NODE_TYPES.AssignmentExpression, + }, + ], + }, + { + filename: 'test.component.ts', + code: ` + @Component({}) + export class TestComponent { + @Output() @Input() testInput; + + testFunction() { + this.testInput = variable; + } + } + `, + errors: [ + { + messageId: 'inputAssignmentError', + type: AST_NODE_TYPES.AssignmentExpression, + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/no-collapsible-if.spec.ts b/eslint-rules/tests/no-collapsible-if.spec.ts new file mode 100644 index 0000000000..eba1021863 --- /dev/null +++ b/eslint-rules/tests/no-collapsible-if.spec.ts @@ -0,0 +1,88 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { noCollapsibleIfRule } from '../src/rules/no-collapsible-if'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'no-collapsible-if', + rule: noCollapsibleIfRule, + tests: { + valid: [ + { + filename: 'test.component.ts', + code: ` + if(a === b && c === d) { + const foo = bar; + } + `, + }, + { + filename: 'test.component.ts', + code: ` + if(a === b && c === d) { + if(foo.bar()) { + const foo = bar; + } + return test; + } + `, + }, + { + filename: 'test.component.ts', + code: ` + if(a === b && c === d) { + if(foo.bar()) { + const foo = bar; + } + } else { + return foo; + } + `, + }, + { + filename: 'test.component.ts', + code: ` + if(a === b && c === d) { + if(foo.bar()) { + const foo = bar; + } else { + return foo; + } + } + `, + }, + ], + invalid: [ + { + filename: 'test.component.ts', + code: ` + let test = foo; + if(a === b) { + if(c=== d) { + const foo = bar; + test = foo.baz(); + return test; + } + } + `, + output: ` + let test = foo; + if((a === b) && (c=== d)){ + const foo = bar; + test = foo.baz(); + return test; + } + `, + errors: [ + { + messageId: 'noCollapsibleIfError', + type: AST_NODE_TYPES.IfStatement, + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/no-initialize-observables-directly.spec.ts b/eslint-rules/tests/no-initialize-observables-directly.spec.ts new file mode 100644 index 0000000000..1cf1d2fe2e --- /dev/null +++ b/eslint-rules/tests/no-initialize-observables-directly.spec.ts @@ -0,0 +1,54 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { noInitializeObservablesDirectlyRule } from '../src/rules/no-initialize-observables-directly'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'no-initialize-observables-directly', + rule: noInitializeObservablesDirectlyRule, + tests: { + valid: [ + { + filename: 'test.component.ts', + code: ` + @Component({}) + export class TestComponent implements OnInit { + observable$: Observable; + ngOnInit() { + this.observable$ = new Observable(); + } + } + `, + }, + { + filename: 'test.component.ts', + code: ` + @Component({}) + export class TestComponent { + observable$ = new Subject() + } + `, + }, + ], + invalid: [ + { + filename: 'test.component.ts', + code: ` + @Component({}) + export class TestComponent { + observable$ = new Observable() + } + `, + errors: [ + { + messageId: 'wrongInitializeError', + type: AST_NODE_TYPES.ClassProperty, + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/no-intelligence-in-artifacts.spec.ts b/eslint-rules/tests/no-intelligence-in-artifacts.spec.ts new file mode 100644 index 0000000000..7201010a78 --- /dev/null +++ b/eslint-rules/tests/no-intelligence-in-artifacts.spec.ts @@ -0,0 +1,104 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { RuleSetting, noIntelligenceInArtifactsRule } from '../src/rules/no-intelligence-in-artifacts'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig[]> = { + ruleName: 'no-intelligence-in-artifacts', + rule: noIntelligenceInArtifactsRule, + tests: { + valid: [], + invalid: [ + { + filename: 'test.component.ts', + options: [ + { + '(component)(\\.spec)?\\.ts$': { + ngrx: 'no ngrx in components', + service: 'no services in components', + }, + }, + ], + code: ` + import { Store } from '@ngrx'; + import { TestService } from 'ish-core/services/test-service' + + @Component({}) + export class TestComponent { + constructor( + private store: Store, + private testService: TestService + ) {} + } + `, + errors: [ + { + messageId: 'noIntelligenceError', + data: { + error: 'no ngrx in components', + }, + type: AST_NODE_TYPES.ImportDeclaration, + }, + { + messageId: 'noIntelligenceError', + data: { + error: 'no services in components', + }, + type: AST_NODE_TYPES.ImportDeclaration, + }, + ], + }, + { + filename: 'test.effects.ts', + options: [ + { + 'effects.ts$': { + facade: 'no facades in effects', + }, + }, + ], + code: ` + import { TestFacade } from 'ish-core/facades/test.facade' + @Injectable() + export class TestEffects {} + `, + errors: [ + { + messageId: 'noIntelligenceError', + data: { + error: 'no facades in effects', + }, + type: AST_NODE_TYPES.ImportDeclaration, + }, + ], + }, + { + filename: 'test.service.ts', + options: [ + { + '^(?!.*/(utils)/.*$).*service.ts$': { + router: 'no router in services', + }, + }, + ], + code: ` + import { Router } from '@angular/router' + @Injectable() + export class TestService {} + `, + errors: [ + { + messageId: 'noIntelligenceError', + data: { + error: 'no router in services', + }, + type: AST_NODE_TYPES.ImportDeclaration, + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/no-object-literal-type-assertion.spec.ts b/eslint-rules/tests/no-object-literal-type-assertion.spec.ts new file mode 100644 index 0000000000..7f920618d4 --- /dev/null +++ b/eslint-rules/tests/no-object-literal-type-assertion.spec.ts @@ -0,0 +1,42 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { noObjectLiteralTypeAssertionRule } from '../src/rules/no-object-literal-type-assertion'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'no-object-literal-type-assertion', + rule: noObjectLiteralTypeAssertionRule, + tests: { + valid: [ + { + filename: 'test.ts', + code: ` + interface Test { + foo: string; + } + const bar: Test; + `, + }, + ], + invalid: [ + { + filename: 'test.ts', + code: ` + interface Test { + foo: string; + } + return {} as Test; + `, + errors: [ + { + messageId: 'noObjectLiteralTypeAssertionError', + type: AST_NODE_TYPES.TSAsExpression, + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/no-return-undefined.spec.ts b/eslint-rules/tests/no-return-undefined.spec.ts new file mode 100644 index 0000000000..fcb2a261a3 --- /dev/null +++ b/eslint-rules/tests/no-return-undefined.spec.ts @@ -0,0 +1,45 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { noReturnUndefinedRule } from '../src/rules/no-return-undefined'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'no-return-undefined', + rule: noReturnUndefinedRule, + tests: { + valid: [ + { + filename: 'test.ts', + code: ` + function testFunction() { + return; + } + `, + }, + ], + invalid: [ + { + filename: 'test.ts', + code: ` + function testFunction() { + return undefined; + } + `, + output: ` + function testFunction() { + return ; + } + `, + errors: [ + { + messageId: 'undefinedError', + type: AST_NODE_TYPES.ReturnStatement, + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/no-star-imports-in-store.spec.ts b/eslint-rules/tests/no-star-imports-in-store.spec.ts new file mode 100644 index 0000000000..268e6d6dff --- /dev/null +++ b/eslint-rules/tests/no-star-imports-in-store.spec.ts @@ -0,0 +1,85 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { noStarImportsInStoreRule } from '../src/rules/no-star-imports-in-store'; + +import { RuleTestConfig } from './_execute-tests'; + +const starImportTest: { code: string; output: string; errors: { messageId: string; type: AST_NODE_TYPES }[] } = { + code: ` + import * as test from 'foo'; + + @Injectable({}) + export class TestEffect { + const a = test.bar; + } + `, + output: ` + import { bar } from 'foo' + + @Injectable({}) + export class TestEffect { + const a = bar; + } + `, + errors: [ + { + messageId: 'starImportError', + type: AST_NODE_TYPES.ImportDeclaration, + }, + { + messageId: 'starImportError', + type: AST_NODE_TYPES.MemberExpression, + }, + ], +}; + +const config: RuleTestConfig = { + ruleName: 'no-star-imports-in-store', + rule: noStarImportsInStoreRule, + tests: { + valid: [ + { + filename: 'test.component.ts', + code: starImportTest.code, + }, + { + filename: 'test.effects.ts', + code: ` + import { Store } from '@ngrx'; + + @Injectable({}) + export class TestEffect { + } + `, + }, + ], + invalid: [ + { + filename: 'test.effects.ts', + code: starImportTest.code, + output: starImportTest.output, + errors: starImportTest.errors, + }, + { + filename: 'test.reducer.ts', + code: starImportTest.code, + output: starImportTest.output, + errors: starImportTest.errors, + }, + { + filename: 'test.actions.ts', + code: starImportTest.code, + output: starImportTest.output, + errors: starImportTest.errors, + }, + { + filename: 'test.selectors.ts', + code: starImportTest.code, + output: starImportTest.output, + errors: starImportTest.errors, + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/no-testbed-with-then.spec.ts b/eslint-rules/tests/no-testbed-with-then.spec.ts new file mode 100644 index 0000000000..ebbd44f4ce --- /dev/null +++ b/eslint-rules/tests/no-testbed-with-then.spec.ts @@ -0,0 +1,44 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { noTestbedWithThenRule } from '../src/rules/no-testbed-with-then'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'no-testbed-with-then', + rule: noTestbedWithThenRule, + tests: { + valid: [ + { + filename: 'demo.component.spec.ts', + code: ` + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + `, + }, + { + filename: 'demo.component.ts', + code: ``, + }, + ], + invalid: [ + { + filename: 'demo.component.spec.ts', + code: ` + beforeEach(() => { + TestBed.configureTestingModule({}); + }).then(() => {}); + `, + errors: [ + { + type: AST_NODE_TYPES.Identifier, + messageId: 'testbedWithThenError', + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/no-var-before-return.spec.ts b/eslint-rules/tests/no-var-before-return.spec.ts new file mode 100644 index 0000000000..84b099b27d --- /dev/null +++ b/eslint-rules/tests/no-var-before-return.spec.ts @@ -0,0 +1,76 @@ +import { noVarBeforeReturnRule } from '../src/rules/no-var-before-return'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'no-var-before-return', + rule: noVarBeforeReturnRule, + tests: { + valid: [ + { + filename: 'test.ts', + code: ` + function testFunction() { + const obj = { a: 12, b: 'abc' }; + const { a, b } = obj; + return a; + } + `, + }, + { + filename: 'test.ts', + code: ` + function testFunction() { + let a = 23; + a += 1; + return a; + } + `, + }, + ], + invalid: [ + { + filename: 'test.ts', + code: ` + function testFunction() { + let abc = '123'; + return abc; + } + `, + output: ` + function testFunction() { + return '123'; + } + `, + errors: [ + { + messageId: 'varError', + line: 3, + }, + ], + }, + { + filename: 'test.ts', + code: ` + function testFunction() { + let abc = new RegExp('ab+c', 'i'); + return abc; + } + `, + output: ` + function testFunction() { + return new RegExp('ab+c', 'i'); + } + `, + errors: [ + { + messageId: 'varError', + line: 3, + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/ordered-imports.spec.ts b/eslint-rules/tests/ordered-imports.spec.ts new file mode 100644 index 0000000000..402c20264a --- /dev/null +++ b/eslint-rules/tests/ordered-imports.spec.ts @@ -0,0 +1,145 @@ +import { orderedImportsRule } from '../src/rules/ordered-imports'; + +import { RuleTestConfig } from './_execute-tests'; + +const invalidTests: { code: string; output: string }[] = [ + // standard sorting + { + code: ` + import { d } from '@test/d'; + import { b } from '@test/b'; + import { c as a} from '@test/c'; + `, + output: ` + import { b } from '@test/b'; + import { c as a } from '@test/c'; + import { d } from '@test/d'; + `, + }, + // with namespace, default or sideeffect imports + { + code: ` + import * as d from '@test/d'; + import def from '@default'; + import { b } from '@test/b'; + import '@sideeffect/init'; + `, + output: ` + import def from '@default'; + import '@sideeffect/init'; + import { b } from '@test/b'; + import * as d from '@test/d'; + `, + }, + // correct grouping + { + code: ` + import { aa } from 'ish-aa'; + import { ab } from '@ab'; + import { d } from './d'; + import { c } from '../c'; + `, + output: ` + import { ab } from '@ab'; + + import { aa } from 'ish-aa'; + + import { c } from '../c'; + + import { d } from './d'; + `, + }, + // grouping and sorting + { + code: ` + import { bb } from 'ish-ab'; + import { aa } from 'ish-aa'; + import { d } from '@test/d'; + import { c } from '@test/c'; + `, + output: ` + import { c } from '@test/c'; + import { d } from '@test/d'; + + import { aa } from 'ish-aa'; + import { bb } from 'ish-ab'; + `, + }, + // import member sorting + { + code: `import { c, b, a } from '@test'`, + output: `import { a, b, c } from '@test'`, + }, + // import member sorting with alias + { + code: `import { xyz as a, b } from '@test'`, + output: `import { b, xyz as a } from '@test'`, + }, +]; + +const config: RuleTestConfig = { + ruleName: 'ordered-imports', + rule: orderedImportsRule, + tests: { + valid: [ + { + filename: 'test.ts', + code: formatter(` + import { ab } from '@ab'; + import def from '@default'; + import '@sideeffect/init'; + import { bbb, xyz as a } from '@test'; + import { b } from '@test/b'; + import * as d from '@test/d'; + + import { aa } from 'ish-aa'; + import { bb } from 'ish-bb'; + + import { c, c1, c2 } from '../c'; + + import { x } from './x'; + `), + }, + { + filename: 'test.ts', + code: formatter(` + + import { ab } from '@ab'; + + import { aa } from 'ish-aa'; + + import { x } from './x'; + + `), + }, + ], + /** + * simplify tests: + * - errors will always be on the same line starting at 1 + * - code is formatted so we can write it more easily + */ + invalid: invalidTests.map(testConf => ({ + filename: 'test.ts', + code: formatter(testConf.code), + output: formatter(testConf.output), + errors: [ + { + line: 1, + messageId: 'unorderedImports', + }, + ], + })), + }, +}; + +/** + * make formatting test code easier: + * - automatically remove leading whitespace per line (but not empty lines) + * - ignore trailing whitespace + * => This rule is whitespace-sensitive so it would make the entire test very unreadable if we didn't do this preprocessing step + */ +function formatter(input: string): string { + return input.replace(/^([^\S\r\n]*)(?=import.*\n)/gm, '').trim(); +} + +export default config; diff --git a/eslint-rules/tests/private-destroy-field.spec.ts b/eslint-rules/tests/private-destroy-field.spec.ts new file mode 100644 index 0000000000..d43fda679e --- /dev/null +++ b/eslint-rules/tests/private-destroy-field.spec.ts @@ -0,0 +1,69 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { privateDestroyFieldRule } from '../src/rules/private-destroy-field'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'private-destroy-field', + rule: privateDestroyFieldRule, + tests: { + valid: [ + { + filename: 'test.component.ts', + code: ` + @Component({}) + export class TestComponent { + private destroy$ = new Subject(); + } + `, + }, + ], + invalid: [ + { + filename: 'test.component.ts', + code: ` + @Component({}) + export class TestComponent { + destroy$ = new Subject(); + } + `, + errors: [ + { + messageId: 'privateDestroyError', + type: AST_NODE_TYPES.ClassProperty, + }, + ], + output: ` + @Component({}) + export class TestComponent { + private destroy$ = new Subject(); + } + `, + }, + { + filename: 'test.component.ts', + code: ` + @Component({}) + export class TestComponent { + public destroy$ = new Subject(); + } + `, + errors: [ + { + messageId: 'privateDestroyError', + type: AST_NODE_TYPES.ClassProperty, + }, + ], + output: ` + @Component({}) + export class TestComponent { + private destroy$ = new Subject(); + } + `, + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/project-structure.spec.ts b/eslint-rules/tests/project-structure.spec.ts new file mode 100644 index 0000000000..0f6fefa1c1 --- /dev/null +++ b/eslint-rules/tests/project-structure.spec.ts @@ -0,0 +1,134 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { projectStructureRule } from '../src/rules/project-structure'; + +import { RuleTestConfig } from './_execute-tests'; + +const options = { + warnUnmatched: false, + reusePatterns: { + name: '[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*', + theme: '(?:\\.(?:foo|bar))*', + }, + pathPatterns: ['^.*/src/test/foo(\\.\\w+)?\\.ts$', '^.*src/test/[\\s\\S]*.ts$'], + patterns: [ + { + name: '^(TestComponent)$', + file: '.*[src/test/test.component]()?\\.ts$', + }, + ], + ignoredFiles: ['foo.ts$'], +}; + +const config: RuleTestConfig = { + ruleName: 'project-structure', + rule: projectStructureRule, + tests: { + valid: [ + // files which matches the ignored list don't need to match any following pattern + { + options: [options], + filename: 'path/src/baz/whatever/foo.ts', + code: ` + @Component({}) + export class FooComponent { + } + `, + }, + // files which matches path pattern is valid, when warnUnmatched is false + { + options: [{ ...options, warnUnmatched: false }], + filename: 'path/src/test/bar.ts', + code: ` + @Component({}) + export class TestComponent { + } + `, + }, + // files which matches path pattern and class name pattern are valid + { + options: [{ ...options, warnUnmatched: true }], + filename: 'path/src/test/test.ts', + code: ` + @Component({}) + export class TestComponent { + } + `, + }, + // extending a class should not impact validity + { + options: [{ ...options, warnUnmatched: true }], + filename: 'path/src/test/test.ts', + code: ` + @Component({}) + export class TestComponent extends OtherComponent { + } + `, + }, + // kebab conversion should work + { + options: [ + { + ...options, + pathPatterns: ['^.*/src/pages/foo-bar/foo-bar-page.component.ts$'], + patterns: [ + { + name: '^([A-Z].*)PageComponent$', + file: '.*/pages//-page\\.component()?\\.ts$', + }, + ], + warnUnmatched: true, + }, + ], + filename: 'path/src/pages/foo-bar/foo-bar-page.component.ts', + code: ` + @Component({}) + export class FooBarPageComponent { + } + `, + }, + ], + invalid: [ + // files doesn't match path pattern when path is invalid and warnUnmatched is false + { + options: [{ ...options, warnUnmatched: false }], + filename: 'path/baz/test.component.ts', + code: ` + @Component({}) + export class TestFooComponent { + } + `, + errors: [ + { + messageId: 'projectStructureError', + data: { + message: 'path/baz/test.component.ts this file path does not match any defined patterns.', + }, + type: AST_NODE_TYPES.ExportNamedDeclaration, + }, + ], + }, + // files doesn't match class name pattern when path is valid, class name is invalid and warnUnmatched is true + { + options: [{ ...options, warnUnmatched: true }], + filename: 'path/src/test/test.component.bar.ts', + code: ` + @Component({}) + export class TestFooComponent { + } + `, + errors: [ + { + messageId: 'projectStructureError', + data: { + message: 'no pattern match for TestFooComponent in file path/src/test/test.component.bar.ts', + }, + type: AST_NODE_TYPES.Identifier, + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/use-alias-imports.spec.ts b/eslint-rules/tests/use-alias-imports.spec.ts new file mode 100644 index 0000000000..0609b58c4c --- /dev/null +++ b/eslint-rules/tests/use-alias-imports.spec.ts @@ -0,0 +1,47 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { useAliasImportsRule } from '../src/rules/use-alias-imports'; + +import { RuleTestConfig } from './_execute-tests'; + +// Attention: This test is an integration test. Results can be influenced by changes in ./tsconfig.json +const config: RuleTestConfig = { + ruleName: 'use-alias-imports', + rule: useAliasImportsRule, + tests: { + valid: [ + { + filename: 'test\\src\\app\\shared\\test\\test.component.ts', + code: ` + import { SharedModule } from 'ish-shared/shared.module'; + `, + }, + { + filename: 'test\\src\\app\\foo\\test\\test.component.ts', + code: ` + import { SharedModule } from '../../shared.module'; + `, + }, + ], + invalid: [ + { + filename: 'test\\src\\app\\shared\\test\\test.component.ts', + code: ` + import { SharedModule } from '../shared.module'; + `, + errors: [ + { + messageId: 'noAlias', + data: { alias: 'ish-shared/' }, + type: AST_NODE_TYPES.ImportDeclaration, + }, + ], + output: ` + import { SharedModule } from 'ish-shared/shared.module'; + `, + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/use-async-synchronization-in-tests.spec.ts b/eslint-rules/tests/use-async-synchronization-in-tests.spec.ts new file mode 100644 index 0000000000..1a5144089e --- /dev/null +++ b/eslint-rules/tests/use-async-synchronization-in-tests.spec.ts @@ -0,0 +1,85 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { useAsyncSynchronizationInTestsRule } from '../src/rules/use-async-synchronization-in-tests'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'use-async-synchronization-in-tests', + rule: useAsyncSynchronizationInTestsRule, + tests: { + valid: [ + { + filename: 'test.spec.ts', + code: ` + it('should do test', done => { + testObs.subscribe(value => { + expect(value).toBe('value'); + done(); + }) + }) + `, + }, + { + filename: 'test.spec.ts', + code: ` + it('should do test', done => { + testObs.subscribe(done,fail,fail); + }) + `, + }, + { + filename: 'test.spec.ts', + code: ` + it('should do test', done => { + testObs.subscribe({complete: done}); + }) + `, + }, + { + filename: 'test.spec.ts', + code: ` + it('should do test', fakeAsync(() => { + testObs.subscribe(); + })) + `, + }, + { + filename: 'test.spec.ts', + code: ` + it('should do test', done => { + testObs.subscribe(fail,fail,fail); + setTimeout(() => { + verify(storeSpy$.dispatch(anything())).never(); + done(); + }, 2000); + }) + `, + }, + { + filename: 'test.spec.ts', + code: ` + it('should do test', done => { + testObs.subscribe(fail,fail,fail); + done(); + }) + `, + }, + ], + invalid: [ + { + filename: 'test.spec.ts', + code: ` + it('should do test', done => { + testObs.subscribe(value => { + expect(value).toBe('value'); + }) + }) + `, + errors: [{ messageId: 'noDoneError', type: AST_NODE_TYPES.CallExpression }], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/use-camel-case-environment-properties-rule.spec.ts b/eslint-rules/tests/use-camel-case-environment-properties-rule.spec.ts new file mode 100644 index 0000000000..9f953f0258 --- /dev/null +++ b/eslint-rules/tests/use-camel-case-environment-properties-rule.spec.ts @@ -0,0 +1,70 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { useCamelCaseEnvironmentPropertiesRule } from '../src/rules/use-camel-case-environment-properties'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'use-camel-case-environment-properties-rule', + rule: useCamelCaseEnvironmentPropertiesRule, + tests: { + valid: [ + { + filename: 'src/test.component.ts', + code: ` + @Component({}) + export class TestComponent { + Some_identifier: string; + } + `, + }, + { + filename: 'src\\environment.test.ts', + code: ` + export interface environment { + someIdentifier: string; + other: string[]; + } + `, + }, + ], + invalid: [ + { + filename: 'src\\environment.test.ts', + code: ` + export interface environment { + SomeIdentifier: string; + other_indetifier: string; + Some_longer_identifier_With_multiple_errors: string; + } + `, + errors: [ + { + messageId: 'camelCaseError', + data: { property: 'SomeIdentifier' }, + type: AST_NODE_TYPES.Identifier, + }, + { + messageId: 'camelCaseError', + data: { property: 'other_indetifier' }, + type: AST_NODE_TYPES.Identifier, + }, + { + messageId: 'camelCaseError', + data: { property: 'Some_longer_identifier_With_multiple_errors' }, + type: AST_NODE_TYPES.Identifier, + }, + ], + output: ` + export interface environment { + someIdentifier: string; + otherIndetifier: string; + someLongerIdentifierWithMultipleErrors: string; + } + `, + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/use-component-change-detection.spec.ts b/eslint-rules/tests/use-component-change-detection.spec.ts new file mode 100644 index 0000000000..0d36704100 --- /dev/null +++ b/eslint-rules/tests/use-component-change-detection.spec.ts @@ -0,0 +1,47 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { useComponentChangeDetectionRule } from '../src/rules/use-component-change-detection'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'use-component-change-detection', + rule: useComponentChangeDetectionRule, + tests: { + valid: [ + { + filename: 'test.service.ts', + code: ` + @Injectable({}) + export class TestService {} + `, + }, + { + filename: 'test.component.ts', + code: ` + @Component({ + changeDetection: ChangeDetectionStrategy.OnPush + }) + export class TestComponent {} + `, + }, + ], + invalid: [ + { + filename: 'test.component.ts', + code: ` + @Component({}) + export class TestComponent {} + `, + errors: [ + { + messageId: 'noChangeDetectionError', + type: AST_NODE_TYPES.Decorator, + }, + ], + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tests/use-jest-extended-matchers-in-tests.spec.ts b/eslint-rules/tests/use-jest-extended-matchers-in-tests.spec.ts new file mode 100644 index 0000000000..853a59ea53 --- /dev/null +++ b/eslint-rules/tests/use-jest-extended-matchers-in-tests.spec.ts @@ -0,0 +1,179 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +import { useJestExtendedMatchersInTestsRule } from '../src/rules/use-jest-extended-matchers-in-tests'; + +import { RuleTestConfig } from './_execute-tests'; + +const config: RuleTestConfig = { + ruleName: 'use-jest-extended-matchers-in-tests', + rule: useJestExtendedMatchersInTestsRule, + tests: { + valid: [ + { + filename: 'test.spec.ts', + code: `expect(variable).toBeFalse()`, + }, + { + filename: 'test.spec.ts', + code: `expect(variable).toBeTrue()`, + }, + { + filename: 'test.spec.ts', + code: `expect(variable).toBeUndefined()`, + }, + { + filename: 'test.spec.ts', + code: `expect(variable).toBeEmpty()`, + }, + { + filename: 'test.spec.ts', + code: `expect(arr).toHaveLength(2)`, + }, + { + filename: 'test.spec.ts', + code: `expect(variable).toBeNan()`, + }, + ], + invalid: [ + // Test additional option pattern + { + filename: 'test.spec.ts', + options: [ + [ + { + pattern: '(toBe|toEqual)\\(null\\)$', + replacement: 'toBeNull()', + text: 'toBeNull', + }, + ], + ], + code: `expect(variable).toEqual(null)`, + errors: [ + { + messageId: 'alternative', + data: { + alternative: 'toBeNull', + }, + type: AST_NODE_TYPES.Identifier, + }, + ], + output: `expect(variable).toBeNull()`, + }, + // Test default patterns + { + filename: 'test.spec.ts', + code: `expect(variable).toEqual(false)`, + errors: [ + { + messageId: 'alternative', + data: { + alternative: 'toBeFalse', + }, + type: AST_NODE_TYPES.Identifier, + }, + ], + output: `expect(variable).toBeFalse()`, + }, + { + filename: 'test.spec.ts', + code: `expect(variable).toEqual(true)`, + errors: [ + { + messageId: 'alternative', + data: { + alternative: 'toBeTrue', + }, + type: AST_NODE_TYPES.Identifier, + }, + ], + output: `expect(variable).toBeTrue()`, + }, + { + filename: 'test.spec.ts', + code: `expect(variable).toEqual(undefined)`, + errors: [ + { + messageId: 'alternative', + data: { + alternative: 'toBeUndefined', + }, + type: AST_NODE_TYPES.Identifier, + }, + ], + output: `expect(variable).toBeUndefined()`, + }, + { + filename: 'test.spec.ts', + code: `expect(variable).toEqual('')`, + errors: [ + { + messageId: 'alternative', + data: { + alternative: 'toBeEmpty', + }, + type: AST_NODE_TYPES.Identifier, + }, + ], + output: `expect(variable).toBeEmpty()`, + }, + { + filename: 'test.spec.ts', + code: `expect(variable).toEqual([])`, + errors: [ + { + messageId: 'alternative', + data: { + alternative: 'toBeEmpty', + }, + type: AST_NODE_TYPES.Identifier, + }, + ], + output: `expect(variable).toBeEmpty()`, + }, + { + filename: 'test.spec.ts', + code: `expect(variable).toEqual({})`, + errors: [ + { + messageId: 'alternative', + data: { + alternative: 'toBeEmpty', + }, + type: AST_NODE_TYPES.Identifier, + }, + ], + output: `expect(variable).toBeEmpty()`, + }, + { + filename: 'test.spec.ts', + code: `expect(arr.length).toEqual(2)`, + errors: [ + { + messageId: 'alternative', + data: { + alternative: 'toHaveLength', + }, + type: AST_NODE_TYPES.Identifier, + }, + ], + output: `expect(arr).toHaveLength(2)`, + }, + { + filename: 'test.spec.ts', + code: `expect(variable).toEqual(NaN)`, + errors: [ + { + messageId: 'alternative', + data: { + alternative: 'toBeNaN', + }, + type: AST_NODE_TYPES.Identifier, + }, + ], + output: `expect(variable).toBeNaN()`, + }, + ], + }, +}; + +export default config; diff --git a/eslint-rules/tsconfig.json b/eslint-rules/tsconfig.json new file mode 100644 index 0000000000..019cb3732a --- /dev/null +++ b/eslint-rules/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "declaration": false, + "experimentalDecorators": true, + "lib": ["dom", "es2015", "es6"], + "module": "commonjs", + "moduleResolution": "node", + "noImplicitAny": false, + "noLib": false, + "outDir": "./dist", + "removeComments": true, + "target": "es2015", + "types": ["node"] + }, + "include": ["src/*.ts"], + "exclude": ["src/*.spec.ts"] +} diff --git a/schematics/src/collection.json b/schematics/src/collection.json index 2fde536696..7c9d12212c 100644 --- a/schematics/src/collection.json +++ b/schematics/src/collection.json @@ -112,6 +112,12 @@ "factory": "./helpers/override/factory#override", "description": "Override a source for customization.", "schema": "./helpers/override/schema.json" + }, + "eslint-rule": { + "aliases": ["er"], + "description": "A blank schematic.", + "factory": "./eslint-rule/factory#eslintRule", + "schema": "./eslint-rule/schema.json" } } } diff --git a/schematics/src/eslint-rule/factory.ts b/schematics/src/eslint-rule/factory.ts new file mode 100644 index 0000000000..003cfd148e --- /dev/null +++ b/schematics/src/eslint-rule/factory.ts @@ -0,0 +1,63 @@ +import { strings } from '@angular-devkit/core'; +import { camelize } from '@angular-devkit/core/src/utils/strings'; +import { Rule, apply, applyTemplates, chain, mergeWith, move, url } from '@angular-devkit/schematics'; +import { SyntaxKind } from 'ts-morph'; + +import { applyLintFix } from '../utils/lint-fix'; +import { addImportToFile } from '../utils/registration'; +import { createTsMorphProject } from '../utils/ts-morph'; + +// You don't have to export the function as default. You can also have more than one rule factory +// per file. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function eslintRule(options: any): Rule { + // extract properties + const ruleName = options.name; + + // update options + options.path = '/eslint-rules/'; + + console.log('eslint-rule schematic', ruleName); + return async _ => { + const operations: Rule[] = []; + + operations.push( + mergeWith( + apply(url('./files'), [ + applyTemplates({ + ...strings, + ...options, + }), + move(options.path), + ]) + ) + ); + + operations.push(addEslintRuleToObject(options)); + operations.push( + addImportToFile({ + module: '/eslint-rules/src/index.ts', + artifactName: `${camelize(options.name)}Rule`, + moduleImportPath: `/eslint-rules/src/rules/${options.name}`, + }) + ); + operations.push(applyLintFix()); + + return chain(operations); + }; +} + +function addEslintRuleToObject(options: { name: string }): Rule { + return host => { + const tsMorphProject = createTsMorphProject(host); + tsMorphProject.addSourceFileAtPath('/eslint-rules/src/index.ts'); + const sourceFile = tsMorphProject.getSourceFile('/eslint-rules/src/index.ts'); + + sourceFile.getFirstDescendantByKindOrThrow(SyntaxKind.ObjectLiteralExpression).addPropertyAssignment({ + name: `'${options.name}'`, + initializer: `${camelize(options.name)}Rule`, + }); + + host.overwrite('/eslint-rules/src/index.ts', sourceFile.getText()); + }; +} diff --git a/schematics/src/eslint-rule/factory_spec.ts b/schematics/src/eslint-rule/factory_spec.ts new file mode 100644 index 0000000000..f12b289d87 --- /dev/null +++ b/schematics/src/eslint-rule/factory_spec.ts @@ -0,0 +1,26 @@ +import { UnitTestTree } from '@angular-devkit/schematics/testing'; + +import { copyFileFromPWA, createApplication, createSchematicRunner } from '../utils/testHelper'; + +import { PWAEslintRuleOptionsSchema as Options } from './schema'; + +describe('eslint-rule', () => { + const schematicRunner = createSchematicRunner(); + const defaultOptions: Options = { + name: 'foo-bar', + }; + + let appTree: UnitTestTree; + beforeEach(async () => { + appTree = await createApplication(schematicRunner).pipe(copyFileFromPWA('eslint-rules/src/index.ts')).toPromise(); + }); + it('should create a rule and add it to index.ts', async () => { + const options = { ...defaultOptions }; + + const tree = await schematicRunner.runSchematicAsync('eslint-rule', options, appTree).toPromise(); + const files = tree.files.filter(x => x.search('rules') >= 0); + expect(files).toContain('/eslint-rules/src/rules/foo-bar.ts'); + + expect(tree.readContent('/eslint-rules/src/index.ts')).toContain("'foo-bar': fooBarRule"); + }); +}); diff --git a/schematics/src/eslint-rule/files/src/rules/__name@dasherize__.ts.template b/schematics/src/eslint-rule/files/src/rules/__name@dasherize__.ts.template new file mode 100644 index 0000000000..c7e07af6b2 --- /dev/null +++ b/schematics/src/eslint-rule/files/src/rules/__name@dasherize__.ts.template @@ -0,0 +1,14 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + +export const <%= camelize(name) %>Rule: TSESLint.RuleModule = { + meta: { + messages: { + PLACEHOLDER_ERROR: ``, + }, + type: 'problem', + schema: [], + }, + create: context => ({ + + }), +}; diff --git a/schematics/src/eslint-rule/files/tests/__name@dasherize__.spec.ts.template b/schematics/src/eslint-rule/files/tests/__name@dasherize__.spec.ts.template new file mode 100644 index 0000000000..e062414289 --- /dev/null +++ b/schematics/src/eslint-rule/files/tests/__name@dasherize__.spec.ts.template @@ -0,0 +1,17 @@ +import { RuleTestConfig } from './_execute-tests'; +import { <%= camelize(name) %>Rule } from '../src/rules/<%= dasherize(name) %>'; +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; + +const config: RuleTestConfig = { + ruleName: '<%= dasherize(name) %>', + rule: <%= camelize(name) %>Rule, + tests: { + valid: [ + + ], + invalid: [ + + ], + }, +}; +export default config; diff --git a/schematics/src/eslint-rule/schema.json b/schematics/src/eslint-rule/schema.json new file mode 100644 index 0000000000..4913dc36bd --- /dev/null +++ b/schematics/src/eslint-rule/schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "SchematicsPWAComponent", + "title": "PWA Eslint Rule Options Schema", + "type": "object", + "description": "Creates a eslint rule.", + "properties": { + "name": { + "type": "string", + "description": "The name of the new rule.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What's the name of the new rule?" + } + } +}