1 | import * as ts from 'typescript';
|
2 | import * as Lint from 'tslint';
|
3 |
|
4 | import { ErrorTolerantWalker } from './utils/ErrorTolerantWalker';
|
5 | import { AstUtils } from './utils/AstUtils';
|
6 | import { Utils } from './utils/Utils';
|
7 | import { ExtendedMetadata } from './utils/ExtendedMetadata';
|
8 |
|
9 | const FAILURE_STRING: string = 'The cohesion of this class is too low. Consider splitting this class into multiple cohesive classes: ';
|
10 |
|
11 |
|
12 |
|
13 |
|
14 | export class Rule extends Lint.Rules.AbstractRule {
|
15 | public static metadata: ExtendedMetadata = {
|
16 | ruleName: 'min-class-cohesion',
|
17 | type: 'maintainability',
|
18 | description: 'High cohesion means the methods and variables of the class are co-dependent and hang together as a logical whole.',
|
19 | options: null,
|
20 | optionsDescription: '',
|
21 | typescriptOnly: true,
|
22 | issueClass: 'Non-SDL',
|
23 | issueType: 'Warning',
|
24 | severity: 'Important',
|
25 | level: 'Opportunity for Excellence',
|
26 | group: 'Correctness',
|
27 | recommendation: '[true, 0.5],',
|
28 | commonWeaknessEnumeration: '',
|
29 | };
|
30 |
|
31 | public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
|
32 | return this.applyWithWalker(new MinClassCohesionRuleWalker(sourceFile, this.getOptions()));
|
33 | }
|
34 | }
|
35 |
|
36 | class MinClassCohesionRuleWalker extends ErrorTolerantWalker {
|
37 | private minClassCohesion: number = 0.5;
|
38 |
|
39 | constructor(sourceFile: ts.SourceFile, options: Lint.IOptions) {
|
40 | super(sourceFile, options);
|
41 | this.parseOptions();
|
42 | }
|
43 |
|
44 | protected visitClassDeclaration(node: ts.ClassDeclaration): void {
|
45 | if (!this.isClassCohesive(node)) {
|
46 | const className: string = node.name == null ? '<unknown>' : node.name.text;
|
47 | this.addFailureAt(node.getStart(), node.getWidth(), FAILURE_STRING + className);
|
48 | }
|
49 | super.visitClassDeclaration(node);
|
50 | }
|
51 |
|
52 | private isClassCohesive(node: ts.ClassDeclaration): boolean {
|
53 | const classNode = new ClassDeclarationHelper(node);
|
54 | if (classNode.extendsSomething) {
|
55 | return true;
|
56 | }
|
57 | const { cohesionScore } = classNode;
|
58 |
|
59 | return cohesionScore >= this.minClassCohesion;
|
60 | }
|
61 |
|
62 | private parseOptions(): void {
|
63 | this.getOptions().forEach((opt: any) => {
|
64 | if (typeof opt === 'boolean') {
|
65 | return;
|
66 | }
|
67 | if (typeof opt === 'number') {
|
68 | this.minClassCohesion = opt;
|
69 | return;
|
70 | }
|
71 | throw new Error(`Rule min-class-cohesion only supports option of type number, not ${typeof opt}.`);
|
72 | });
|
73 | }
|
74 | }
|
75 |
|
76 | class ClassDeclarationHelper {
|
77 | constructor(private node: ts.ClassDeclaration) {}
|
78 |
|
79 | public get cohesionScore(): number {
|
80 | const { fieldNames, methods } = this;
|
81 |
|
82 |
|
83 |
|
84 |
|
85 | if (methods.length === 0) {
|
86 | return 1.0;
|
87 | }
|
88 | const numFields = fieldNames.length;
|
89 | if (numFields === 0) {
|
90 | return 0.0;
|
91 | }
|
92 | const methodScores = methods.map(method => {
|
93 | const used = this.numberOfFieldsUsedByMethod(fieldNames, method);
|
94 | return used / numFields;
|
95 | });
|
96 | const sumScores = methodScores.reduce((total, current) => total + current, 0);
|
97 | return sumScores / methods.length;
|
98 |
|
99 | }
|
100 |
|
101 | private get fieldNames(): string[] {
|
102 | const parameterNames: string[] = this.constructorParameterNames;
|
103 | const instanceFields: string[] = this.instanceFieldNames;
|
104 | return [...parameterNames, ...instanceFields];
|
105 | }
|
106 |
|
107 | private get methods(): ts.MethodDeclaration[] {
|
108 | return <ts.MethodDeclaration[]>this.node.members.filter(
|
109 | (classElement: ts.ClassElement): boolean => {
|
110 | switch (classElement.kind) {
|
111 | case ts.SyntaxKind.MethodDeclaration:
|
112 | case ts.SyntaxKind.GetAccessor:
|
113 | case ts.SyntaxKind.SetAccessor:
|
114 | return !AstUtils.isStatic(classElement);
|
115 | default:
|
116 | return false;
|
117 | }
|
118 | }
|
119 | );
|
120 | }
|
121 |
|
122 | private numberOfFieldsUsedByMethod(fieldNames: string[], method: ts.MethodDeclaration): number {
|
123 | const fields = ClassDeclarationHelper.fieldsUsedByMethod(method);
|
124 | return fieldNames.reduce((count, fieldName) => {
|
125 | if (fields[fieldName]) {
|
126 | return count + 1;
|
127 | }
|
128 | return count;
|
129 | }, 0);
|
130 | }
|
131 |
|
132 | private get constructorParameterNames(): string[] {
|
133 | return this.constructorParameters.map(param => param.name.getText());
|
134 | }
|
135 |
|
136 | private get constructorParameters(): ts.ParameterDeclaration[] {
|
137 | const ctor: ts.ConstructorDeclaration = this.constructorDeclaration;
|
138 | if (ctor) {
|
139 | return ctor.parameters.filter(
|
140 | (param: ts.ParameterDeclaration): boolean => {
|
141 | return (
|
142 | AstUtils.hasModifier(param.modifiers, ts.SyntaxKind.PublicKeyword) ||
|
143 | AstUtils.hasModifier(param.modifiers, ts.SyntaxKind.PrivateKeyword) ||
|
144 | AstUtils.hasModifier(param.modifiers, ts.SyntaxKind.ProtectedKeyword) ||
|
145 | AstUtils.hasModifier(param.modifiers, ts.SyntaxKind.ReadonlyKeyword)
|
146 | );
|
147 | }
|
148 | );
|
149 | }
|
150 | return [];
|
151 | }
|
152 |
|
153 | private get constructorDeclaration(): ts.ConstructorDeclaration | undefined {
|
154 | return <ts.ConstructorDeclaration>(
|
155 | this.node.members.find((element: ts.ClassElement): boolean => element.kind === ts.SyntaxKind.Constructor)
|
156 | );
|
157 | }
|
158 |
|
159 | private get instanceFieldNames(): string[] {
|
160 | return this.instanceFields.map(param => param.name.getText());
|
161 | }
|
162 |
|
163 | private get instanceFields(): ts.PropertyDeclaration[] {
|
164 | return <ts.PropertyDeclaration[]>(
|
165 | this.node.members.filter((classElement: ts.ClassElement): boolean => classElement.kind === ts.SyntaxKind.PropertyDeclaration)
|
166 | );
|
167 | }
|
168 |
|
169 | private static fieldsUsedByMethod(method: ts.MethodDeclaration): FieldsUsageMap {
|
170 | const walker = new ClassMethodWalker();
|
171 | walker.walk(method);
|
172 | return walker.fieldsUsed;
|
173 | }
|
174 |
|
175 | public get extendsSomething(): boolean {
|
176 | return Utils.exists(
|
177 | this.node.heritageClauses,
|
178 | (clause: ts.HeritageClause): boolean => {
|
179 | return clause.token === ts.SyntaxKind.ExtendsKeyword;
|
180 | }
|
181 | );
|
182 | }
|
183 |
|
184 | public get name() {
|
185 | return this.node.name == null ? '<unknown>' : this.node.name.text;
|
186 | }
|
187 | }
|
188 |
|
189 | class ClassMethodWalker extends Lint.SyntaxWalker {
|
190 | public fieldsUsed: FieldsUsageMap = {};
|
191 |
|
192 | protected visitPropertyAccessExpression(node: ts.PropertyAccessExpression): void {
|
193 | const isOnThis = node.expression.kind === ts.SyntaxKind.ThisKeyword;
|
194 | if (isOnThis) {
|
195 | const field = node.name.text;
|
196 |
|
197 | this.fieldsUsed[field] = true;
|
198 | }
|
199 | super.visitPropertyAccessExpression(node);
|
200 | }
|
201 | }
|
202 |
|
203 | interface FieldsUsageMap {
|
204 | [field: string]: boolean;
|
205 | }
|