From ae3f07b61eedebe07e2816a29b32ef733febd038 Mon Sep 17 00:00:00 2001 From: Guillaume Bilodeau Date: Mon, 18 Dec 2017 09:40:17 -0500 Subject: [PATCH] No output named after standard event (#474) * feat(no-output-named-after-standard-event-rule): Initial version * feat(no-output-named-after-standard-event-rule): Add rationale * feat(no-output-named-after-standard-event-rule): Use hash map instead of array for constant time check --- package-lock.json | 55 +++-- src/index.ts | 1 + src/noOutputNamedAfterStandardEventRule.ts | 220 ++++++++++++++++++ ...oOutputNamedAfterStandardEventRule.spec.ts | 59 +++++ 4 files changed, 321 insertions(+), 14 deletions(-) create mode 100644 src/noOutputNamedAfterStandardEventRule.ts create mode 100644 test/noOutputNamedAfterStandardEventRule.spec.ts diff --git a/package-lock.json b/package-lock.json index eb11f59c5..29f933c62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,49 @@ { "name": "codelyzer", - "version": "3.2.0", + "version": "4.0.2", "lockfileVersion": 1, "requires": true, "dependencies": { + "@angular/common": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-5.1.1.tgz", + "integrity": "sha512-SFRzdDthoiKaMLuV+TAwjKXFWwTRFGuidlWC3BhUf8/HzNSePAdvfdQcqbEaE5buMn403OV105S9Tyx5tILQeA==", + "dev": true, + "requires": { + "tslib": "1.7.1" + } + }, "@angular/compiler": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-4.4.4.tgz", - "integrity": "sha1-Mm6wAp2aNUGqyhJN75rcUcNvK0E=", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-5.1.1.tgz", + "integrity": "sha512-k4J2kRiBjtjkDcDut2JVUpqQGLJWd8j3Don+swzZHuEklbLmsVRGM6u/fmH0K9TMwKHtC5Ycap8kj4bWXUYfwg==", "dev": true, "requires": { "tslib": "1.7.1" } }, "@angular/core": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-4.4.4.tgz", - "integrity": "sha1-vTfs9UFY+XSJmWyThr0iL4CjL1w=", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-5.1.1.tgz", + "integrity": "sha512-8HJ0lNM5Z+pf+JfOl5mAWgNfrdtnMhVcEGCEniJAQweKOfYCziuyB0ALkX/Q6jGmd2IshR36SarwCYEc5ttt/w==", + "dev": true, + "requires": { + "tslib": "1.7.1" + } + }, + "@angular/platform-browser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-5.1.1.tgz", + "integrity": "sha512-QpkNXoO2pqURQJxXPhZo6RFeirKbr56O0SwoMpYfXGGN1qEIicoWZHobCUTp7/jvjx5Xjc7886Fvu/qJrE7wVA==", + "dev": true, + "requires": { + "tslib": "1.7.1" + } + }, + "@angular/platform-browser-dynamic": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-5.1.1.tgz", + "integrity": "sha512-xnin1eK5nF7EO4tYZvRlhT28DyhL3p4NKWsZQwfqyBwSF0T2mJ1vjhjCZVT0MmaOyt5D+0eUkHIhBDqeZyBMMQ==", "dev": true, "requires": { "tslib": "1.7.1" @@ -1614,12 +1641,12 @@ } }, "rxjs": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-5.4.1.tgz", - "integrity": "sha1-ti91fyeURdJloYpY+wpw3JDpFiY=", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-5.5.5.tgz", + "integrity": "sha512-D/MfQnPMBk8P8gfwGxvCkuaWBcG58W7dUMT//URPoYzIbDEKT0GezdirkK5whMgKFBATfCoTpxO8bJQGJ04W5A==", "dev": true, "requires": { - "symbol-observable": "1.0.4" + "symbol-observable": "1.0.1" } }, "safe-buffer": { @@ -1828,9 +1855,9 @@ } }, "symbol-observable": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.4.tgz", - "integrity": "sha1-Kb9hXUqnEhvdiYsi1LP5vE4qoD0=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.1.tgz", + "integrity": "sha1-g0D8RwLDEi310iKI+IKD9RPT/dQ=", "dev": true }, "tar": { diff --git a/src/index.ts b/src/index.ts index 8d9babf3d..aa8ebd2e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export { Rule as ImportDestructuringSpacingRule } from './importDestructuringSpa export { Rule as NoAttributeParameterDecoratorRule } from './noAttributeParameterDecoratorRule'; export { Rule as NoForwardRefRule } from './noForwardRefRule'; export { Rule as NoInputRenameRule } from './noInputRenameRule'; +export { Rule as NoOutputNamedAfterStandardEventRule } from './noOutputNamedAfterStandardEventRule'; export { Rule as NoOutputOnPrefixRule } from './noOutputOnPrefixRule'; export { Rule as NoOutputRenameRule } from './noOutputRenameRule'; export { Rule as NoUnusedCssRule } from './noUnusedCssRule'; diff --git a/src/noOutputNamedAfterStandardEventRule.ts b/src/noOutputNamedAfterStandardEventRule.ts new file mode 100644 index 000000000..b402259ba --- /dev/null +++ b/src/noOutputNamedAfterStandardEventRule.ts @@ -0,0 +1,220 @@ +import * as Lint from 'tslint'; +import * as ts from 'typescript'; +import { sprintf } from 'sprintf-js'; +import { NgWalker } from './angular/ngWalker'; + +export class Rule extends Lint.Rules.AbstractRule { + public static metadata: Lint.IRuleMetadata = { + ruleName: 'no-output-named-after-standard-event', + type: 'maintainability', + description: `Disallows naming directive outputs after a standard DOM event.`, + rationale: `Listeners subscribed to an output with such a name will also be invoked when the native event is raised.`, + options: null, + optionsDescription: `Not configurable.`, + typescriptOnly: true, + }; + + static FAILURE_STRING: string = 'In the class "%s", the output ' + + 'property "%s" should not be named after a standard event.'; + + public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { + return this.applyWithWalker( + new OutputMetadataWalker(sourceFile, + this.getOptions())); + } +} + + +export class OutputMetadataWalker extends NgWalker { + // source: https://developer.mozilla.org/en-US/docs/Web/Events + private readonly standardEventNames = new Map([ + ['abort', true], + ['afterprint', true], + ['animationend', true], + ['animationiteration', true], + ['animationstart', true], + ['appinstalled', true], + ['audioprocess', true], + ['audioend', true], + ['audiostart', true], + ['beforeprint', true], + ['beforeunload', true], + ['beginEvent', true], + ['blocked', true], + ['blur', true], + ['boundary', true], + ['cached', true], + ['canplay', true], + ['canplaythrough', true], + ['change', true], + ['chargingchange', true], + ['chargingtimechange', true], + ['checking', true], + ['click', true], + ['close', true], + ['complete', true], + ['compositionend', true], + ['compositionstart', true], + ['compositionupdate', true], + ['contextmenu', true], + ['copy', true], + ['cut', true], + ['dblclick', true], + ['devicechange', true], + ['devicelight', true], + ['devicemotion', true], + ['deviceorientation', true], + ['deviceproximity', true], + ['dischargingtimechange', true], + ['DOMAttributeNameChanged', true], + ['DOMAttrModified', true], + ['DOMCharacterDataModified', true], + ['DOMContentLoaded', true], + ['DOMElementNameChanged', true], + ['focus', true], + ['focusin', true], + ['focusout', true], + ['DOMNodeInserted', true], + ['DOMNodeInsertedIntoDocument', true], + ['DOMNodeRemoved', true], + ['DOMNodeRemovedFromDocument', true], + ['DOMSubtreeModified', true], + ['downloading', true], + ['drag', true], + ['dragend', true], + ['dragenter', true], + ['dragleave', true], + ['dragover', true], + ['dragstart', true], + ['drop', true], + ['durationchange', true], + ['emptied', true], + ['end', true], + ['ended', true], + ['endEvent', true], + ['error', true], + ['fullscreenchange', true], + ['fullscreenerror', true], + ['gamepadconnected', true], + ['gamepaddisconnected', true], + ['gotpointercapture', true], + ['hashchange', true], + ['lostpointercapture', true], + ['input', true], + ['invalid', true], + ['keydown', true], + ['keypress', true], + ['keyup', true], + ['languagechange', true], + ['levelchange', true], + ['load', true], + ['loadeddata', true], + ['loadedmetadata', true], + ['loadend', true], + ['loadstart', true], + ['mark', true], + ['message', true], + ['messageerror', true], + ['mousedown', true], + ['mouseenter', true], + ['mouseleave', true], + ['mousemove', true], + ['mouseout', true], + ['mouseover', true], + ['mouseup', true], + ['nomatch', true], + ['notificationclick', true], + ['noupdate', true], + ['obsolete', true], + ['offline', true], + ['online', true], + ['open', true], + ['orientationchange', true], + ['pagehide', true], + ['pageshow', true], + ['paste', true], + ['pause', true], + ['pointercancel', true], + ['pointerdown', true], + ['pointerenter', true], + ['pointerleave', true], + ['pointerlockchange', true], + ['pointerlockerror', true], + ['pointermove', true], + ['pointerout', true], + ['pointerover', true], + ['pointerup', true], + ['play', true], + ['playing', true], + ['popstate', true], + ['progress', true], + ['push', true], + ['pushsubscriptionchange', true], + ['ratechange', true], + ['readystatechange', true], + ['repeatEvent', true], + ['reset', true], + ['resize', true], + ['resourcetimingbufferfull', true], + ['result', true], + ['resume', true], + ['scroll', true], + ['seeked', true], + ['seeking', true], + ['select', true], + ['selectstart', true], + ['selectionchange', true], + ['show', true], + ['soundend', true], + ['soundstart', true], + ['speechend', true], + ['speechstart', true], + ['stalled', true], + ['start', true], + ['storage', true], + ['submit', true], + ['success', true], + ['suspend', true], + ['SVGAbort', true], + ['SVGError', true], + ['SVGLoad', true], + ['SVGResize', true], + ['SVGScroll', true], + ['SVGUnload', true], + ['SVGZoom', true], + ['timeout', true], + ['timeupdate', true], + ['touchcancel', true], + ['touchend', true], + ['touchmove', true], + ['touchstart', true], + ['transitionend', true], + ['unload', true], + ['updateready', true], + ['upgradeneeded', true], + ['userproximity', true], + ['voiceschanged', true], + ['versionchange', true], + ['visibilitychange', true], + ['volumechange', true], + ['waiting', true], + ['wheel', true] + ]); + + visitNgOutput(property: ts.PropertyDeclaration, output: ts.Decorator, args: string[]) { + let className = (property).parent.name.text; + let memberName = (property.name).text; + + if (memberName && this.standardEventNames.get(memberName)) { + const failureConfig: string[] = [Rule.FAILURE_STRING, className, memberName]; + const errorMessage = sprintf.apply(null, failureConfig); + this.addFailure( + this.createFailure( + property.getStart(), + property.getWidth(), + errorMessage + ) + ); + } + } +} diff --git a/test/noOutputNamedAfterStandardEventRule.spec.ts b/test/noOutputNamedAfterStandardEventRule.spec.ts new file mode 100644 index 000000000..957d9dfd3 --- /dev/null +++ b/test/noOutputNamedAfterStandardEventRule.spec.ts @@ -0,0 +1,59 @@ +import { assertSuccess, assertAnnotated } from './testHelper'; + +describe('no-output-named-after-standard-event', () => { + describe('invalid directive output property', () => { + it('should fail, when component output property is named with change prefix', () => { + const source = ` + @Component() + class ButtonComponent { + @Output() change = new EventEmitter(); + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + } + `; + + assertAnnotated({ + ruleName: 'no-output-named-after-standard-event', + message: 'In the class "ButtonComponent", the output property "change" should not be named after a standard event.', + source + }); + }); + + it('should fail, when directive output property is named with change prefix', () => { + const source = ` + @Directive() + class ButtonDirective { + @Output() change = new EventEmitter(); + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + } + `; + + assertAnnotated({ + ruleName: 'no-output-named-after-standard-event', + message: 'In the class "ButtonDirective", the output property "change" should not be named after a standard event.', + source + }); + }); + }); + + describe('valid directive output property', () => { + it('should succeed, when a component output property is properly named', () => { + const source = ` + @Component() + class ButtonComponent { + @Output() buttonChange = new EventEmitter(); + } + `; + assertSuccess('no-output-named-after-standard-event', source); + }); + + it('should succeed, when a directive output property is properly named', () => { + const source = ` + @Directive() + class ButtonDirective { + @Output() buttonChange = new EventEmitter(); + } + `; + assertSuccess('no-output-named-after-standard-event', source); + }); + }); +});