From 6718906fa83fbceb947b0ea0b7f23428b993b1d3 Mon Sep 17 00:00:00 2001 From: Josh Goldberg Date: Wed, 3 Apr 2019 12:48:27 -0400 Subject: [PATCH 01/17] feat(eslint-plugin): added new rule unbound-method (#204) --- packages/eslint-plugin/README.md | 1 + packages/eslint-plugin/ROADMAP.md | 3 +- .../docs/rules/unbound-method.md | 92 ++++++ .../eslint-plugin/src/rules/unbound-method.ts | 123 ++++++++ .../tests/rules/unbound-method.test.ts | 273 ++++++++++++++++++ 5 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 packages/eslint-plugin/docs/rules/unbound-method.md create mode 100644 packages/eslint-plugin/src/rules/unbound-method.ts create mode 100644 packages/eslint-plugin/tests/rules/unbound-method.test.ts diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 6d433a5eef51..8057f15843d4 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -152,6 +152,7 @@ Then you should add `airbnb` (or `airbnb-base`) to your `extends` section of `.e | [`@typescript-eslint/promise-function-async`](./docs/rules/promise-function-async.md) | Requires any function or method that returns a Promise to be marked async. (`promise-function-async` from TSLint) | | | :thought_balloon: | | [`@typescript-eslint/restrict-plus-operands`](./docs/rules/restrict-plus-operands.md) | When adding two variables, operands must both be of type number or of type string. (`restrict-plus-operands` from TSLint) | | | :thought_balloon: | | [`@typescript-eslint/type-annotation-spacing`](./docs/rules/type-annotation-spacing.md) | Require consistent spacing around type annotations (`typedef-whitespace` from TSLint) | :heavy_check_mark: | :wrench: | | +| [`@typescript-eslint/unbound-method`](./docs/rules/unbound-method.md) | Enforces unbound methods are called with their expected scope. (`no-unbound-method` from TSLint) | :heavy_check_mark: | | :thought_balloon: | | [`@typescript-eslint/unified-signatures`](./docs/rules/unified-signatures.md) | Warns for any two overloads that could be unified into one. (`unified-signatures` from TSLint) | | | | diff --git a/packages/eslint-plugin/ROADMAP.md b/packages/eslint-plugin/ROADMAP.md index 11ccb8ac22b7..c0cdcbf0b3ea 100644 --- a/packages/eslint-plugin/ROADMAP.md +++ b/packages/eslint-plugin/ROADMAP.md @@ -77,7 +77,7 @@ | [`no-submodule-imports`] | πŸŒ“ | [`import/no-internal-modules`] (slightly different) | | [`no-switch-case-fall-through`] | 🌟 | [`no-fallthrough`][no-fallthrough] | | [`no-this-assignment`] | βœ… | [`@typescript-eslint/no-this-alias`] | -| [`no-unbound-method`] | πŸ›‘ | N/A | +| [`no-unbound-method`] | βœ… | [`@typescript-eslint/unbound-method`] | | [`no-unnecessary-class`] | βœ… | [`@typescript-eslint/no-extraneous-class`] | | [`no-unsafe-any`] | πŸ›‘ | N/A | | [`no-unsafe-finally`] | 🌟 | [`no-unsafe-finally`][no-unsafe-finally] | @@ -586,6 +586,7 @@ Relevant plugins: [`chai-expect-keywords`](https://github.com/gavinaiken/eslint- [`@typescript-eslint/no-namespace`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-namespace.md [`@typescript-eslint/no-non-null-assertion`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-non-null-assertion.md [`@typescript-eslint/no-triple-slash-reference`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-triple-slash-reference.md +[`@typescript-eslint/unbound-method`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/unbound-method.md [`@typescript-eslint/no-unnecessary-type-assertion`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-unnecessary-type-assertion.md [`@typescript-eslint/no-var-requires`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-var-requires.md [`@typescript-eslint/type-annotation-spacing`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/type-annotation-spacing.md diff --git a/packages/eslint-plugin/docs/rules/unbound-method.md b/packages/eslint-plugin/docs/rules/unbound-method.md new file mode 100644 index 000000000000..d0268dadeec3 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/unbound-method.md @@ -0,0 +1,92 @@ +# Enforces unbound methods are called with their expected scope (unbound-method) + +Warns when a method is used outside of a method call. + +Class functions don't preserve the class scope when passed as standalone variables. + +## Rule Details + +Examples of **incorrect** code for this rule + +```ts +class MyClass { + public log(): void { + console.log(this); + } +} + +const instance = new MyClass(); + +// This logs the global scope (`window`/`global`), not the class instance +const myLog = instance.log; +myLog(); + +// This log might later be called with an incorrect scope +const { log } = instance; +``` + +Examples of **correct** code for this rule + +```ts +class MyClass { + public logUnbound(): void { + console.log(this); + } + + public logBound = () => console.log(this); +} + +const instance = new MyClass(); + +// logBound will always be bound with the correct scope +const { logBound } = instance; +logBound(); + +// .bind and lambdas will also add a correct scope +const dotBindLog = instance.log.bind(instance); +const innerLog = () => instance.log(); +``` + +## Options + +The rule accepts an options object with the following property: + +- `ignoreStatic` to not check whether `static` methods are correctly bound + +### `ignoreStatic` + +Examples of **correct** code for this rule with `{ ignoreStatic: true }`: + +```ts +class OtherClass { + static log() { + console.log(OtherClass); + } +} + +// With `ignoreStatic`, statics are assumed to not rely on a particular scope +const { log } = OtherClass; + +log(); +``` + +### Example + +```json +{ + "@typescript-eslint/unbound-method": [ + "error", + { + "ignoreStatic": true + } + ] +} +``` + +## When Not To Use It + +If your code intentionally waits to bind methods after use, such as by passing a `scope: this` along with the method, you can disable this rule. + +## Related To + +- TSLint: [no-unbound-method](https://palantir.github.io/tslint/rules/no-unbound-method/) diff --git a/packages/eslint-plugin/src/rules/unbound-method.ts b/packages/eslint-plugin/src/rules/unbound-method.ts new file mode 100644 index 000000000000..621e95ec7c53 --- /dev/null +++ b/packages/eslint-plugin/src/rules/unbound-method.ts @@ -0,0 +1,123 @@ +import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/typescript-estree'; +import * as tsutils from 'tsutils'; +import * as ts from 'typescript'; + +import * as util from '../util'; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +interface Config { + ignoreStatic: boolean; +} + +type Options = [Config]; + +type MessageIds = 'unbound'; + +export default util.createRule({ + name: 'unbound-method', + meta: { + docs: { + category: 'Best Practices', + description: + 'Enforces unbound methods are called with their expected scope.', + tslintName: 'no-unbound-method', + recommended: 'error', + }, + messages: { + unbound: + 'Avoid referencing unbound methods which may cause unintentional scoping of `this`.', + }, + schema: [ + { + type: 'object', + properties: { + ignoreStatic: { + type: 'boolean', + }, + }, + additionalProperties: false, + }, + ], + type: 'problem', + }, + defaultOptions: [ + { + ignoreStatic: false, + }, + ], + create(context, [{ ignoreStatic }]) { + const parserServices = util.getParserServices(context); + const checker = parserServices.program.getTypeChecker(); + + return { + [AST_NODE_TYPES.MemberExpression](node: TSESTree.MemberExpression) { + if (isSafeUse(node)) { + return; + } + + const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node); + const symbol = checker.getSymbolAtLocation(originalNode); + + if (symbol && isDangerousMethod(symbol, ignoreStatic)) { + context.report({ + messageId: 'unbound', + node, + }); + } + }, + }; + }, +}); + +function isDangerousMethod(symbol: ts.Symbol, ignoreStatic: boolean) { + const { valueDeclaration } = symbol; + + switch (valueDeclaration.kind) { + case ts.SyntaxKind.MethodDeclaration: + case ts.SyntaxKind.MethodSignature: + return !( + ignoreStatic && + tsutils.hasModifier( + valueDeclaration.modifiers, + ts.SyntaxKind.StaticKeyword, + ) + ); + } + + return false; +} + +function isSafeUse(node: TSESTree.Node): boolean { + const parent = node.parent!; + + switch (parent.type) { + case AST_NODE_TYPES.IfStatement: + case AST_NODE_TYPES.ForStatement: + case AST_NODE_TYPES.MemberExpression: + case AST_NODE_TYPES.UpdateExpression: + case AST_NODE_TYPES.WhileStatement: + return true; + + case AST_NODE_TYPES.CallExpression: + return parent.callee === node; + + case AST_NODE_TYPES.ConditionalExpression: + return parent.test === node; + + case AST_NODE_TYPES.LogicalExpression: + return parent.operator !== '||'; + + case AST_NODE_TYPES.TaggedTemplateExpression: + return parent.tag === node; + + case AST_NODE_TYPES.TSNonNullExpression: + case AST_NODE_TYPES.TSAsExpression: + case AST_NODE_TYPES.TSTypeAssertion: + return isSafeUse(parent); + } + + return false; +} diff --git a/packages/eslint-plugin/tests/rules/unbound-method.test.ts b/packages/eslint-plugin/tests/rules/unbound-method.test.ts new file mode 100644 index 000000000000..8b4ed003fd4c --- /dev/null +++ b/packages/eslint-plugin/tests/rules/unbound-method.test.ts @@ -0,0 +1,273 @@ +import path from 'path'; +import rule from '../../src/rules/unbound-method'; +import { RuleTester } from '../RuleTester'; + +const rootPath = path.join(process.cwd(), 'tests/fixtures/'); + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: rootPath, + project: './tsconfig.json', + }, +}); + +ruleTester.run('unbound-method', rule, { + valid: [ + ` +class ContainsMethods { + bound?: () => void; + unbound?(): void; + + static boundStatic?: () => void; + static unboundStatic?(): void; +} + +let instance = new ContainsMethods(); + +instance.bound(); +instance.unbound(); + +ContainsMethods.boundStatic(); +ContainsMethods.unboundStatic(); + +{ + const bound = instance.bound; + const boundStatic = ContainsMethods; +} +{ + const { bound } = instance; + const { boundStatic } = ContainsMethods; +} + +(instance.bound)(); +(instance.unbound)(); + +(ContainsMethods.boundStatic)(); +(ContainsMethods.unboundStatic)(); + +instance.bound\`\`; +instance.unbound\`\`; + +if (instance.bound) { } +if (instance.unbound) { } + +if (ContainsMethods.boundStatic) { } +if (ContainsMethods.unboundStatic) { } + +while (instance.bound) { } +while (instance.unbound) { } + +while (ContainsMethods.boundStatic) { } +while (ContainsMethods.unboundStatic) { } + +instance.bound as any; +ContainsMethods.boundStatic as any; + +instance.bound++; ++instance.bound; +++instance.bound; +instance.bound--; +-instance.bound; +--instance.bound; +instance.bound += 1; +instance.bound -= 1; +instance.bound *= 1; +instance.bound /= 1; + +instance.bound || 0; +instane.bound && 0; + +instance.bound ? 1 : 0; +instance.unbound ? 1 : 0; + +ContainsMethods.boundStatic++; ++ContainsMethods.boundStatic; +++ContainsMethods.boundStatic; +ContainsMethods.boundStatic--; +-ContainsMethods.boundStatic; +--ContainsMethods.boundStatic; +ContainsMethods.boundStatic += 1; +ContainsMethods.boundStatic -= 1; +ContainsMethods.boundStatic *= 1; +ContainsMethods.boundStatic /= 1; + +ContainsMethods.boundStatic || 0; +instane.boundStatic && 0; + +ContainsMethods.boundStatic ? 1 : 0; +ContainsMethods.unboundStatic ? 1 : 0; +`, + ], + invalid: [ + { + code: ` +class ContainsMethods { + bound?: () => void; + unbound?(): void; + static boundStatic?: () => void; + static unboundStatic?(): void; +} + +const instance = new ContainsMethods(); + +{ + const unbound = instance.unbound; + const unboundStatic = ContainsMethods.unboundStatic; +} +{ + const { unbound } = instance.unbound; + const { unboundStatic } = ContainsMethods.unboundStatic; +} + +instance.unbound; +instance.unbound as any; + +ContainsMethods.unboundStatic; +ContainsMethods.unboundStatic as any; + +instance.unbound++; ++instance.unbound; +++instance.unbound; +instance.unbound--; +-instance.unbound; +--instance.unbound; +instance.unbound += 1; +instance.unbound -= 1; +instance.unbound *= 1; +instance.unbound /= 1; + +instance.unbound || 0; +instance.unbound && 0; + +ContainsMethods.unboundStatic++; ++ContainsMethods.unboundStatic; +++ContainsMethods.unboundStatic; +ContainsMethods.unboundStatic--; +-ContainsMethods.unboundStatic; +--ContainsMethods.unboundStatic; +ContainsMethods.unboundStatic += 1; +ContainsMethods.unboundStatic -= 1; +ContainsMethods.unboundStatic *= 1; +ContainsMethods.unboundStatic /= 1; + +ContainsMethods.unboundStatic || 0; +ContainsMethods.unboundStatic && 0; +`, + errors: [ + { + line: 12, + messageId: 'unbound', + }, + { + line: 13, + messageId: 'unbound', + }, + { + line: 16, + messageId: 'unbound', + }, + { + line: 17, + messageId: 'unbound', + }, + { + line: 20, + messageId: 'unbound', + }, + { + line: 21, + messageId: 'unbound', + }, + { + line: 23, + messageId: 'unbound', + }, + { + line: 24, + messageId: 'unbound', + }, + { + line: 27, + messageId: 'unbound', + }, + { + line: 30, + messageId: 'unbound', + }, + { + line: 32, + messageId: 'unbound', + }, + { + line: 33, + messageId: 'unbound', + }, + { + line: 34, + messageId: 'unbound', + }, + { + line: 35, + messageId: 'unbound', + }, + { + line: 37, + messageId: 'unbound', + }, + { + line: 41, + messageId: 'unbound', + }, + { + line: 44, + messageId: 'unbound', + }, + { + line: 46, + messageId: 'unbound', + }, + { + line: 47, + messageId: 'unbound', + }, + { + line: 48, + messageId: 'unbound', + }, + { + line: 49, + messageId: 'unbound', + }, + { + line: 51, + messageId: 'unbound', + }, + ], + }, + { + code: ` +class ContainsMethods { + unbound?(): void; + + static unboundStatic?(): void; +} + +new ContainsMethods().unbound; + +ContainsMethods.unboundStatic; +`, + options: [ + { + ignoreStatic: true, + }, + ], + errors: [ + { + line: 8, + messageId: 'unbound', + }, + ], + }, + ], +}); From fab182f9ac91147e465a0ef8f11a6d700336370b Mon Sep 17 00:00:00 2001 From: C Lentfort Date: Thu, 4 Apr 2019 15:55:57 +0100 Subject: [PATCH 02/17] docs: correct supported typescript version (#401) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0aef0f23d0b1..0a5267f1097f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ The `canary` (latest master) version is: We will always endeavor to support the latest stable version of TypeScript. Sometimes, but not always, changes in TypeScript will not require breaking changes in this project, and so we are able to support more than one version of TypeScript. -**The version range of TypeScript currently supported by this parser is `>=3.2.1 <3.4.0`.** +**The version range of TypeScript currently supported by this parser is `>=3.2.1 <3.5.0`.** This is reflected in the `devDependency` requirement within the package.json file, and it is what the tests will be run against. We have an open `peerDependency` requirement in order to allow for experimentation on newer/beta versions of TypeScript. From 6f77ba611354c49252bc16768e55324fb00b0ac6 Mon Sep 17 00:00:00 2001 From: Dylan Kirkby Date: Sat, 6 Apr 2019 17:57:28 -0700 Subject: [PATCH 03/17] chore: add some tests and refactor tsconfig path resolution (#412) --- .../typescript-estree/src/tsconfig-parser.ts | 21 ++++----- .../tests/lib/semanticInfo.ts | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/typescript-estree/src/tsconfig-parser.ts b/packages/typescript-estree/src/tsconfig-parser.ts index 135bbdfbc04e..d953b39d8ead 100644 --- a/packages/typescript-estree/src/tsconfig-parser.ts +++ b/packages/typescript-estree/src/tsconfig-parser.ts @@ -50,6 +50,12 @@ function diagnosticReporter(diagnostic: ts.Diagnostic): void { const noopFileWatcher = { close: () => {} }; +function getTsconfigPath(tsconfigPath: string, extra: Extra): string { + return path.isAbsolute(tsconfigPath) + ? tsconfigPath + : path.join(extra.tsconfigRootDir || process.cwd(), tsconfigPath); +} + /** * Calculate project environments using options provided by consumer and paths from config * @param code The code being linted @@ -64,7 +70,6 @@ export function calculateProjectParserOptions( extra: Extra, ): ts.Program[] { const results = []; - const tsconfigRootDir = extra.tsconfigRootDir; // preserve reference to code and file being linted currentLintOperationState.code = code; @@ -77,11 +82,8 @@ export function calculateProjectParserOptions( watchCallback(filePath, ts.FileWatcherEventKind.Changed); } - for (let tsconfigPath of extra.projects) { - // if absolute paths aren't provided, make relative to tsconfigRootDir - if (!path.isAbsolute(tsconfigPath)) { - tsconfigPath = path.join(tsconfigRootDir, tsconfigPath); - } + for (let rawTsconfigPath of extra.projects) { + const tsconfigPath = getTsconfigPath(rawTsconfigPath, extra); const existingWatch = knownWatchProgramMap.get(tsconfigPath); @@ -193,12 +195,7 @@ export function createProgram(code: string, filePath: string, extra: Extra) { return undefined; } - let tsconfigPath = extra.projects[0]; - - // if absolute paths aren't provided, make relative to tsconfigRootDir - if (!path.isAbsolute(tsconfigPath)) { - tsconfigPath = path.join(extra.tsconfigRootDir, tsconfigPath); - } + const tsconfigPath = getTsconfigPath(extra.projects[0], extra); const commandLine = ts.getParsedCommandLineOfConfigFile( tsconfigPath, diff --git a/packages/typescript-estree/tests/lib/semanticInfo.ts b/packages/typescript-estree/tests/lib/semanticInfo.ts index ab7df7278a07..df3fed691a40 100644 --- a/packages/typescript-estree/tests/lib/semanticInfo.ts +++ b/packages/typescript-estree/tests/lib/semanticInfo.ts @@ -48,6 +48,21 @@ describe('semanticInfo', () => { ); }); + it(`should cache the created ts.program`, () => { + const filename = testFiles[0]; + const code = readFileSync(filename, 'utf8'); + const options = createOptions(filename); + const optionsProjectString = { + ...options, + project: './tsconfig.json', + }; + expect( + parseAndGenerateServices(code, optionsProjectString).services.program, + ).toBe( + parseAndGenerateServices(code, optionsProjectString).services.program, + ); + }); + it(`should handle "project": "./tsconfig.json" and "project": ["./tsconfig.json"] the same`, () => { const filename = testFiles[0]; const code = readFileSync(filename, 'utf8'); @@ -65,6 +80,38 @@ describe('semanticInfo', () => { ); }); + it(`should resolve absolute and relative tsconfig paths the same`, () => { + const filename = testFiles[0]; + const code = readFileSync(filename, 'utf8'); + const options = createOptions(filename); + const optionsAbsolutePath = { + ...options, + project: `${__dirname}/../fixtures/semanticInfo/tsconfig.json`, + }; + const optionsRelativePath = { + ...options, + project: `./tsconfig.json`, + }; + const absolutePathResult = parseAndGenerateServices( + code, + optionsAbsolutePath, + ); + const relativePathResult = parseAndGenerateServices( + code, + optionsRelativePath, + ); + if (absolutePathResult.services.program === undefined) { + throw new Error('Unable to create ts.program for absolute tsconfig'); + } else if (relativePathResult.services.program === undefined) { + throw new Error('Unable to create ts.program for relative tsconfig'); + } + expect( + absolutePathResult.services.program.getResolvedProjectReferences(), + ).toEqual( + relativePathResult.services.program.getResolvedProjectReferences(), + ); + }); + // case-specific tests it('isolated-file tests', () => { const fileName = resolve(FIXTURES_DIR, 'isolated-file.src.ts'); From 2521b857ae9262a65d736be2f2238e903cc1d862 Mon Sep 17 00:00:00 2001 From: Linda_pp Date: Sun, 7 Apr 2019 16:59:15 +0900 Subject: [PATCH 04/17] fix(eslint-plugin): no-object-literal-type-assertion: fix `as const` is reported (#390) --- .../src/rules/no-object-literal-type-assertion.ts | 6 ++++++ .../tests/rules/no-object-literal-type-assertion.test.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/packages/eslint-plugin/src/rules/no-object-literal-type-assertion.ts b/packages/eslint-plugin/src/rules/no-object-literal-type-assertion.ts index 55c91ac6e512..d83620133217 100644 --- a/packages/eslint-plugin/src/rules/no-object-literal-type-assertion.ts +++ b/packages/eslint-plugin/src/rules/no-object-literal-type-assertion.ts @@ -50,6 +50,12 @@ export default util.createRule({ case AST_NODE_TYPES.TSAnyKeyword: case AST_NODE_TYPES.TSUnknownKeyword: return false; + case AST_NODE_TYPES.TSTypeReference: + // Ignore `as const` and `` (#166) + return ( + node.typeName.type === AST_NODE_TYPES.Identifier && + node.typeName.name !== 'const' + ); default: return true; } diff --git a/packages/eslint-plugin/tests/rules/no-object-literal-type-assertion.test.ts b/packages/eslint-plugin/tests/rules/no-object-literal-type-assertion.test.ts index e25e96de6940..336df4c29a1b 100644 --- a/packages/eslint-plugin/tests/rules/no-object-literal-type-assertion.test.ts +++ b/packages/eslint-plugin/tests/rules/no-object-literal-type-assertion.test.ts @@ -26,6 +26,8 @@ ruleTester.run('no-object-literal-type-assertion', rule, { // Allow cast to 'unknown' `const foo = {} as unknown;`, `const foo = {};`, + `const foo = {} as const;`, + `const foo = {};`, { code: `print({ bar: 5 } as Foo)`, options: [ From 4b3d82022a45eeb52a94446454e5809fbb5812eb Mon Sep 17 00:00:00 2001 From: Gavin Barron Date: Sun, 7 Apr 2019 01:07:22 -0700 Subject: [PATCH 05/17] feat(eslint-plugin): [member-accessibility] add more options (#322) --- .../rules/explicit-member-accessibility.md | 235 ++++++++++++- .../rules/explicit-member-accessibility.ts | 199 +++++++++-- .../explicit-member-accessibility.test.ts | 322 ++++++++++++++++++ 3 files changed, 719 insertions(+), 37 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/explicit-member-accessibility.md b/packages/eslint-plugin/docs/rules/explicit-member-accessibility.md index ac3e8b1d7fcc..a672b766c6c9 100644 --- a/packages/eslint-plugin/docs/rules/explicit-member-accessibility.md +++ b/packages/eslint-plugin/docs/rules/explicit-member-accessibility.md @@ -10,28 +10,247 @@ be easier to use. This rule aims to make code more readable and explicit about who can use which properties. -The following patterns are considered warnings: +## Options + +```ts +type AccessibilityLevel = + | 'explicit' // require an accessor (including public) + | 'no-public' // don't require public + | 'off'; // don't check + +interface Config { + accessibility?: AccessibilityLevel; + overrides?: { + accessors?: AccessibilityLevel; + constructors?: AccessibilityLevel; + methods?: AccessibilityLevel; + properties?: AccessibilityLevel; + parameterProperties?: AccessibilityLevel; + }; +} +``` + +Default config: + +```JSON +{ "accessibility": "explicit" } +``` + +This rule in it's default state requires no configuration and will enforce that every class member has an accessibility modifier. If you would like to allow for some implicit public members then you have the following options: +A possible configuration could be: + +```ts +{ + accessibility: 'explicit', + overrides { + accessors: 'explicit', + constructors: 'no-public', + methods: 'explicit', + properties: 'off', + parameterProperties: 'explicit' + } +} +``` + +The following patterns are considered incorrect code if no options are provided: + +```ts +class Animal { + constructor(name) { + // No accessibility modifier + this.animalName = name; + } + animalName: string; // No accessibility modifier + get name(): string { + // No accessibility modifier + return this.animalName; + } + set name(value: string) { + // No accessibility modifier + this.animalName = value; + } + walk() { + // method + } +} +``` + +The following patterns are considered correct with the default options `{ accessibility: 'explicit' }`: + +```ts +class Animal { + public constructor(public breed, animalName) { + // Parameter property and constructor + this.animalName = name; + } + private animalName: string; // Property + get name(): string { + // get accessor + return this.animalName; + } + set name(value: string) { + // set accessor + this.animalName = value; + } + public walk() { + // method + } +} +``` + +The following patterns are considered incorrect with the accessibility set to **no-public** `[{ accessibility: 'no-public' }]`: + +```ts +class Animal { + public constructor(public breed, animalName) { + // Parameter property and constructor + this.animalName = name; + } + public animalName: string; // Property + public get name(): string { + // get accessor + return this.animalName; + } + public set name(value: string) { + // set accessor + this.animalName = value; + } + public walk() { + // method + } +} +``` + +The following patterns are considered correct with the accessibility set to **no-public** `[{ accessibility: 'no-public' }]`: + +```ts +class Animal { + constructor(protected breed, animalName) { + // Parameter property and constructor + this.name = name; + } + private animalName: string; // Property + get name(): string { + // get accessor + return this.animalName; + } + private set name(value: string) { + // set accessor + this.animalName = value; + } + protected walk() { + // method + } +} +``` + +### Overrides + +There are three ways in which an override can be used. + +- To disallow the use of public on a given member. +- To enforce explicit member accessibility when the root has allowed implicit public accessibility +- To disable any checks on given member type + +#### Disallow the use of public on a given member + +e.g. `[ { overrides: { constructor: 'no-public' } } ]` + +The following patterns are considered incorrect with the example override + +```ts +class Animal { + public constructor(protected animalName) {} + public get name() { + return this.animalName; + } +} +``` + +The following patterns are considered correct with the example override + +```ts +class Animal { + constructor(protected animalName) {} + public get name() { + return this.animalName; + } +} +``` + +#### Require explicit accessibility for a given member + +e.g. `[ { accessibility: 'no-public', overrides: { properties: 'explicit' } } ]` + +The following patterns are considered incorrect with the example override + +```ts +class Animal { + constructor(protected animalName) {} + get name() { + return this.animalName; + } + protected set name(value: string) { + this.animalName = value; + } + legs: number; + private hasFleas: boolean; +} +``` + +The following patterns are considered correct with the example override + +```ts +class Animal { + constructor(protected animalName) {} + get name() { + return this.animalName; + } + protected set name(value: string) { + this.animalName = value; + } + public legs: number; + private hasFleas: boolean; +} +``` + +#### Disable any checks on given member type + +e.g. `[{ overrides: { accessors : 'off' } } ]` + +As no checks on the overridden member type are performed all permutations of visibility are permitted for that member type + +The follow pattern is considered incorrect for the given configuration ```ts class Animal { - name: string; // No accessibility modifier - getName(): string {} // No accessibility modifier + constructor(protected animalName) {} + public get name() { + return this.animalName; + } + get legs() { + return this.legCount; + } } ``` -The following patterns are not warnings: +The following patterns are considered correct with the example override ```ts class Animal { - private name: string; // explicit accessibility modifier - public getName(): string {} // explicit accessibility modifier + public constructor(protected animalName) {} + public get name() { + return this.animalName; + } + get legs() { + return this.legCount; + } } ``` ## When Not To Use It -If you think defaulting to public is a good default, then you will not need -this rule. +If you think defaulting to public is a good default, then you should consider using the `no-public` setting. If you want to mix implicit and explicit public members then disable this rule. ## Further Reading diff --git a/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts b/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts index 66029faa5018..be79d684a690 100644 --- a/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts +++ b/packages/eslint-plugin/src/rules/explicit-member-accessibility.ts @@ -1,7 +1,29 @@ -import { TSESTree } from '@typescript-eslint/typescript-estree'; +import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/typescript-estree'; import * as util from '../util'; -export default util.createRule({ +type AccessibilityLevel = + | 'explicit' // require an accessor (including public) + | 'no-public' // don't require public + | 'off'; // don't check + +interface Config { + accessibility?: AccessibilityLevel; + overrides?: { + accessors?: AccessibilityLevel; + constructors?: AccessibilityLevel; + methods?: AccessibilityLevel; + properties?: AccessibilityLevel; + parameterProperties?: AccessibilityLevel; + }; +} + +type Options = [Config]; + +type MessageIds = 'unwantedPublicAccessibility' | 'missingAccessibility'; + +const accessibilityLevel = { enum: ['explicit', 'no-public', 'off'] }; + +export default util.createRule({ name: 'explicit-member-accessibility', meta: { type: 'problem', @@ -15,12 +37,60 @@ export default util.createRule({ messages: { missingAccessibility: 'Missing accessibility modifier on {{type}} {{name}}.', + unwantedPublicAccessibility: + 'Public accessibility modifier on {{type}} {{name}}.', }, - schema: [], + schema: [ + { + type: 'object', + properties: { + accessibility: accessibilityLevel, + overrides: { + type: 'object', + properties: { + accessors: accessibilityLevel, + constructors: accessibilityLevel, + methods: accessibilityLevel, + properties: accessibilityLevel, + parameterProperties: accessibilityLevel, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + ], }, - defaultOptions: [], - create(context) { + defaultOptions: [{ accessibility: 'explicit' }], + create(context, [option]) { const sourceCode = context.getSourceCode(); + const baseCheck: AccessibilityLevel = option.accessibility || 'explicit'; + const overrides = option.overrides || {}; + const ctorCheck = overrides.constructors || baseCheck; + const accessorCheck = overrides.accessors || baseCheck; + const methodCheck = overrides.methods || baseCheck; + const propCheck = overrides.properties || baseCheck; + const paramPropCheck = overrides.parameterProperties || baseCheck; + + /** + * Generates the report for rule violations + */ + function reportIssue( + messageId: MessageIds, + nodeType: string, + node: TSESTree.Node, + nodeName: string, + ) { + context.report({ + node: node, + messageId: messageId, + data: { + type: nodeType, + name: nodeName, + }, + }); + } + /** * Checks if a method declaration has an accessibility modifier. * @param methodDefinition The node representing a MethodDefinition. @@ -28,18 +98,49 @@ export default util.createRule({ function checkMethodAccessibilityModifier( methodDefinition: TSESTree.MethodDefinition, ): void { - if ( - !methodDefinition.accessibility && - util.isTypeScriptFile(context.getFilename()) - ) { - context.report({ - node: methodDefinition, - messageId: 'missingAccessibility', - data: { - type: 'method definition', - name: util.getNameFromClassMember(methodDefinition, sourceCode), - }, - }); + let nodeType = 'method definition'; + let check = baseCheck; + switch (methodDefinition.kind) { + case 'method': + check = methodCheck; + break; + case 'constructor': + check = ctorCheck; + break; + case 'get': + case 'set': + check = accessorCheck; + nodeType = `${methodDefinition.kind} property accessor`; + break; + } + if (check === 'off') { + return; + } + + if (util.isTypeScriptFile(context.getFilename())) { + // const methodName = util.getNameFromPropertyName(methodDefinition.key); + const methodName = util.getNameFromClassMember( + methodDefinition, + sourceCode, + ); + if ( + check === 'no-public' && + methodDefinition.accessibility === 'public' + ) { + reportIssue( + 'unwantedPublicAccessibility', + nodeType, + methodDefinition, + methodName, + ); + } else if (check === 'explicit' && !methodDefinition.accessibility) { + reportIssue( + 'missingAccessibility', + nodeType, + methodDefinition, + methodName, + ); + } } } @@ -50,22 +151,62 @@ export default util.createRule({ function checkPropertyAccessibilityModifier( classProperty: TSESTree.ClassProperty, ): void { - if ( - !classProperty.accessibility && - util.isTypeScriptFile(context.getFilename()) - ) { - context.report({ - node: classProperty, - messageId: 'missingAccessibility', - data: { - type: 'class property', - name: util.getNameFromPropertyName(classProperty.key), - }, - }); + const nodeType = 'class property'; + + if (util.isTypeScriptFile(context.getFilename())) { + const propertyName = util.getNameFromPropertyName(classProperty.key); + if ( + propCheck === 'no-public' && + classProperty.accessibility === 'public' + ) { + reportIssue( + 'unwantedPublicAccessibility', + nodeType, + classProperty, + propertyName, + ); + } else if (propCheck === 'explicit' && !classProperty.accessibility) { + reportIssue( + 'missingAccessibility', + nodeType, + classProperty, + propertyName, + ); + } + } + } + + /** + * Checks that the parameter property has the desired accessibility modifiers set. + * @param {TSESTree.TSParameterProperty} node The node representing a Parameter Property + */ + function checkParameterPropertyAccessibilityModifier( + node: TSESTree.TSParameterProperty, + ) { + const nodeType = 'parameter property'; + if (util.isTypeScriptFile(context.getFilename())) { + // HAS to be an identifier or assignment or TSC will throw + if ( + node.parameter.type !== AST_NODE_TYPES.Identifier && + node.parameter.type !== AST_NODE_TYPES.AssignmentPattern + ) { + return; + } + + const nodeName = + node.parameter.type === AST_NODE_TYPES.Identifier + ? node.parameter.name + : // has to be an Identifier or TSC will throw an error + (node.parameter.left as TSESTree.Identifier).name; + + if (paramPropCheck === 'no-public' && node.accessibility === 'public') { + reportIssue('unwantedPublicAccessibility', nodeType, node, nodeName); + } } } return { + TSParameterProperty: checkParameterPropertyAccessibilityModifier, ClassProperty: checkPropertyAccessibilityModifier, MethodDefinition: checkMethodAccessibilityModifier, }; diff --git a/packages/eslint-plugin/tests/rules/explicit-member-accessibility.test.ts b/packages/eslint-plugin/tests/rules/explicit-member-accessibility.test.ts index e630450689ce..51a94a19de20 100644 --- a/packages/eslint-plugin/tests/rules/explicit-member-accessibility.test.ts +++ b/packages/eslint-plugin/tests/rules/explicit-member-accessibility.test.ts @@ -39,6 +39,129 @@ class Test { } `, }, + { + filename: 'test.ts', + code: ` +class Test { + protected name: string + protected foo?: string + public getX () { + return this.x + } +} + `, + options: [{ accessibility: 'explicit' }], + }, + { + filename: 'test.ts', + code: ` +class Test { + protected name: string + protected foo?: string + getX () { + return this.x + } +} + `, + options: [{ accessibility: 'no-public' }], + }, + { + filename: 'test.ts', + code: ` +class Test { + name: string + foo?: string + getX () { + return this.x + } + get fooName(): string { + return this.foo + ' ' + this.name + } +} + `, + options: [{ accessibility: 'no-public' }], + }, + { + filename: 'test.ts', + code: ` +class Test { + private x: number; + constructor (x: number) { + this.x = x; + } + get internalValue() { + return this.x; + } + private set internalValue(value: number) { + this.x = value; + } + public square (): number { + return this.x * this.x; + } +} + `, + options: [{ overrides: { constructors: 'off', accessors: 'off' } }], + }, + { + filename: 'test.ts', + code: ` +class Test { + private x: number; + public constructor (x: number) { + this.x = x; + } + public get internalValue() { + return this.x; + } + public set internalValue(value: number) { + this.x = value; + } + public square (): number { + return this.x * this.x; + } + half (): number { + return this.x / 2; + } +} + `, + options: [{ overrides: { methods: 'off' } }], + }, + { + filename: 'test.ts', + code: ` +class Test { + constructor(private x: number){} +} + `, + options: [{ accessibility: 'no-public' }], + }, + { + filename: 'test.ts', + code: ` +class Test { + constructor(public x: number){} +} + `, + options: [ + { + accessibility: 'no-public', + overrides: { parameterProperties: 'off' }, + }, + ], + }, + { + filename: 'test.js', + code: ` +class Test { + constructor(public x: number){} +} + `, + options: [ + { + accessibility: 'no-public', + }, + ], + }, ], invalid: [ { @@ -116,5 +239,204 @@ class Test { }, ], }, + { + filename: 'test.ts', + code: ` +class Test { + protected name: string + protected foo?: string + public getX () { + return this.x + } +} + `, + options: [{ accessibility: 'no-public' }], + errors: [ + { + messageId: 'unwantedPublicAccessibility', + data: { + type: 'method definition', + name: 'getX', + }, + line: 5, + column: 3, + }, + ], + }, + { + filename: 'test.ts', + code: ` +class Test { + protected name: string + public foo?: string + getX () { + return this.x + } +} + `, + options: [{ accessibility: 'no-public' }], + errors: [ + { + messageId: 'unwantedPublicAccessibility', + data: { + type: 'class property', + name: 'foo', + }, + line: 4, + column: 3, + }, + ], + }, + { + filename: 'test.ts', + code: ` +class Test { + public x: number + public getX () { + return this.x + } +} + `, + errors: [ + { + messageId: 'unwantedPublicAccessibility', + line: 3, + column: 3, + }, + { + messageId: 'unwantedPublicAccessibility', + line: 4, + column: 3, + }, + ], + options: [{ accessibility: 'no-public' }], + }, + { + filename: 'test.ts', + code: ` +class Test { + private x: number; + constructor (x: number) { + this.x = x; + } + get internalValue() { + return this.x; + } + set internalValue(value: number) { + this.x = value; + } +} + `, + errors: [ + { + messageId: 'missingAccessibility', + line: 7, + column: 3, + }, + { + messageId: 'missingAccessibility', + line: 10, + column: 3, + }, + ], + options: [{ overrides: { constructors: 'no-public' } }], + }, + { + filename: 'test.ts', + code: ` +class Test { + private x: number; + constructor (x: number) { + this.x = x; + } + get internalValue() { + return this.x; + } + set internalValue(value: number) { + this.x = value; + } +} + `, + errors: [ + { + messageId: 'missingAccessibility', + line: 4, + column: 3, + }, + { + messageId: 'missingAccessibility', + line: 7, + column: 3, + }, + { + messageId: 'missingAccessibility', + line: 10, + column: 3, + }, + ], + }, + { + filename: 'test.ts', + code: ` +class Test { + constructor(public x: number){} +} + `, + errors: [ + { + messageId: 'unwantedPublicAccessibility', + line: 3, + column: 15, + }, + ], + options: [ + { + accessibility: 'no-public', + }, + ], + }, + { + filename: 'test.ts', + code: ` +class Test { + constructor(public x: number){} + public foo(): string { + return 'foo'; + } +} + `, + errors: [ + { + messageId: 'missingAccessibility', + line: 3, + column: 3, + }, + { + messageId: 'unwantedPublicAccessibility', + line: 3, + column: 15, + }, + ], + options: [ + { + overrides: { parameterProperties: 'no-public' }, + }, + ], + }, + { + filename: 'test.ts', + code: ` +class Test { + constructor(public x: number){} +} + `, + errors: [ + { + messageId: 'missingAccessibility', + line: 3, + column: 3, + }, + ], + }, ], }); From d476f15a9d3e2642419a8facc6231b952de9a934 Mon Sep 17 00:00:00 2001 From: Scott O'Hara Date: Sun, 7 Apr 2019 19:35:37 +1000 Subject: [PATCH 06/17] fix(eslint-plugin): indent: fix false positive on type parameters (#385) --- packages/eslint-plugin/src/rules/indent.ts | 1 + .../eslint-plugin/tests/rules/indent.test.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/eslint-plugin/src/rules/indent.ts b/packages/eslint-plugin/src/rules/indent.ts index a3c6f5971d5e..85187a7046ba 100644 --- a/packages/eslint-plugin/src/rules/indent.ts +++ b/packages/eslint-plugin/src/rules/indent.ts @@ -74,6 +74,7 @@ const KNOWN_NODES = new Set([ AST_NODE_TYPES.TSTypeOperator, AST_NODE_TYPES.TSTypeParameter, AST_NODE_TYPES.TSTypeParameterDeclaration, + AST_NODE_TYPES.TSTypeParameterInstantiation, AST_NODE_TYPES.TSTypeReference, AST_NODE_TYPES.TSUnionType, ]); diff --git a/packages/eslint-plugin/tests/rules/indent.test.ts b/packages/eslint-plugin/tests/rules/indent.test.ts index d845a8711721..7fb18428d9fc 100644 --- a/packages/eslint-plugin/tests/rules/indent.test.ts +++ b/packages/eslint-plugin/tests/rules/indent.test.ts @@ -742,6 +742,30 @@ const foo : Foo = { `, options: [4, { VariableDeclarator: { const: 3 } }], }, + { + code: ` +const name: string = ' Typescript ' + .toUpperCase() + .trim(), + + greeting: string = (" Hello " + name) + .toUpperCase() + .trim(); + `, + options: [2, { VariableDeclarator: { const: 3 } }], + }, + { + code: ` +const div: JQuery = $('
') + .addClass('some-class') + .appendTo($('body')), + + button: JQuery = $('