1 | import * as ts from 'typescript';
|
2 | import * as Lint from 'tslint';
|
3 |
|
4 | import { ErrorTolerantWalker } from './utils/ErrorTolerantWalker';
|
5 | import { ExtendedMetadata } from './utils/ExtendedMetadata';
|
6 | import { AstUtils } from './utils/AstUtils';
|
7 |
|
8 |
|
9 |
|
10 |
|
11 | export class Rule extends Lint.Rules.AbstractRule {
|
12 | public static metadata: ExtendedMetadata = {
|
13 | ruleName: 'no-feature-envy',
|
14 | type: 'maintainability',
|
15 | description: 'A method accesses the data of another object more than its own data.',
|
16 | options: null,
|
17 | optionsDescription: '',
|
18 | optionExamples: [],
|
19 | recommendation: '[true, 1, ["_"]],',
|
20 | typescriptOnly: false,
|
21 | issueClass: 'Non-SDL',
|
22 | issueType: 'Warning',
|
23 | severity: 'Moderate',
|
24 | level: 'Opportunity for Excellence',
|
25 | group: 'Clarity',
|
26 | commonWeaknessEnumeration: '',
|
27 | };
|
28 |
|
29 | public static FAILURE_STRING(feature: MethodFeature): string {
|
30 | const { methodName, className, otherClassName } = feature;
|
31 | const failureMessage = `Method "${methodName}" uses "${otherClassName}" more than its own class "${className}".`;
|
32 | const recommendation = `Extract or Move Method from "${methodName}" into "${otherClassName}".`;
|
33 | return `${failureMessage} ${recommendation}`;
|
34 | }
|
35 |
|
36 | public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
|
37 | return this.applyWithWalker(new NoFeatureEnvyRuleWalker(sourceFile, this.getOptions()));
|
38 | }
|
39 | }
|
40 |
|
41 | class NoFeatureEnvyRuleWalker extends ErrorTolerantWalker {
|
42 | private threshold: number = 0;
|
43 | private exclude: string[] = [];
|
44 |
|
45 | constructor(sourceFile: ts.SourceFile, options: Lint.IOptions) {
|
46 | super(sourceFile, options);
|
47 | this.parseOptions();
|
48 | }
|
49 |
|
50 | protected visitClassDeclaration(node: ts.ClassDeclaration): void {
|
51 | this.checkAndReport(node);
|
52 | super.visitClassDeclaration(node);
|
53 | }
|
54 |
|
55 | private checkAndReport(node: ts.ClassDeclaration): void {
|
56 | this.getFeatureMethodsForClass(node).forEach(feature => {
|
57 | const failureMessage = Rule.FAILURE_STRING(feature);
|
58 | this.addFailureAtNode(feature.methodNode, failureMessage);
|
59 | });
|
60 | }
|
61 |
|
62 | private getFeatureMethodsForClass(classNode: ts.ClassDeclaration): MethodFeature[] {
|
63 | const methods = this.methodsForClass(classNode);
|
64 | return <any[]>methods
|
65 | .map(method => {
|
66 | const walker = new ClassMethodWalker(classNode, method);
|
67 | return walker.features();
|
68 | })
|
69 | .map(features => this.getTopFeature(features))
|
70 | .filter(feature => feature !== undefined);
|
71 | }
|
72 |
|
73 | private getTopFeature(features: MethodFeature[]): MethodFeature | void {
|
74 | const filteredFeatures = this.filterFeatures(features);
|
75 | return filteredFeatures.reduce((best, current) => {
|
76 | if (!best) {
|
77 | return current;
|
78 | }
|
79 | if (current.featureEnvy() > best.featureEnvy()) {
|
80 | return current;
|
81 | }
|
82 | return best;
|
83 | }, undefined);
|
84 | }
|
85 |
|
86 | private filterFeatures(features: MethodFeature[]): MethodFeature[] {
|
87 | return features.filter(feature => {
|
88 | const isExcluded = this.exclude.indexOf(feature.otherClassName) !== -1;
|
89 | if (isExcluded) {
|
90 | return false;
|
91 | }
|
92 | return feature.featureEnvy() > this.threshold;
|
93 | });
|
94 | }
|
95 |
|
96 | protected methodsForClass(classNode: ts.ClassDeclaration): ts.MethodDeclaration[] {
|
97 | return <ts.MethodDeclaration[]>classNode.members.filter(
|
98 | (classElement: ts.ClassElement): boolean => {
|
99 | switch (classElement.kind) {
|
100 | case ts.SyntaxKind.MethodDeclaration:
|
101 | case ts.SyntaxKind.GetAccessor:
|
102 | case ts.SyntaxKind.SetAccessor:
|
103 | return !AstUtils.isStatic(classElement);
|
104 | default:
|
105 | return false;
|
106 | }
|
107 | }
|
108 | );
|
109 | }
|
110 |
|
111 | private parseOptions(): void {
|
112 | this.getOptions().forEach((opt: any) => {
|
113 | if (typeof opt === 'boolean') {
|
114 | return;
|
115 | }
|
116 | if (typeof opt === 'number') {
|
117 | this.threshold = opt;
|
118 | return;
|
119 | }
|
120 | if (Array.isArray(opt)) {
|
121 | this.exclude = opt;
|
122 | return;
|
123 | }
|
124 | });
|
125 | }
|
126 | }
|
127 |
|
128 | class ClassMethodWalker extends Lint.SyntaxWalker {
|
129 | private featureEnvyMap: EnvyMap = {};
|
130 |
|
131 | constructor(private classNode: ts.ClassDeclaration, private methodNode: ts.MethodDeclaration) {
|
132 | super();
|
133 | this.walk(this.methodNode);
|
134 | }
|
135 |
|
136 | public features(): MethodFeature[] {
|
137 | const thisClassAccesses = this.getCountForClass('this');
|
138 | return this.classesUsed.map(className => {
|
139 | const otherClassAccesses = this.getCountForClass(className);
|
140 | return new MethodFeature({
|
141 | classNode: this.classNode,
|
142 | methodNode: this.methodNode,
|
143 | otherClassName: className,
|
144 | thisClassAccesses,
|
145 | otherClassAccesses,
|
146 | });
|
147 | });
|
148 | }
|
149 |
|
150 | private getCountForClass(className: string): number {
|
151 | return this.featureEnvyMap[className] || 0;
|
152 | }
|
153 |
|
154 | private get classesUsed(): string[] {
|
155 | return Object.keys(this.featureEnvyMap).filter(className => className !== 'this');
|
156 | }
|
157 |
|
158 | protected visitPropertyAccessExpression(node: ts.PropertyAccessExpression) {
|
159 | if (this.isTopPropertyAccess(node)) {
|
160 | const className = this.classNameForPropertyAccess(node);
|
161 | this.incrementCountForClass(className);
|
162 | }
|
163 | super.visitPropertyAccessExpression(node);
|
164 | }
|
165 |
|
166 | private incrementCountForClass(className: string): void {
|
167 | if (this.featureEnvyMap[className] !== undefined) {
|
168 | this.featureEnvyMap[className] += 1;
|
169 | } else {
|
170 | this.featureEnvyMap[className] = 1;
|
171 | }
|
172 | }
|
173 |
|
174 | private isTopPropertyAccess(node: ts.PropertyAccessExpression): boolean {
|
175 | switch (node.expression.kind) {
|
176 | case ts.SyntaxKind.Identifier:
|
177 | case ts.SyntaxKind.ThisKeyword:
|
178 | case ts.SyntaxKind.SuperKeyword:
|
179 | return true;
|
180 | }
|
181 | return false;
|
182 | }
|
183 |
|
184 | private classNameForPropertyAccess(node: ts.PropertyAccessExpression): string {
|
185 | const { expression } = node;
|
186 | if (ts.isThisTypeNode(node)) {
|
187 | return 'this';
|
188 | }
|
189 | if (expression.kind === ts.SyntaxKind.SuperKeyword) {
|
190 | return 'this';
|
191 | }
|
192 | if (this.classNode.name.getText() === expression.getText()) {
|
193 | return 'this';
|
194 | }
|
195 | return expression.getText();
|
196 | }
|
197 | }
|
198 |
|
199 | export class MethodFeature {
|
200 | constructor(
|
201 | private data: {
|
202 | classNode: ts.ClassDeclaration;
|
203 | methodNode: ts.MethodDeclaration;
|
204 | otherClassName: string;
|
205 | thisClassAccesses: number;
|
206 | otherClassAccesses: number;
|
207 | }
|
208 | ) {}
|
209 |
|
210 | public get className(): string {
|
211 | return this.classNode.name.text;
|
212 | }
|
213 | public get classNode(): ts.ClassDeclaration {
|
214 | return this.data.classNode;
|
215 | }
|
216 |
|
217 | public get methodName(): string {
|
218 | return this.methodNode.name.getText();
|
219 | }
|
220 | public get methodNode(): ts.MethodDeclaration {
|
221 | return this.data.methodNode;
|
222 | }
|
223 |
|
224 | public featureEnvy(): number {
|
225 | const { thisClassAccesses, otherClassAccesses } = this.data;
|
226 | return otherClassAccesses - thisClassAccesses;
|
227 | }
|
228 |
|
229 | public get otherClassName(): string {
|
230 | return this.data.otherClassName;
|
231 | }
|
232 | }
|
233 |
|
234 | interface EnvyMap {
|
235 | [className: string]: number;
|
236 | }
|