diff --git a/package.json b/package.json index ac19600..23291ce 100644 --- a/package.json +++ b/package.json @@ -12,31 +12,38 @@ ], "main": "dist/index.js", "files": [ - "dist" + "dist/index.js" ], "scripts": { - "build": "tsc -p .", + "build": "tsc -p . && rollup -c", "test": "jest --coverage", "version": "npm run build", "release": "np" }, "dependencies": { - "@types/eslint": "^6.1.3", - "@typescript-eslint/parser": "^2.5.0", "eslint-utils": "^1.4.3", "tsutils": "^3.17.1" }, "peerDependencies": { - "@typescript-eslint/parser": "^2.5.0", - "eslint": "^6.5.1" + "@typescript-eslint/parser": "^2.4.0", + "@typescript-eslint/eslint-plugin": "^2.4.0", + "eslint-plugin-react": "^7.16.0", + "eslint": "^6.0.0", + "typescript": "^3.5.0" }, "devDependencies": { + "@types/eslint": "^6.1.3", "@types/jest": "^24.0.19", "@typescript-eslint/eslint-plugin": "^2.5.0", + "@typescript-eslint/parser": "^2.5.0", "eslint": "^6.5.1", + "eslint-plugin-react": "^7.16.0", + "eslint-utils": "^1.4.2", "jest": "^24.9.0", "jest-cli": "^24.9.0", "np": "^5.1.1", + "rollup": "^1.24.0", + "rollup-plugin-node-resolve": "^5.2.0", "ts-jest": "^24.1.0", "typescript": "3.6.4" }, diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..89c47fa --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,22 @@ +import nodeResolve from 'rollup-plugin-node-resolve'; +export default { + input: 'dist/build/index.js', + plugins: [ + nodeResolve({ + preferBuiltins: true + }) + ], + treeshake: { + pureExternalModules: true + }, + external: [ + 'eslint', + 'typescript', + 'tsutils', + 'eslint-utils' + ], + output: { + file: 'dist/index.js', + format: 'commonjs' + } +}; diff --git a/src/configs/base.ts b/src/configs/base.ts new file mode 100644 index 0000000..468cf3f --- /dev/null +++ b/src/configs/base.ts @@ -0,0 +1,32 @@ +export default { + overrides: [ + { + files: ['*.ts', '*.tsx'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + 'jsx': true + } + }, + plugins: [ + '@stencil' + ], + rules: { + '@stencil/async-methods': 2, + '@stencil/ban-prefix': [2, ['stencil', 'stnl', 'st']], + '@stencil/decorators-context': 2, + '@stencil/element-type': 2, + '@stencil/host-data-deprecated': 2, + '@stencil/methods-must-be-public': 2, + '@stencil/no-unused-watch': 2, + '@stencil/prefer-vdom-listener': 2, + '@stencil/props-must-be-public': 2, + '@stencil/render-returns-host': 2, + '@stencil/reserved-member-names': 2, + '@stencil/single-export': 2, + } + } + ] +}; diff --git a/src/configs/index.ts b/src/configs/index.ts index 5f7d6b7..27c2e61 100644 --- a/src/configs/index.ts +++ b/src/configs/index.ts @@ -1,5 +1,9 @@ +import base from './base'; import recommended from './recommended'; +import strict from './strict'; export default { - recommended + base, + recommended, + strict }; diff --git a/src/configs/recommended.ts b/src/configs/recommended.ts index e0ae51a..7dd2bd8 100644 --- a/src/configs/recommended.ts +++ b/src/configs/recommended.ts @@ -1,37 +1,13 @@ export default { - overrides: [ - { - parser: '@typescript-eslint/parser', - files: ['*.ts', '*.tsx'], - parserOptions: { - ecmaVersion: 2018, - sourceType: 'module', - ecmaFeatures: { - 'jsx': true - } - }, +plugins: [ + "react" + ], + extends: [ + "plugin:@stencil/base", + ], rules: { '@d0whc3r/stencil/async-methods': 'error', '@d0whc3r/stencil/ban-prefix': ['error', ['stencil', 'stnl', 'st']], - // '@d0whc3r/stencil/component-order': [ - // 'error', { - // order: [ - // 'own-prop', - // 'element', - // 'state', - // 'watched-state', - // 'prop', - // 'watched-prop', - // 'event', - // 'lifecycle', - // 'listen', - // 'method', - // 'own-method', - // 'render' - // ], - // followingWatch: true, - // alphabetical: true - // }], '@d0whc3r/stencil/decorators-context': 'error', '@d0whc3r/stencil/decorators-style': [ 'error', { @@ -57,10 +33,36 @@ export default { '@d0whc3r/stencil/required-jsdoc': 'error', '@d0whc3r/stencil/reserved-member-names': 'error', '@d0whc3r/stencil/single-export': 'error', - '@d0whc3r/stencil/strict-mutable': 'error' - }, - plugins: [ - '@d0whc3r/stencil' - ] + '@d0whc3r/stencil/strict-mutable': 'error, + "react/jsx-no-bind": [1, { + "ignoreRefs": true }] + } }; + +/* + rules: { + '@stencil/strict-boolean-conditions': 2, + '@stencil/ban-exported-const-enums': 2, + '@stencil/ban-side-effects': 2, + '@stencil/strict-mutable': 2, + '@stencil/decorators-style': [ + 'error', { + prop: 'inline', + state: 'inline', + element: 'inline', + event: 'inline', + method: 'multiline', + watch: 'multiline', + listen: 'multiline' + } + ], + '@stencil/own-methods-must-be-private': 1, + '@stencil/own-props-must-be-private': 1, + '@stencil/dependency-suggestions': 1, + '@stencil/required-jsdoc': 1, + "react/jsx-no-bind": [1, { + "ignoreRefs": true + }] + } +*/ \ No newline at end of file diff --git a/src/configs/strict.ts b/src/configs/strict.ts new file mode 100644 index 0000000..f0a00bd --- /dev/null +++ b/src/configs/strict.ts @@ -0,0 +1,58 @@ +export default { + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@stencil/recommended", + ], + rules: { + // Resets + "@typescript-eslint/camelcase": 0, + "@typescript-eslint/explicit-function-return-type": 0, + "@typescript-eslint/ban-ts-ignore": 0, + "@typescript-eslint/no-this-alias": 0, + "@typescript-eslint/no-non-null-assertion": 0, + "@typescript-eslint/no-unused-vars": 0, + "@typescript-eslint/no-empty-interface": 0, + "@typescript-eslint/no-use-before-define": 0, + "@typescript-eslint/no-explicit-any": 0, + "no-constant-condition": 0, + + + // Best practices + "no-shadow": 2, + "require-atomic-updates": 2, + "no-var": 2, + "prefer-object-spread": 2, + "no-nested-ternary": 2, + "no-duplicate-imports": 2, + + // General formatting + "indent": [2, 2], + "no-trailing-spaces": 2, + "curly": [2, "all"], + "comma-spacing": 2, + "comma-style": 2, + "computed-property-spacing": 2, + "comma-dangle": [2, "always-multiline"], + "func-style": [2, "expression", { "allowArrowFunctions": true }], + "multiline-ternary": [2, "always-multiline"], + "operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }], + "linebreak-style": 2, + "space-in-parens": 2, + "@typescript-eslint/semi": 2, + "@typescript-eslint/brace-style": 2, + "@typescript-eslint/func-call-spacing": 2, + + // JSX formatting + "react/jsx-closing-tag-location": 2, + "react/jsx-curly-newline": [2, "never"], + "react/jsx-closing-bracket-location": 2, + "react/jsx-curly-spacing": [2, {"when": "never", "children": true}], + "react/jsx-boolean-value": [2, "never"], + "react/jsx-child-element-spacing": 2, + "react/jsx-indent-props": [2, "first"], + "react/jsx-props-no-multi-spaces": 2, + "react/jsx-equals-spacing": [2, "never"], + } +}; diff --git a/src/index.ts b/src/index.ts index 7ead455..7146c94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import rules from './rules'; import configs from './configs'; -export = { +export { rules, configs }; diff --git a/src/rules/async-methods.ts b/src/rules/async-methods.ts index eda28b6..dbc94fb 100644 --- a/src/rules/async-methods.ts +++ b/src/rules/async-methods.ts @@ -1,7 +1,7 @@ import { Rule } from 'eslint'; import ts from 'typescript'; import { stencilComponentContext } from '../utils'; -import * as tsutils from 'tsutils'; +import { isThenableType } from 'tsutils'; const rule: Rule.RuleModule = { meta: { @@ -30,7 +30,7 @@ const rule: Rule.RuleModule = { const method = parserServices.esTreeNodeToTSNodeMap.get(node); const signature = typeChecker.getSignatureFromDeclaration(method); const returnType = typeChecker.getReturnTypeOfSignature(signature!); - if (!tsutils.isThenableType(typeChecker, method, returnType)) { + if (!isThenableType(typeChecker, method, returnType)) { const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node) as ts.Node; const text = String(originalNode.getFullText()); context.report({ diff --git a/src/rules/ban-exported-const-enums.ts b/src/rules/ban-exported-const-enums.ts new file mode 100644 index 0000000..4f9c327 --- /dev/null +++ b/src/rules/ban-exported-const-enums.ts @@ -0,0 +1,26 @@ +import { Rule } from 'eslint'; + +const rule: Rule.RuleModule = { + meta: { + docs: { + description: 'This rule catches exports of const enums', + category: 'Possible Errors', + recommended: true + }, + schema: [], + type: 'problem' + }, + + create(context): Rule.RuleListener { + return { + 'ExportNamedDeclaration > TSEnumDeclaration[const]': (node: any) => { + context.report({ + node: node, + message: `Exported const enums are not allowed` + }); + } + }; + } +}; + +export default rule; diff --git a/src/rules/ban-side-effects.ts b/src/rules/ban-side-effects.ts new file mode 100644 index 0000000..0e756c5 --- /dev/null +++ b/src/rules/ban-side-effects.ts @@ -0,0 +1,65 @@ +import { Rule } from 'eslint'; + +const rule: Rule.RuleModule = { + meta: { + docs: { + description: 'This rule catches function calls at the top level', + category: 'Possible Errors', + recommended: false + }, + schema: [ + { + type: 'array', + items: { + type: 'string' + }, + minLength: 0, + additionalProperties: false + } + ], + type: 'suggestion' + }, + + create(context): Rule.RuleListener { + const shouldSkip = /\b(spec|e2e|test)\./.test(context.getFilename()); + const skipFunctions = context.options[0] || DEFAULTS; + if (shouldSkip) { + return {}; + } + return { + 'CallExpression': (node: any) => { + if (skipFunctions.includes(node.callee.name)) { + return; + } + if (!isInScope(node)) { + context.report({ + node: node, + message: `Call expressions at the top-level should be avoided.` + }); + } + } + }; + } +}; + +const isInScope = (n: any): boolean => { + const type = n.type; + if ( + type === 'ArrowFunctionExpression' || + type === 'FunctionDeclaration' || + type === 'ClassDeclaration' || + type === 'ExportNamedDeclaration' + ) { + return true; + } + n = n.parent; + if (n) { + return isInScope(n); + } + return false; +} + +const DEFAULTS = ['describe', 'test', 'bind']; + +export default rule; + diff --git a/src/rules/decorators-style.ts b/src/rules/decorators-style.ts index a055050..8e3f851 100644 --- a/src/rules/decorators-style.ts +++ b/src/rules/decorators-style.ts @@ -117,7 +117,7 @@ const rule: Rule.RuleModule = { return { ...stencil.rules, 'ClassProperty': getStyle, - 'MethodDefinition': getStyle + 'MethodDefinition[kind=method]': getStyle }; } }; diff --git a/src/rules/dependency-suggestions.ts b/src/rules/dependency-suggestions.ts new file mode 100644 index 0000000..0c25859 --- /dev/null +++ b/src/rules/dependency-suggestions.ts @@ -0,0 +1,38 @@ +import { Rule } from 'eslint'; +import ts from 'typescript'; +import { stencilComponentContext } from '../utils'; +import * as tsutils from 'tsutils'; + +const rule: Rule.RuleModule = { + meta: { + docs: { + description: 'This rule can provide suggestions about dependencies in stencil apps', + recommended: true + }, + schema: [], + type: 'suggestion', + }, + + create(context): Rule.RuleListener { + return { + 'ImportDeclaration': (node: any) => { + const importName = node.source.value; + const message = SUGGESTIONS[importName]; + if (message) { + context.report({ + node, + message + }); + } + } + }; + } +}; + +const SUGGESTIONS: {[importName: string]: string} = { + 'classnames': `Stencil can already render conditional classes: +
`, + 'lodash': `"lodash" will bloat your build, use "lodash-es" instead: https://www.npmjs.com/package/lodash-es` +} + +export default rule; diff --git a/src/rules/element-type.ts b/src/rules/element-type.ts index e0494b4..15e9069 100644 --- a/src/rules/element-type.ts +++ b/src/rules/element-type.ts @@ -34,16 +34,13 @@ const rule: Rule.RuleModule = { const component = getDecorator(node.parent.parent.parent, 'Component'); const [{ tag }] = parseDecorator(component); const parsedTag = `HTML${parseTag(tag)}Element`; + if (tagType !== parsedTag) { - const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node.parent); - const text = originalNode.getFullText(); - const type = originalNode.type.typeName.escapedText; context.report({ - node: node.parent, + node: node.parent.typeAnnotation, message: `@Element type is not matching tag for component (${parsedTag})`, fix(fixer) { - const result = text.replace(`: ${type}`, `: ${parsedTag}`); - return fixer.replaceText(node.parent, result); + return fixer.replaceText(node.parent.typeAnnotation.typeAnnotation, parsedTag); } }); } diff --git a/src/rules/index.ts b/src/rules/index.ts index cb03e75..d1c949f 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -19,8 +19,16 @@ import requiredPrefix from './required-prefix'; import reservedMemberNames from './reserved-member-names'; import singleExport from './single-export'; import strictMutable from './strict-mutable'; +import banSideEffects from './ban-side-effects'; +import strictBooleanConditions from './strict-boolean-conditions'; +import banExportedConstEnums from './ban-exported-const-enums'; +import dependencySuggestions from './dependency-suggestions'; export default { + 'ban-side-effects': banSideEffects, + 'ban-exported-const-enums': banExportedConstEnums, + 'dependency-suggestions': dependencySuggestions, + 'strict-boolean-conditions': strictBooleanConditions, 'async-methods': asyncMethods, 'ban-prefix': banPrefix, 'class-pattern': classPattern, diff --git a/src/rules/methods-must-be-public.ts b/src/rules/methods-must-be-public.ts index b4f949c..08ddf1e 100644 --- a/src/rules/methods-must-be-public.ts +++ b/src/rules/methods-must-be-public.ts @@ -19,7 +19,7 @@ const rule: Rule.RuleModule = { const parserServices = context.parserServices; return { ...stencil.rules, - 'MethodDefinition': (node: any) => { + 'MethodDefinition[kind=method]': (node: any) => { if (stencil.isComponent() && getDecorator(node, 'Method')) { const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node) as ts.Node; if (isPrivate(originalNode)) { diff --git a/src/rules/no-unused-watch.ts b/src/rules/no-unused-watch.ts index c12a215..d9c5f93 100644 --- a/src/rules/no-unused-watch.ts +++ b/src/rules/no-unused-watch.ts @@ -47,7 +47,7 @@ const rule: Rule.RuleModule = { 'ClassDeclaration': stencil.rules.ClassDeclaration, 'ClassProperty > Decorator[expression.callee.name=Prop]': getVars, 'ClassProperty > Decorator[expression.callee.name=State]': getVars, - 'MethodDefinition > Decorator[expression.callee.name=Watch]': checkWatch, + 'MethodDefinition[kind=method] > Decorator[expression.callee.name=Watch]': checkWatch, 'ClassDeclaration:exit': (node: any) => { if (!stencil.isComponent()) { return; diff --git a/src/rules/own-methods-must-be-private.ts b/src/rules/own-methods-must-be-private.ts index 82bd2e3..f8b74d1 100644 --- a/src/rules/own-methods-must-be-private.ts +++ b/src/rules/own-methods-must-be-private.ts @@ -20,7 +20,7 @@ const rule: Rule.RuleModule = { const parserServices = context.parserServices; return { ...stencil.rules, - 'MethodDefinition': (node: any) => { + 'MethodDefinition[kind=method]': (node: any) => { if (!stencil.isComponent()) { return; } diff --git a/src/rules/prefer-vdom-listener.ts b/src/rules/prefer-vdom-listener.ts index 70c3bf8..b953bbb 100644 --- a/src/rules/prefer-vdom-listener.ts +++ b/src/rules/prefer-vdom-listener.ts @@ -16,7 +16,7 @@ const rule: Rule.RuleModule = { const stencil = stencilComponentContext(); return { ...stencil.rules, - 'MethodDefinition': (node: any) => { + 'MethodDefinition[kind=method]': (node: any) => { if (!stencil.isComponent()) { return; } diff --git a/src/rules/render-returns-host.ts b/src/rules/render-returns-host.ts index e061264..e8212fb 100644 --- a/src/rules/render-returns-host.ts +++ b/src/rules/render-returns-host.ts @@ -33,7 +33,7 @@ const rule: Rule.RuleModule = { return { ...stencil.rules, - 'MethodDefinition[key.name=render] ReturnStatement': (node: any) => { + 'MethodDefinition[kind=method][key.name=render] ReturnStatement': (node: any) => { if (!stencil.isComponent()) { return; } diff --git a/src/rules/required-jsdoc.ts b/src/rules/required-jsdoc.ts index a3e3d23..359400d 100644 --- a/src/rules/required-jsdoc.ts +++ b/src/rules/required-jsdoc.ts @@ -51,7 +51,7 @@ const rule: Rule.RuleModule = { return { ...stencil.rules, 'ClassProperty': getJSDoc, - 'MethodDefinition': getJSDoc + 'MethodDefinition[kind=method]': getJSDoc }; } }; diff --git a/src/rules/required-prefix.ts b/src/rules/required-prefix.ts index 5cd4123..a04cca4 100644 --- a/src/rules/required-prefix.ts +++ b/src/rules/required-prefix.ts @@ -29,10 +29,6 @@ const rule: Rule.RuleModule = { return; } const [{ tag }] = parseDecorator(component); - if (!tag) { - console.warn('[required-prefix] No tag detected for component'); - return; - } const options = context.options[0]; const match = options.some((t: string) => tag.startsWith(t)); diff --git a/src/rules/reserved-member-names.ts b/src/rules/reserved-member-names.ts index 651dc81..ce342c9 100644 --- a/src/rules/reserved-member-names.ts +++ b/src/rules/reserved-member-names.ts @@ -52,7 +52,7 @@ const rule: Rule.RuleModule = { return { ...stencil.rules, 'ClassProperty > Decorator[expression.callee.name=Prop]': checkName, - 'MethodDefinition > Decorator[expression.callee.name=Method]': checkName + 'MethodDefinition[kind=method] > Decorator[expression.callee.name=Method]': checkName }; } }; diff --git a/src/rules/strict-boolean-conditions.ts b/src/rules/strict-boolean-conditions.ts new file mode 100644 index 0000000..dd5c9d8 --- /dev/null +++ b/src/rules/strict-boolean-conditions.ts @@ -0,0 +1,448 @@ +/** + * @license + * Copyright 2016 Palantir Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as ts from "typescript"; + +import { Rule } from 'eslint'; + +const OPTION_ALLOW_NULL_UNION = "allow-null-union"; +const OPTION_ALLOW_UNDEFINED_UNION = "allow-undefined-union"; +const OPTION_ALLOW_STRING = "allow-string"; +const OPTION_ALLOW_ENUM = "allow-enum"; +const OPTION_ALLOW_NUMBER = "allow-number"; +const OPTION_ALLOW_MIX = "allow-mix"; +const OPTION_ALLOW_BOOLEAN_OR_UNDEFINED = "allow-boolean-or-undefined"; +const OPTION_ALLOW_ANY_RHS = "allow-any-rhs"; + +const rule: Rule.RuleModule = { + meta: { + docs: { + description: `Restricts the types allowed in boolean expressions. By default only booleans are allowed. + The following nodes are checked: + * Arguments to the \`!\`, \`&&\`, and \`||\` operators + * The condition in a conditional expression (\`cond ? x : y\`) + * Conditions for \`if\`, \`for\`, \`while\`, and \`do-while\` statements.`, + category: 'Possible Errors', + recommended: true + }, + schema: [{ + type: "array", + items: { + type: "string", + enum: [ + OPTION_ALLOW_NULL_UNION, + OPTION_ALLOW_UNDEFINED_UNION, + OPTION_ALLOW_STRING, + OPTION_ALLOW_ENUM, + OPTION_ALLOW_NUMBER, + OPTION_ALLOW_BOOLEAN_OR_UNDEFINED, + OPTION_ALLOW_ANY_RHS + ], + }, + minLength: 0, + maxLength: 5, + }], + type: 'problem' + }, + + create(context): Rule.RuleListener { + const parserServices = context.parserServices; + const program = parserServices.program; + const rawOptions = context.options[0] || ['allow-null-union', 'allow-undefined-union', 'allow-boolean-or-undefined'] + const options = parseOptions(rawOptions, true); + const checker = program.getTypeChecker() as ts.TypeChecker; + + function walk(sourceFile: ts.SourceFile): void { + ts.forEachChild(sourceFile, function cb(node: ts.Node): void { + switch (node.kind) { + case ts.SyntaxKind.PrefixUnaryExpression: { + const { + operator, + operand + } = node as ts.PrefixUnaryExpression; + if (operator === ts.SyntaxKind.ExclamationToken) { + checkExpression(operand, node as ts.PrefixUnaryExpression); + } + break; + } + + case ts.SyntaxKind.IfStatement: + case ts.SyntaxKind.WhileStatement: + case ts.SyntaxKind.DoStatement: { + const c = node as ts.IfStatement | ts.WhileStatement | ts.DoStatement; + // If it's a boolean binary expression, we'll check it when recursing. + checkExpression(c.expression, c); + break; + } + + case ts.SyntaxKind.ConditionalExpression: + checkExpression((node as ts.ConditionalExpression).condition, node as ts.ConditionalExpression); + break; + + case ts.SyntaxKind.ForStatement: { + const { + condition + } = node as ts.ForStatement; + if (condition !== undefined) { + checkExpression(condition, node as ts.ForStatement); + } + } + } + + return ts.forEachChild(node, cb); + }); + + function checkExpression(node: ts.Expression, location: Location): void { + const type = checker.getTypeAtLocation(node); + const failure = getTypeFailure(type, options); + if (failure !== undefined) { + if (failure === TypeFailure.AlwaysTruthy && + !options.strictNullChecks && + (options.allowNullUnion || options.allowUndefinedUnion)) { + // OK; It might be null/undefined. + return; + } + const originalNode = parserServices.tsNodeToESTreeNodeMap.get(node); + context.report({ + node: originalNode, + message: showFailure(location, failure, isUnionType(type), options), + }) + } + } + } + + return { + 'Program': (node: any) => { + const sourceFile = parserServices.esTreeNodeToTSNodeMap.get(node); + walk(sourceFile); + } + }; + } +}; + + + +interface Options { + strictNullChecks: boolean; + allowNullUnion: boolean; + allowUndefinedUnion: boolean; + allowString: boolean; + allowEnum: boolean; + allowNumber: boolean; + allowMix: boolean; + allowBooleanOrUndefined: boolean; + allowAnyRhs: boolean; +} + +function parseOptions(ruleArguments: string[], strictNullChecks: boolean): Options { + return { + strictNullChecks, + allowNullUnion: has(OPTION_ALLOW_NULL_UNION), + allowUndefinedUnion: has(OPTION_ALLOW_UNDEFINED_UNION), + allowString: has(OPTION_ALLOW_STRING), + allowEnum: has(OPTION_ALLOW_ENUM), + allowNumber: has(OPTION_ALLOW_NUMBER), + allowMix: has(OPTION_ALLOW_MIX), + allowBooleanOrUndefined: has(OPTION_ALLOW_BOOLEAN_OR_UNDEFINED), + allowAnyRhs: has(OPTION_ALLOW_ANY_RHS), + }; + + function has(name: string): boolean { + return ruleArguments.indexOf(name) !== -1; + } +} + +function getTypeFailure(type: ts.Type, options: Options): TypeFailure | undefined { + if (isUnionType(type)) { + return handleUnion(type, options); + } + + const kind = getKind(type); + const failure = failureForKind(kind, /*isInUnion*/ false, options); + if (failure !== undefined) { + return failure; + } + + switch (triState(kind)) { + case true: + // Allow 'any'. Allow 'true' itself, but not any other always-truthy type. + // tslint:disable-next-line no-bitwise + return isTypeFlagSet(type, ts.TypeFlags.Any | ts.TypeFlags.BooleanLiteral) ? undefined : TypeFailure.AlwaysTruthy; + case false: + // Allow 'false' itself, but not any other always-falsy type + return isTypeFlagSet(type, ts.TypeFlags.BooleanLiteral) ? undefined : TypeFailure.AlwaysFalsy; + case undefined: + return undefined; + } +} + +function isBooleanUndefined(type: ts.UnionType): boolean | undefined { + let isTruthy = false; + for (const ty of type.types) { + if (isTypeFlagSet(ty, ts.TypeFlags.Boolean)) { + isTruthy = true; + } else if (isTypeFlagSet(ty, ts.TypeFlags.BooleanLiteral)) { + isTruthy = isTruthy || (ty as ts.IntrinsicType).intrinsicName === "true"; + } else if (!isTypeFlagSet(ty, ts.TypeFlags.Void | ts.TypeFlags.Undefined)) { // tslint:disable-line:no-bitwise + return undefined; + } + } + return isTruthy; +} + +function handleUnion(type: ts.UnionType, options: Options): TypeFailure | undefined { + if (options.allowBooleanOrUndefined) { + switch (isBooleanUndefined(type)) { + case true: + return undefined; + case false: + return TypeFailure.AlwaysFalsy; + } + } + + for (const ty of type.types) { + const kind = getKind(ty); + const failure = failureForKind(kind, /*isInUnion*/ true, options); + if (failure !== undefined) { + return failure; + } + } + return undefined; +} + +/** Fails if a kind of falsiness is not allowed. */ +function failureForKind(kind: TypeKind, isInUnion: boolean, options: Options): TypeFailure | undefined { + switch (kind) { + case TypeKind.String: + case TypeKind.FalseStringLiteral: + return options.allowString ? undefined : TypeFailure.String; + case TypeKind.Number: + case TypeKind.FalseNumberLiteral: + return options.allowNumber ? undefined : TypeFailure.Number; + case TypeKind.Enum: + return options.allowEnum ? undefined : TypeFailure.Enum; + case TypeKind.Promise: + return TypeFailure.Promise; + case TypeKind.Null: + return isInUnion && !options.allowNullUnion ? TypeFailure.Null : undefined; + case TypeKind.Undefined: + return isInUnion && !options.allowUndefinedUnion ? TypeFailure.Undefined : undefined; + default: + return undefined; + } +} + +export type Location = | + ts.PrefixUnaryExpression | + ts.IfStatement | + ts.WhileStatement | + ts.DoStatement | + ts.ForStatement | + ts.ConditionalExpression | + ts.BinaryExpression; + +export const enum TypeFailure { + AlwaysTruthy, + AlwaysFalsy, + String, + Number, + Null, + Undefined, + Enum, + Mixes, + Promise +} + +const enum TypeKind { + String, + FalseStringLiteral, + Number, + FalseNumberLiteral, + Boolean, + FalseBooleanLiteral, + Null, + Undefined, + Enum, + AlwaysTruthy, + Promise +} + +/** Divides a type into always true, always false, or unknown. */ +function triState(kind: TypeKind): boolean | undefined { + switch (kind) { + case TypeKind.String: + case TypeKind.Number: + case TypeKind.Boolean: + case TypeKind.Enum: + return undefined; + + case TypeKind.Null: + case TypeKind.Undefined: + case TypeKind.FalseNumberLiteral: + case TypeKind.FalseStringLiteral: + case TypeKind.FalseBooleanLiteral: + return false; + + case TypeKind.AlwaysTruthy: + case TypeKind.Promise: + return true; + } +} + +function getKind(type: ts.Type): TypeKind { + return is(ts.TypeFlags.StringLike) ? TypeKind.String : + is(ts.TypeFlags.NumberLike) ? TypeKind.Number : + is(ts.TypeFlags.Boolean) ? TypeKind.Boolean : + isObject('Promise') ? TypeKind.Promise : + is(ts.TypeFlags.Null) ? TypeKind.Null : + is(ts.TypeFlags.Undefined | ts.TypeFlags.Void) ? TypeKind.Undefined // tslint:disable-line:no-bitwise + : + is(ts.TypeFlags.EnumLike) ? TypeKind.Enum : + is(ts.TypeFlags.BooleanLiteral) ? + ((type as ts.IntrinsicType).intrinsicName === "true" ? TypeKind.AlwaysTruthy : TypeKind.FalseBooleanLiteral) : + TypeKind.AlwaysTruthy; + + function is(flags: ts.TypeFlags) { + return isTypeFlagSet(type, flags); + } + + function isObject(name: string) { + const symbol = type.getSymbol(); + return (symbol && symbol.getName() === name) + } +} + + +function binaryBooleanExpressionKind(node: ts.BinaryExpression): "&&" | "||" | undefined { + switch (node.operatorToken.kind) { + case ts.SyntaxKind.AmpersandAmpersandToken: + return "&&"; + case ts.SyntaxKind.BarBarToken: + return "||"; + default: + return undefined; + } +} + +function stringOr(parts: string[]): string { + switch (parts.length) { + case 1: + return parts[0]; + case 2: + return `${parts[0]} or ${parts[1]}`; + default: + let res = ""; + for (let i = 0; i < parts.length - 1; i++) { + res += `${parts[i]}, `; + } + return `${res}or ${parts[parts.length - 1]}`; + } +} + +function isUnionType(type: ts.Type): type is ts.UnionType { + return isTypeFlagSet(type, ts.TypeFlags.Union) && !isTypeFlagSet(type, ts.TypeFlags.Enum); +} + +function showLocation(n: Location): string { + switch (n.kind) { + case ts.SyntaxKind.PrefixUnaryExpression: + return "operand for the '!' operator"; + case ts.SyntaxKind.ConditionalExpression: + return "condition"; + case ts.SyntaxKind.ForStatement: + return "'for' condition"; + case ts.SyntaxKind.IfStatement: + return "'if' condition"; + case ts.SyntaxKind.WhileStatement: + return "'while' condition"; + case ts.SyntaxKind.DoStatement: + return "'do-while' condition"; + case ts.SyntaxKind.BinaryExpression: + return `operand for the '${binaryBooleanExpressionKind(n)}' operator`; + } +} + +function showFailure(location: Location, ty: TypeFailure, unionType: boolean, options: Options): string { + const expectedTypes = showExpectedTypes(options); + const expected = expectedTypes.length === 1 ? + `Only ${expectedTypes[0]}s are allowed` : + `Allowed types are ${stringOr(expectedTypes)}`; + const tyFail = showTypeFailure(ty, unionType, options.strictNullChecks); + return `This type is not allowed in the ${showLocation(location)} because it ${tyFail}. ${expected}.`; +} + +function showExpectedTypes(options: Options): string[] { + const parts = ["boolean"]; + if (options.allowNullUnion) { + parts.push("null-union"); + } + if (options.allowUndefinedUnion) { + parts.push("undefined-union"); + } + if (options.allowString) { + parts.push("string"); + } + if (options.allowEnum) { + parts.push("enum"); + } + if (options.allowNumber) { + parts.push("number"); + } + if (options.allowBooleanOrUndefined) { + parts.push("boolean-or-undefined"); + } + return parts; +} + +function showTypeFailure(ty: TypeFailure, unionType: boolean, strictNullChecks: boolean) { + const is = unionType ? "could be" : "is"; + switch (ty) { + case TypeFailure.AlwaysTruthy: + return strictNullChecks ? + "is always truthy" : + "is always truthy. It may be null/undefined, but neither " + + `'${OPTION_ALLOW_NULL_UNION}' nor '${OPTION_ALLOW_UNDEFINED_UNION}' is set`; + case TypeFailure.AlwaysFalsy: + return "is always falsy"; + case TypeFailure.String: + return `${is} a string`; + case TypeFailure.Number: + return `${is} a number`; + case TypeFailure.Null: + return `${is} null`; + case TypeFailure.Undefined: + return `${is} undefined`; + case TypeFailure.Enum: + return `${is} an enum`; + case TypeFailure.Promise: + return "promise handled as boolean expression"; + case TypeFailure.Mixes: + return "unions more than one truthy/falsy type"; + } +} + +function isTypeFlagSet(obj: any, flag: any) { + return (obj.flags & flag) !== 0; +} + +declare module "typescript" { + // No other way to distinguish boolean literal true from boolean literal false + export interface IntrinsicType extends ts.Type { + intrinsicName: string; + } +} + +export default rule; \ No newline at end of file diff --git a/src/rules/strict-mutable.ts b/src/rules/strict-mutable.ts index 338c1ac..1772fa9 100644 --- a/src/rules/strict-mutable.ts +++ b/src/rules/strict-mutable.ts @@ -35,18 +35,22 @@ const rule: Rule.RuleModule = { const parserServices = context.parserServices; function getMutable(node: any) { - if (stencil.isComponent()) { - const parsed = parseDecorator(node); - const mutable = parsed && parsed.length && parsed[0].mutable || false; - if (mutable) { - const varName = node.parent.key.name; - mutableProps.set(varName, node); - } + if (!stencil.isComponent()) { + return; + } + const parsed = parseDecorator(node); + const mutable = parsed && parsed.length && parsed[0].mutable || false; + if (mutable) { + const varName = node.parent.key.name; + mutableProps.set(varName, node); } } function removeVar(name: any) { - if (name && name.escapedText) { + if (!name) { + return; + } + if (name.escapedText) { mutableProps.delete(name.escapedText); } } @@ -59,38 +63,32 @@ const rule: Rule.RuleModule = { } function checkStatement(st: any) { - if (st) { - const { expression, thenStatement, elseStatement } = st; - [...getArray(thenStatement), ...getArray(elseStatement), expression].filter((ex) => !!ex) - .forEach(checkExpression); + if (!st) { + return; } + const { expression, thenStatement, elseStatement } = st; + [...getArray(thenStatement), ...getArray(elseStatement), expression].filter((ex) => !!ex).forEach(checkExpression); } function checkExpression(expr: any) { - if (expr) { - const { expression, left, openingElement, body, nextContainer, statements, thenStatement } = expr; - const args = expr.arguments; - if (left && left.name && expr.operatorToken && ASSIGN_TOKENS.includes(expr.operatorToken.kind)) { - removeVar(left.name); - } - if (openingElement) { - getArray(openingElement.attributes).forEach(checkExpression); - } - [...getArray(thenStatement), expression, body, nextContainer, ...getArray(args)].filter((ex) => !!ex) - .forEach(checkExpression); - if (statements) { - statements.forEach(checkStatement); - } + if (!expr) { + return; + } + const { expression, left, openingElement, body, nextContainer, statements, thenStatement } = expr; + if (left && left.name && expr.operatorToken && ASSIGN_TOKENS.includes(expr.operatorToken.kind)) { + removeVar(left.name); + } + if (openingElement) { + getArray(openingElement.attributes).forEach(checkExpression); + } + [...getArray(thenStatement), expression, body, nextContainer].filter((ex) => !!ex).forEach(checkExpression); + if (statements) { + statements.forEach(checkStatement); } } - return { - 'ClassDeclaration': stencil.rules.ClassDeclaration, - 'ClassProperty > Decorator[expression.callee.name=Prop]': getMutable, - 'MethodDefinition': (node: any) => { - if (!stencil.isComponent()) { - return; - } + function checkMethod(node: any) { + if (stencil.isComponent()) { const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node); const statements = originalNode.body.statements; if (statements && statements.length) { @@ -98,34 +96,22 @@ const rule: Rule.RuleModule = { checkStatement(st); }); } - }, + } + } + + return { + 'ClassDeclaration': stencil.rules.ClassDeclaration, + 'ClassProperty > Decorator[expression.callee.name=Prop]': getMutable, + 'MethodDefinition, ArrowFunctionExpression': checkMethod, 'ClassDeclaration:exit': (node: any) => { if (!stencil.isComponent()) { return; } stencil.rules['ClassDeclaration:exit'](node); mutableProps.forEach((varNode, varName) => { - const originalNode = parserServices.esTreeNodeToTSNodeMap.get(varNode.parent); - const text = originalNode.getFullText(); - const parsed = parseDecorator(varNode); context.report({ node: varNode.parent, message: `@Prop() "${varName}" should not be mutable`, - fix(fixer) { - const options = parsed && parsed.length && parsed[0] || {}; - delete options.mutable; - let opts = ''; - if (options && Object.keys(options).length) { - opts = Object.keys(options).map((key) => { - const value = options[key]; - const val = typeof value === 'string' ? `'${value}'` : value; - return `${key}: ${val}`; - }).join(', '); - opts = `{ ${opts} }`; - } - const result = text.replace(/@Prop\((.*)?\)/, `@Prop(${opts})`); - return fixer.replaceText(varNode, result); - } }); }); mutableProps.clear(); diff --git a/src/utils.ts b/src/utils.ts index 3af4d12..8bb1850 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -32,7 +32,7 @@ export function parseDecorator(decorator: any) { } export function decoratorName(dec: any): string { - return dec && dec.expression && dec.expression.callee.name; + return dec && dec.expression && dec.expression.callee && dec.expression.callee.name; } export function isDecoratorNamed(propName: string) { diff --git a/tests/lib/rules/element-type/element-type.spec.ts b/tests/lib/rules/element-type/element-type.spec.ts index 465cb86..f1b6203 100644 --- a/tests/lib/rules/element-type/element-type.spec.ts +++ b/tests/lib/rules/element-type/element-type.spec.ts @@ -20,7 +20,7 @@ describe('stencil rules', () => { { code: fs.readFileSync(files.wrong, 'utf8'), filename: files.wrong, - errors: 3 + errors: 1 } ] }); diff --git a/tests/lib/rules/element-type/element-type.wrong.tsx b/tests/lib/rules/element-type/element-type.wrong.tsx index fdffab4..f3b97b4 100644 --- a/tests/lib/rules/element-type/element-type.wrong.tsx +++ b/tests/lib/rules/element-type/element-type.wrong.tsx @@ -1,9 +1,8 @@ @Component({ tag: 'sample-tag' }) export class TheSampleTag { - @Element() private theElement!: HTMLElement; - - @Element() private readonly theElement!: HTMLElement; - + /** + * Element: is the element + */ @Element() theElement!: HTMLElement; render() { diff --git a/tests/lib/rules/strict-mutable/strict-mutable.good.tsx b/tests/lib/rules/strict-mutable/strict-mutable.good.tsx index 8b8c1b8..8ae98f1 100644 --- a/tests/lib/rules/strict-mutable/strict-mutable.good.tsx +++ b/tests/lib/rules/strict-mutable/strict-mutable.good.tsx @@ -7,7 +7,6 @@ export class SampleTag { @Prop({ mutable: true }) mutableInJsx?: boolean; @Prop({ mutable: true }) mutableInJsx2?: boolean; @Prop({ mutable: true }) testMutableReturn?: boolean; - @Prop({ mutable: true }) mutableInArrayFn?: boolean; private internalMethod() { const test = 'hi'; @@ -18,9 +17,6 @@ export class SampleTag { } private onClick(e: Event) { - this.eventHandler((variable) => { - this.mutableInArrayFn = variable; - }); e.preventDefault(); if (!this.testNotMutable) { this.testMutable = true; diff --git a/tsconfig.json b/tsconfig.json index ec3d8d5..e2c2260 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,16 +3,15 @@ "allowSyntheticDefaultImports": true, "allowUnreachableCode": false, "allowUnusedLabels": false, - "declaration": true, - "declarationMap": true, + "declaration": false, "esModuleInterop": true, "experimentalDecorators": true, - "module": "commonjs", + "module": "esnext", "moduleResolution": "node", "strict": true, "target": "es2017", - "outDir": "dist", + "outDir": "dist/build", "lib": ["es2017"] }, - "include": ["src"] + "files": ["src/index.ts"], }