Skip to content

Commit bc58e79

Browse files
crisbetojelbourn
authored andcommitted
build: expand decorators validation rule to cover class members (#17673)
Expands the lint rule that we use to validate class decorators to also apply to class members. We need this in order to support linting things like `descendants` on `ContentChildren`. These changes switch around the config data structure to make it more flexible, fix some cases where we were using `any` and add some extra properties to the config.
1 parent a5437cd commit bc58e79

File tree

2 files changed

+137
-70
lines changed

2 files changed

+137
-70
lines changed

tools/tslint-rules/validateDecoratorsRule.ts

Lines changed: 118 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@ import * as minimatch from 'minimatch';
1111
* ```
1212
* "validate-decorators": [true, {
1313
* "Component": {
14-
* "encapsulation": "\\.None$",
15-
* "!styles": ".*"
14+
* "argument": 0,
15+
* "properties": {
16+
* "encapsulation": "\\.None$",
17+
* "!styles": ".*"
18+
* }
1619
* },
17-
* "NgModule": "^(?!\\s*$).+"
20+
* "NgModule": {
21+
* "argument": 0,
22+
* "properties": "^(?!\\s*$).+"
23+
* }
1824
* }, "src/material"]
1925
* ```
2026
*/
@@ -24,15 +30,32 @@ export class Rule extends Lint.Rules.AbstractRule {
2430
}
2531
}
2632

33+
/**
34+
* Token used to indicate that all properties of an object
35+
* should be linted against a single pattern.
36+
*/
37+
const ALL_PROPS_TOKEN = '*';
38+
39+
/** Object that can be used to configured the rule. */
40+
interface RuleConfig {
41+
[key: string]: {
42+
argument: number,
43+
required?: boolean,
44+
properties: {[key: string]: string}
45+
};
46+
}
47+
2748
/** Represents a set of required and forbidden decorator properties. */
2849
type DecoratorRuleSet = {
29-
required: {[key: string]: RegExp},
30-
forbidden: {[key: string]: RegExp},
50+
argument: number,
51+
required: boolean,
52+
requiredProps: {[key: string]: RegExp},
53+
forbiddenProps: {[key: string]: RegExp},
3154
};
3255

3356
/** Represents a map between decorator names and rule sets. */
3457
type DecoratorRules = {
35-
[decorator: string]: DecoratorRuleSet | RegExp
58+
[decorator: string]: DecoratorRuleSet
3659
};
3760

3861
class Walker extends Lint.RuleWalker {
@@ -57,8 +80,16 @@ class Walker extends Lint.RuleWalker {
5780
}
5881

5982
visitClassDeclaration(node: ts.ClassDeclaration) {
60-
if (this._enabled && node.decorators) {
61-
node.decorators.forEach(decorator => this._validatedDecorator(decorator.expression));
83+
if (this._enabled) {
84+
if (node.decorators) {
85+
node.decorators.forEach(decorator => this._validateDecorator(decorator));
86+
}
87+
88+
node.members.forEach(member => {
89+
if (member.decorators) {
90+
member.decorators.forEach(decorator => this._validateDecorator(decorator));
91+
}
92+
});
6293
}
6394

6495
super.visitClassDeclaration(node);
@@ -68,63 +99,76 @@ class Walker extends Lint.RuleWalker {
6899
* Validates that a decorator matches all of the defined rules.
69100
* @param decorator Decorator to be checked.
70101
*/
71-
private _validatedDecorator(decorator: any) {
102+
private _validateDecorator(decorator: ts.Decorator) {
103+
const expression = decorator.expression;
104+
105+
if (!expression || !ts.isCallExpression(expression)) {
106+
return;
107+
}
108+
72109
// Get the rules that are relevant for the current decorator.
73-
const rules = this._rules[decorator.expression.getText()];
110+
const rules = this._rules[expression.expression.getText()];
111+
const args = expression.arguments;
74112

75113
// Don't do anything if there are no rules.
76114
if (!rules) {
77115
return;
78116
}
79117

80-
// If the rule is a regex, extract the arguments as a string and run it against them.
81-
if (rules instanceof RegExp) {
82-
const decoratorText: string = decorator.getText();
83-
const openParenthesisIndex = decoratorText.indexOf('(');
84-
const closeParenthesisIndex = decoratorText.lastIndexOf(')');
85-
const argumentsText = openParenthesisIndex > -1 ? decoratorText.substring(
86-
openParenthesisIndex + 1,
87-
closeParenthesisIndex > -1 ? closeParenthesisIndex : decoratorText.length) : '';
88-
89-
if (!rules.test(argumentsText)) {
90-
this.addFailureAtNode(decorator.parent, `Expected decorator arguments to match "${rules}"`);
91-
}
118+
const allPropsRequirement = rules.requiredProps[ALL_PROPS_TOKEN];
92119

120+
// If we have a rule that applies to all properties, we just run it through once and we exit.
121+
if (allPropsRequirement) {
122+
const argumentText = args[rules.argument] ? args[rules.argument].getText() : '';
123+
if (!allPropsRequirement.test(argumentText)) {
124+
this.addFailureAtNode(expression.parent, `Expected decorator argument ${rules.argument} ` +
125+
`to match "${allPropsRequirement}"`);
126+
}
93127
return;
94128
}
95129

96-
const args = decorator.arguments;
130+
if (!args[rules.argument]) {
131+
if (rules.required) {
132+
this.addFailureAtNode(expression.parent,
133+
`Missing required argument at index ${rules.argument}`);
134+
}
135+
return;
136+
}
97137

98-
if (!args || !args.length || !args[0].properties) {
138+
if (!ts.isObjectLiteralExpression(args[rules.argument])) {
99139
return;
100140
}
101141

102142
// Extract the property names and values.
103-
const props = args[0].properties
104-
.filter((node: ts.PropertyAssignment) => node.name && node.initializer)
105-
.map((node: ts.PropertyAssignment) => ({
106-
name: node.name.getText(),
107-
value: node.initializer.getText(),
108-
node
109-
}));
143+
const props: {name: string, value: string, node: ts.PropertyAssignment}[] = [];
144+
145+
(args[rules.argument] as ts.ObjectLiteralExpression).properties.forEach(prop => {
146+
if (ts.isPropertyAssignment(prop) && prop.name && prop.initializer) {
147+
props.push({
148+
name: prop.name.getText(),
149+
value: prop.initializer.getText(),
150+
node: prop
151+
});
152+
}
153+
});
110154

111155
// Find all of the required rule properties that are missing from the decorator.
112-
const missing = Object.keys(rules.required)
113-
.filter(key => !props.find((prop: any) => prop.name === key));
156+
const missing = Object.keys(rules.requiredProps)
157+
.filter(key => !props.find(prop => prop.name === key));
114158

115159
if (missing.length) {
116160
// Exit early if any of the properties are missing.
117-
this.addFailureAtNode(decorator.expression,
161+
this.addFailureAtNode(expression.expression,
118162
'Missing required properties: ' + missing.join(', '));
119163
} else {
120164
// If all the necessary properties are defined, ensure that
121165
// they match the pattern and aren't in the forbidden list.
122166
props
123-
.filter((prop: any) => rules.required[prop.name] || rules.forbidden[prop.name])
124-
.forEach((prop: any) => {
167+
.filter(prop => rules.requiredProps[prop.name] || rules.forbiddenProps[prop.name])
168+
.forEach(prop => {
125169
const {name, value, node} = prop;
126-
const requiredPattern = rules.required[name];
127-
const forbiddenPattern = rules.forbidden[name];
170+
const requiredPattern = rules.requiredProps[name];
171+
const forbiddenPattern = rules.forbiddenProps[name];
128172

129173
if (requiredPattern && !requiredPattern.test(value)) {
130174
this.addFailureAtNode(node, `Invalid value for property. ` +
@@ -143,33 +187,45 @@ class Walker extends Lint.RuleWalker {
143187
* @param config Config object passed in via the tslint.json.
144188
* @returns Sanitized rules.
145189
*/
146-
private _generateRules(config: {[key: string]: string|{[key: string]: string}}): DecoratorRules {
190+
private _generateRules(config: RuleConfig|null): DecoratorRules {
147191
const output: DecoratorRules = {};
148192

149193
if (config) {
150-
Object.keys(config)
151-
.filter(decoratorName => Object.keys(config[decoratorName]).length > 0)
152-
.forEach(decoratorName => {
153-
const decoratorConfig = config[decoratorName];
154-
155-
if (typeof decoratorConfig === 'string') {
156-
output[decoratorName] = new RegExp(decoratorConfig);
157-
} else {
158-
output[decoratorName] = Object.keys(decoratorConfig).reduce((rules, prop) => {
159-
const isForbidden = prop.startsWith('!');
160-
const cleanName = isForbidden ? prop.slice(1) : prop;
161-
const pattern = new RegExp(decoratorConfig[prop]);
162-
163-
if (isForbidden) {
164-
rules.forbidden[cleanName] = pattern;
165-
} else {
166-
rules.required[cleanName] = pattern;
167-
}
168-
169-
return rules;
170-
}, {required: {}, forbidden: {}} as DecoratorRuleSet);
171-
}
172-
});
194+
Object.keys(config).forEach(decoratorName => {
195+
const decoratorConfig = config[decoratorName];
196+
const {argument, properties, required} = decoratorConfig;
197+
198+
// * is a special token which means to run the pattern across the entire object.
199+
const allProperties = properties[ALL_PROPS_TOKEN];
200+
201+
if (allProperties) {
202+
output[decoratorName] = {
203+
argument,
204+
required: !!required,
205+
requiredProps: {[ALL_PROPS_TOKEN]: new RegExp(allProperties)},
206+
forbiddenProps: {}
207+
};
208+
} else {
209+
output[decoratorName] = Object.keys(decoratorConfig.properties).reduce((rules, prop) => {
210+
const isForbidden = prop.startsWith('!');
211+
const cleanName = isForbidden ? prop.slice(1) : prop;
212+
const pattern = new RegExp(properties[prop]);
213+
214+
if (isForbidden) {
215+
rules.forbiddenProps[cleanName] = pattern;
216+
} else {
217+
rules.requiredProps[cleanName] = pattern;
218+
}
219+
220+
return rules;
221+
}, {
222+
argument,
223+
required: !!required,
224+
requiredProps: {} as {[key: string]: RegExp},
225+
forbiddenProps: {} as {[key: string]: RegExp}
226+
});
227+
}
228+
});
173229
}
174230

175231
return output;

tslint.json

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -133,17 +133,28 @@
133133
}],
134134
"validate-decorators": [true, {
135135
"Component": {
136-
"!host": "\\[class\\]",
137-
"!preserveWhitespaces": ".*",
138-
"!styles": ".*",
139-
"!moduleId": ".*",
140-
"changeDetection": "\\.OnPush$",
141-
"encapsulation": "\\.None$"
136+
"argument": 0,
137+
"properties": {
138+
"!host": "\\[class\\]",
139+
"!preserveWhitespaces": ".*",
140+
"!styles": ".*",
141+
"!moduleId": ".*",
142+
"changeDetection": "\\.OnPush$",
143+
"encapsulation": "\\.None$"
144+
}
142145
},
143146
"Directive": {
144-
"!host": "\\[class\\]"
147+
"argument": 0,
148+
"properties": {
149+
"!host": "\\[class\\]"
150+
}
145151
},
146-
"NgModule": "^(?!\\s*$).+"
152+
"NgModule": {
153+
"argument": 0,
154+
"properties": {
155+
"*": "^(?!\\s*$).+"
156+
}
157+
}
147158
}, "src/!(a11y-demo|e2e-app|components-examples|universal-app|dev-app)/**/!(*.spec).ts"],
148159
"require-license-banner": [
149160
true,

0 commit comments

Comments
 (0)