@ethlete/eslint-plugin 1.0.0-next.24 → 1.0.0-next.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/migrations.json +12 -0
- package/package.json +13 -2
- package/src/configs/recommended.js +37 -27
- package/src/generators/generators.json +10 -0
- package/src/generators/restore-module-augmentation-interfaces/migration.js +50 -0
- package/src/generators/restore-module-augmentation-interfaces/restore-module-augmentation-interfaces.js +103 -0
- package/src/generators/restore-module-augmentation-interfaces/schema.json +14 -0
- package/src/index.js +8 -2
- package/src/rules/angular-decorator-property-order.js +24 -7
- package/src/rules/class-constant-property.js +1 -1
- package/src/rules/class-member-order.js +60 -8
- package/src/rules/consistent-type-definitions.js +60 -0
- package/src/rules/enforce-routing-view-naming.js +34 -23
- package/src/rules/guard-return-newline.js +4 -7
- package/src/rules/internals/angular-member-visibility.js +24 -2
- package/src/rules/internals/angular-metadata-fix.js +231 -1
- package/src/rules/internals/angular-version.js +50 -0
- package/src/rules/internals/implemented-contract-members.js +26 -11
- package/src/rules/no-angular-router-api.js +86 -33
- package/src/rules/no-angular-seo-services.js +1 -0
- package/src/rules/no-async-await.js +5 -1
- package/src/rules/no-cdk-import.js +32 -7
- package/src/rules/no-csp-unsafe.js +362 -0
- package/src/rules/no-direct-dom-manipulation.js +50 -28
- package/src/rules/no-document-cookie.js +51 -8
- package/src/rules/no-dom-query.js +5 -2
- package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
- package/src/rules/no-empty-newlines-between-imports.js +4 -1
- package/src/rules/no-impure-top-level-provider.js +42 -7
- package/src/rules/no-leading-underscore-class-member.js +27 -2
- package/src/rules/no-legacy-angular-decorators.js +11 -6
- package/src/rules/no-legacy-prepare-without-injector.js +57 -12
- package/src/rules/no-legacy-query-import.js +61 -16
- package/src/rules/no-member-alias.js +0 -5
- package/src/rules/no-native-observers.js +89 -24
- package/src/rules/no-pipe-logic.js +1 -2
- package/src/rules/no-readonly-signal.js +1 -5
- package/src/rules/no-redundant-internal.js +5 -2
- package/src/rules/no-redundant-on-push-change-detection.js +3 -48
- package/src/rules/no-rxjs-in-effect.js +7 -5
- package/src/rules/no-screaming-case-local.js +3 -5
- package/src/rules/no-standalone-flag.js +1 -0
- package/src/rules/no-subscribe-with-body.js +30 -1
- package/src/rules/no-template-literal-before-inline-template.js +6 -0
- package/src/rules/no-trivial-return-type.js +18 -1
- package/src/rules/no-unused-class-member.js +1 -1
- package/src/rules/no-window-location.js +60 -22
- package/src/rules/prefer-clone-equal.js +61 -8
- package/src/rules/prefer-concise-angular-host-directives.js +39 -11
- package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
- package/src/rules/prefer-element-dimensions.js +264 -11
- package/src/rules/prefer-linked-signal.js +37 -39
- package/src/rules/prefer-match-media.js +17 -3
- package/src/rules/prefer-rxjs-timer.js +26 -4
- package/src/rules/prefer-scroll-state.js +0 -4
- package/src/rules/prefer-static-boolean-properties.js +1 -0
- package/src/rules/prefer-viewport-size.js +153 -16
- package/src/rules/require-dollar-suffix.js +11 -3
- package/src/rules/require-form-submit.js +4 -1
- package/src/rules/require-on-push-change-detection.js +7 -259
- package/src/rules/require-view-encapsulation-none.js +6 -214
- package/src/rules/template-member-accessibility.js +12 -4
|
@@ -47,7 +47,6 @@ const ROUTER_STATE_PROPS = new Map([
|
|
|
47
47
|
['url', "injectUrl() from '@ethlete/core'"],
|
|
48
48
|
['events', "injectRouterEvent() from '@ethlete/core'"],
|
|
49
49
|
['routerState', "injectRouterState() from '@ethlete/core'"],
|
|
50
|
-
['snapshot', "injectRouterState() from '@ethlete/core'"],
|
|
51
50
|
['lastSuccessfulNavigation', "injectRouterState() from '@ethlete/core'"],
|
|
52
51
|
['currentNavigation', "injectRouterState() from '@ethlete/core'"],
|
|
53
52
|
]);
|
|
@@ -81,47 +80,103 @@ const noAngularRouterApi = {
|
|
|
81
80
|
isImportedAs(sourceCode, call.callee, 'inject') &&
|
|
82
81
|
isImportedAs(sourceCode, call.arguments[0], token, ANGULAR_ROUTER);
|
|
83
82
|
|
|
83
|
+
/** @type {WeakMap<any, Set<string>>} */
|
|
84
|
+
const classRouterMembers = new WeakMap();
|
|
85
|
+
|
|
84
86
|
/**
|
|
85
|
-
*
|
|
86
|
-
* We track identifiers whose initializer is inject(Router) so we can
|
|
87
|
-
* detect property access on them anywhere in the file.
|
|
88
|
-
* @type {Set<string>}
|
|
87
|
+
* @param {any} classBody
|
|
89
88
|
*/
|
|
90
|
-
const
|
|
89
|
+
const getClassRouterMembers = (classBody) => {
|
|
90
|
+
let members = classRouterMembers.get(classBody);
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
if (!members) {
|
|
93
|
+
members = new Set();
|
|
94
|
+
|
|
95
|
+
for (const member of classBody.body) {
|
|
96
|
+
if (member.type === 'PropertyDefinition' && member.key.type === 'Identifier' && !member.computed) {
|
|
97
|
+
if (isInjectOf(member.value, 'Router')) members.add(member.key.name);
|
|
98
|
+
}
|
|
98
99
|
}
|
|
99
|
-
},
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
classRouterMembers.set(classBody, members);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return members;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {any} thisExpression
|
|
109
|
+
*/
|
|
110
|
+
const getThisClassBody = (thisExpression) => {
|
|
111
|
+
let current = thisExpression.parent;
|
|
112
|
+
|
|
113
|
+
while (current) {
|
|
114
|
+
if (current.type === 'ClassBody') return current;
|
|
115
|
+
|
|
116
|
+
if (current.type === 'FunctionExpression' || current.type === 'FunctionDeclaration') {
|
|
117
|
+
const parent = current.parent;
|
|
118
|
+
const isMember = parent?.type === 'MethodDefinition' || parent?.type === 'PropertyDefinition';
|
|
119
|
+
if (!isMember) return null;
|
|
105
120
|
}
|
|
106
|
-
},
|
|
107
121
|
|
|
108
|
-
|
|
122
|
+
current = current.parent;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {any} identifier
|
|
130
|
+
*/
|
|
131
|
+
const isRouterVariable = (identifier) => {
|
|
132
|
+
/** @type {import('eslint').Scope.Scope | null} */
|
|
133
|
+
let scope = sourceCode.getScope(identifier);
|
|
134
|
+
|
|
135
|
+
while (scope) {
|
|
136
|
+
const variable = scope.set.get(identifier.name);
|
|
137
|
+
|
|
138
|
+
if (variable) {
|
|
139
|
+
const definition = /** @type {any} */ (variable.defs[0]);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
definition?.type === 'Variable' &&
|
|
143
|
+
definition.node.id.type === 'Identifier' &&
|
|
144
|
+
isInjectOf(definition.node.init, 'Router')
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
scope = scope.upper;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return false;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {any} object
|
|
156
|
+
*/
|
|
157
|
+
const isRouterReference = (object) => {
|
|
158
|
+
if (object.type === 'Identifier') return isRouterVariable(object);
|
|
159
|
+
|
|
160
|
+
if (
|
|
161
|
+
object.type !== 'MemberExpression' ||
|
|
162
|
+
object.computed ||
|
|
163
|
+
object.object.type !== 'ThisExpression' ||
|
|
164
|
+
object.property.type !== 'Identifier'
|
|
165
|
+
) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const classBody = getThisClassBody(object.object);
|
|
170
|
+
|
|
171
|
+
return !!classBody && getClassRouterMembers(classBody).has(object.property.name);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return {
|
|
109
175
|
MemberExpression(node) {
|
|
110
176
|
if (node.property.type !== 'Identifier') return;
|
|
111
177
|
const prop = node.property.name;
|
|
112
178
|
if (!ROUTER_STATE_PROPS.has(prop)) return;
|
|
113
|
-
|
|
114
|
-
// Check object is a known router binding (direct: router.url)
|
|
115
|
-
// or a this.router.url / self.router.url pattern
|
|
116
|
-
const obj = node.object;
|
|
117
|
-
const isDirectBinding = obj.type === 'Identifier' && routerBindings.has(obj.name);
|
|
118
|
-
const isThisBinding =
|
|
119
|
-
obj.type === 'MemberExpression' &&
|
|
120
|
-
obj.object.type === 'ThisExpression' &&
|
|
121
|
-
obj.property.type === 'Identifier' &&
|
|
122
|
-
routerBindings.has(obj.property.name);
|
|
123
|
-
|
|
124
|
-
if (!isDirectBinding && !isThisBinding) return;
|
|
179
|
+
if (!isRouterReference(node.object)) return;
|
|
125
180
|
|
|
126
181
|
context.report({
|
|
127
182
|
node,
|
|
@@ -130,7 +185,6 @@ const noAngularRouterApi = {
|
|
|
130
185
|
});
|
|
131
186
|
},
|
|
132
187
|
|
|
133
|
-
// ── ActivatedRoute import ───────────────────────────────────────────────
|
|
134
188
|
[MODULE_REFERENCE_SELECTOR](node) {
|
|
135
189
|
const reference = getModuleReference(node);
|
|
136
190
|
if (reference?.source !== ANGULAR_ROUTER) return;
|
|
@@ -139,7 +193,6 @@ const noAngularRouterApi = {
|
|
|
139
193
|
}
|
|
140
194
|
},
|
|
141
195
|
|
|
142
|
-
// ── inject(ActivatedRoute) ──────────────────────────────────────────────
|
|
143
196
|
CallExpression(node) {
|
|
144
197
|
if (isInjectOf(node, 'ActivatedRoute')) {
|
|
145
198
|
context.report({ node, messageId: 'noActivatedRoute' });
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* }
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
/** Storybook's play function is called by the test runner and has to be a promise. */
|
|
22
|
+
/** Storybook's play function is called by the test runner and has to be a promise; only exempt in story files. */
|
|
23
23
|
const isStorybookPlayFunction = (node) => {
|
|
24
24
|
const { parent } = node;
|
|
25
25
|
|
|
@@ -49,7 +49,11 @@ const noAsyncAwait = {
|
|
|
49
49
|
schema: [],
|
|
50
50
|
},
|
|
51
51
|
create(context) {
|
|
52
|
+
const isStoryFile = /\.stories\.[cm]?[jt]sx?$/.test(context.filename);
|
|
53
|
+
|
|
52
54
|
const isExempt = (node) => {
|
|
55
|
+
if (!isStoryFile) return false;
|
|
56
|
+
|
|
53
57
|
for (let current = node; current; current = current.parent) {
|
|
54
58
|
if (isStorybookPlayFunction(current)) return true;
|
|
55
59
|
}
|
|
@@ -26,17 +26,33 @@ const CDK_PACKAGE = '@ethlete/cdk';
|
|
|
26
26
|
const MIGRATION_MAP = '@ethlete/cdk/migration-map.json';
|
|
27
27
|
const DEFAULT_DOCS_BASE_URL = 'https://ethlete-sdk-docs.web.app';
|
|
28
28
|
|
|
29
|
-
/** @
|
|
29
|
+
/** @typedef {Record<string, { kind: string, to?: string, package?: string, docs?: string, note?: string }>} TMigrationMap */
|
|
30
|
+
|
|
31
|
+
/** @type {Map<string, { mtimeMs: number | null, map: TMigrationMap }>} */
|
|
30
32
|
const mapCache = new Map();
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} path
|
|
36
|
+
*/
|
|
37
|
+
const getMtimeMs = (path) => {
|
|
38
|
+
try {
|
|
39
|
+
return fs.statSync(path).mtimeMs;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
32
45
|
/**
|
|
33
46
|
* @param {string | undefined} mapPath
|
|
34
47
|
* @param {string} cwd
|
|
48
|
+
* @returns {TMigrationMap | null}
|
|
35
49
|
*/
|
|
36
50
|
const loadMigrationMap = (mapPath, cwd) => {
|
|
37
|
-
const cacheKey = mapPath ? path.resolve(cwd, mapPath) : MIGRATION_MAP
|
|
51
|
+
const cacheKey = mapPath ? path.resolve(cwd, mapPath) : `${cwd}:${MIGRATION_MAP}`;
|
|
52
|
+
const mtimeMs = mapPath ? getMtimeMs(cacheKey) : null;
|
|
53
|
+
const cached = mapCache.get(cacheKey);
|
|
38
54
|
|
|
39
|
-
if (
|
|
55
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.map;
|
|
40
56
|
|
|
41
57
|
let map;
|
|
42
58
|
|
|
@@ -45,14 +61,23 @@ const loadMigrationMap = (mapPath, cwd) => {
|
|
|
45
61
|
? JSON.parse(fs.readFileSync(cacheKey, 'utf8'))
|
|
46
62
|
: createRequire(path.join(cwd, 'noop.js'))(MIGRATION_MAP);
|
|
47
63
|
} catch {
|
|
48
|
-
|
|
64
|
+
return null;
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
mapCache.set(cacheKey, map);
|
|
67
|
+
mapCache.set(cacheKey, { mtimeMs, map });
|
|
52
68
|
|
|
53
69
|
return map;
|
|
54
70
|
};
|
|
55
71
|
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} note
|
|
74
|
+
*/
|
|
75
|
+
const formatNote = (note) => {
|
|
76
|
+
const sentence = note.charAt(0).toUpperCase() + note.slice(1);
|
|
77
|
+
|
|
78
|
+
return /[.!?]$/u.test(sentence) ? sentence : `${sentence}.`;
|
|
79
|
+
};
|
|
80
|
+
|
|
56
81
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
57
82
|
const noCdkImport = {
|
|
58
83
|
meta: {
|
|
@@ -86,7 +111,7 @@ const noCdkImport = {
|
|
|
86
111
|
const map = loadMigrationMap(options.migrationMapPath, context.cwd);
|
|
87
112
|
|
|
88
113
|
/** @param {string} name */
|
|
89
|
-
const entryFor = (name) => map
|
|
114
|
+
const entryFor = (name) => (map && Object.hasOwn(map, name) ? map[name] : null);
|
|
90
115
|
|
|
91
116
|
return {
|
|
92
117
|
[MODULE_REFERENCE_SELECTOR](node) {
|
|
@@ -135,7 +160,7 @@ const noCdkImport = {
|
|
|
135
160
|
to: entry.to,
|
|
136
161
|
package: entry.package ?? '@ethlete/components',
|
|
137
162
|
docs: entry.docs ? `${docsBaseUrl}${entry.docs}` : migrationDocs,
|
|
138
|
-
note: entry.note ? ` ${entry.note}
|
|
163
|
+
note: entry.note ? ` ${formatNote(entry.note)}` : '',
|
|
139
164
|
},
|
|
140
165
|
});
|
|
141
166
|
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Disallows code that a strict Content Security Policy without `'unsafe-inline'` / `'unsafe-eval'`
|
|
6
|
+
* blocks at runtime:
|
|
7
|
+
*
|
|
8
|
+
* script-src 'self' 'nonce-…'; style-src 'self' 'nonce-…'
|
|
9
|
+
*
|
|
10
|
+
* BAD:
|
|
11
|
+
* el.setAttribute('style', 'color: red'); // inline style attribute
|
|
12
|
+
* renderer.setAttribute(el, 'style', css);
|
|
13
|
+
* const s = document.createElement('style'); // appended without a nonce
|
|
14
|
+
* eval(code); new Function(code); setTimeout('tick()', 10);
|
|
15
|
+
* `<div style="color: red">…</div>` // HTML string parsed into the DOM
|
|
16
|
+
* <div style="color: red"></div> // static template attribute
|
|
17
|
+
* host: { style: 'display: block' }
|
|
18
|
+
*
|
|
19
|
+
* GOOD:
|
|
20
|
+
* el.style.color = 'red'; el.style.setProperty('--x', '1'); [style.color]="c"
|
|
21
|
+
* s.setAttribute('nonce', inject(CSP_NONCE)); // or s.nonce = …
|
|
22
|
+
* a class or data attribute styled by a shipped stylesheet
|
|
23
|
+
*
|
|
24
|
+
* In an Angular template this rule runs on `.html` files and on inline templates extracted by
|
|
25
|
+
* `processInlineTemplates`; in TypeScript it skips a decorator's `template` string for that reason.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const { isGlobalReference } = require('./internals/import-resolution');
|
|
30
|
+
|
|
31
|
+
const NONCED_ELEMENTS = new Set(['script', 'style']);
|
|
32
|
+
const TIMER_FUNCTIONS = new Set(['setTimeout', 'setInterval']);
|
|
33
|
+
const HTML_STYLE_ATTRIBUTE = /<[a-zA-Z][^<>]*\sstyle\s*=/;
|
|
34
|
+
const UNFIXABLE_STYLE_VALUE = /['"\\]|\/\*|!important|url\(/i;
|
|
35
|
+
const PLAIN_STYLE_PROPERTY = /^[a-zA-Z]+$/;
|
|
36
|
+
const STYLE_PROPERTY = /^-{0,2}[a-zA-Z][\w-]*$/;
|
|
37
|
+
const QUOTED_INLINE_TEMPLATE = /\btemplate\s*:\s*['"]/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Turns `a: b; c-d: e` into `{ a: 'b', 'c-d': 'e' }`, or returns null when the declarations cannot be
|
|
41
|
+
* rewritten without changing what they mean.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} css
|
|
44
|
+
*/
|
|
45
|
+
const toStyleObjectLiteral = (css) => {
|
|
46
|
+
if (UNFIXABLE_STYLE_VALUE.test(css)) return null;
|
|
47
|
+
|
|
48
|
+
const entries = [];
|
|
49
|
+
|
|
50
|
+
for (const declaration of css.split(';')) {
|
|
51
|
+
if (!declaration.trim()) continue;
|
|
52
|
+
|
|
53
|
+
const colon = declaration.indexOf(':');
|
|
54
|
+
|
|
55
|
+
if (colon === -1) return null;
|
|
56
|
+
|
|
57
|
+
const property = declaration.slice(0, colon).trim();
|
|
58
|
+
const value = declaration
|
|
59
|
+
.slice(colon + 1)
|
|
60
|
+
.trim()
|
|
61
|
+
.replace(/\s+/g, ' ');
|
|
62
|
+
|
|
63
|
+
if (!STYLE_PROPERTY.test(property) || !value) return null;
|
|
64
|
+
|
|
65
|
+
entries.push(`${PLAIN_STYLE_PROPERTY.test(property) ? property : `'${property}'`}: '${value}'`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return entries.length ? `{ ${entries.join(', ')} }` : null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** @param {any} node */
|
|
72
|
+
const stringValue = (node) => (node?.type === 'Literal' && typeof node.value === 'string' ? node.value : null);
|
|
73
|
+
|
|
74
|
+
/** @param {any} node */
|
|
75
|
+
const propertyName = (node) => {
|
|
76
|
+
if (node?.type !== 'Property' || node.computed) return null;
|
|
77
|
+
if (node.key.type === 'Identifier') return node.key.name;
|
|
78
|
+
|
|
79
|
+
return stringValue(node.key);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** @param {any} node */
|
|
83
|
+
const calleeMemberName = (node) =>
|
|
84
|
+
node.callee.type === 'MemberExpression' && !node.callee.computed && node.callee.property.type === 'Identifier'
|
|
85
|
+
? node.callee.property.name
|
|
86
|
+
: null;
|
|
87
|
+
|
|
88
|
+
/** @param {any} node @param {string} name */
|
|
89
|
+
const hasProperty = (node, name) =>
|
|
90
|
+
node?.type === 'ObjectExpression' && node.properties.some(/** @param {any} p */ (p) => propertyName(p) === name);
|
|
91
|
+
|
|
92
|
+
/** @param {any} node */
|
|
93
|
+
const isDecoratorTemplate = (node) => {
|
|
94
|
+
const property = node.parent;
|
|
95
|
+
|
|
96
|
+
if (propertyName(property) !== 'template' || property.value !== node) return false;
|
|
97
|
+
|
|
98
|
+
const call = property.parent?.parent;
|
|
99
|
+
|
|
100
|
+
return call?.type === 'CallExpression' && call.parent?.type === 'Decorator';
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** @param {any} node */
|
|
104
|
+
const isHostMetadata = (node) => {
|
|
105
|
+
const property = node.parent;
|
|
106
|
+
|
|
107
|
+
if (propertyName(property) !== 'host' || property.value !== node) return false;
|
|
108
|
+
|
|
109
|
+
const call = property.parent?.parent;
|
|
110
|
+
|
|
111
|
+
return call?.type === 'CallExpression' && call.parent?.type === 'Decorator';
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** @param {any} node */
|
|
115
|
+
const enclosingScopeNode = (node) => {
|
|
116
|
+
let current = node.parent;
|
|
117
|
+
|
|
118
|
+
while (current) {
|
|
119
|
+
if (
|
|
120
|
+
current.type === 'FunctionDeclaration' ||
|
|
121
|
+
current.type === 'FunctionExpression' ||
|
|
122
|
+
current.type === 'ArrowFunctionExpression' ||
|
|
123
|
+
current.type === 'Program'
|
|
124
|
+
) {
|
|
125
|
+
return current;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
current = current.parent;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** @param {any} node */
|
|
135
|
+
const boundTarget = (node) => {
|
|
136
|
+
const parent = node.parent;
|
|
137
|
+
|
|
138
|
+
if (parent?.type === 'VariableDeclarator' && parent.init === node && parent.id.type === 'Identifier')
|
|
139
|
+
return parent.id;
|
|
140
|
+
if (parent?.type === 'AssignmentExpression' && parent.right === node) return parent.left;
|
|
141
|
+
|
|
142
|
+
return null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
146
|
+
const noCspUnsafe = {
|
|
147
|
+
meta: {
|
|
148
|
+
type: 'problem',
|
|
149
|
+
fixable: 'code',
|
|
150
|
+
docs: {
|
|
151
|
+
description:
|
|
152
|
+
"Disallow code a strict Content Security Policy (no 'unsafe-inline', no 'unsafe-eval') blocks at runtime.",
|
|
153
|
+
recommended: true,
|
|
154
|
+
},
|
|
155
|
+
schema: [],
|
|
156
|
+
messages: {
|
|
157
|
+
styleAttribute:
|
|
158
|
+
"A `style` attribute is blocked by a CSP without 'unsafe-inline'. Set styles through CSSOM (`el.style.x = …`, `style.setProperty()`, a `[style.x]` binding) or use a class.",
|
|
159
|
+
htmlStyleAttribute:
|
|
160
|
+
"This HTML string sets a `style` attribute, which a CSP without 'unsafe-inline' blocks once it is parsed into the DOM. Use a class or a data attribute styled by a stylesheet.",
|
|
161
|
+
templateStyleAttribute:
|
|
162
|
+
"A static `style` attribute is blocked by a CSP without 'unsafe-inline'. Use `[style.x]` bindings or a class.",
|
|
163
|
+
missingNonce:
|
|
164
|
+
"A `<{{tag}}>` element created without a nonce is blocked by a CSP without 'unsafe-inline'. Set `nonce` from `inject(CSP_NONCE)` on it in the same function.",
|
|
165
|
+
dynamicCode: "`{{name}}` evaluates a string as code, which a CSP without 'unsafe-eval' blocks.",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
create(context) {
|
|
169
|
+
const sourceCode = context.sourceCode;
|
|
170
|
+
const parserServices = /** @type {any} */ (sourceCode.parserServices);
|
|
171
|
+
|
|
172
|
+
if (parserServices?.convertNodeSourceSpanToLoc) {
|
|
173
|
+
/** @param {any} span @param {string} messageId */
|
|
174
|
+
const reportSpan = (span, messageId) =>
|
|
175
|
+
context.report({ loc: parserServices.convertNodeSourceSpanToLoc(span), messageId });
|
|
176
|
+
|
|
177
|
+
let quotedInlineTemplate = /** @type {boolean | undefined} */ (undefined);
|
|
178
|
+
|
|
179
|
+
// The fix writes single quotes, which would end a quoted `template: '…'` string in the component.
|
|
180
|
+
const canFixTemplate = () => {
|
|
181
|
+
if (context.physicalFilename === context.filename || !context.physicalFilename.endsWith('.ts')) return true;
|
|
182
|
+
|
|
183
|
+
quotedInlineTemplate ??= QUOTED_INLINE_TEMPLATE.test(fs.readFileSync(context.physicalFilename, 'utf8'));
|
|
184
|
+
|
|
185
|
+
return !quotedInlineTemplate;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
/** @param {any} node */
|
|
190
|
+
Element(node) {
|
|
191
|
+
const hasStyleBinding = (node.inputs ?? []).some(
|
|
192
|
+
/** @param {any} input */ (input) => input.name === 'style' && String(input.keySpan) === 'style',
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
for (const attribute of node.attributes ?? []) {
|
|
196
|
+
if (attribute.name !== 'style') continue;
|
|
197
|
+
|
|
198
|
+
const literal = hasStyleBinding || !canFixTemplate() ? null : toStyleObjectLiteral(attribute.value);
|
|
199
|
+
|
|
200
|
+
context.report({
|
|
201
|
+
loc: parserServices.convertNodeSourceSpanToLoc(attribute.sourceSpan),
|
|
202
|
+
messageId: 'templateStyleAttribute',
|
|
203
|
+
fix: literal
|
|
204
|
+
? (fixer) =>
|
|
205
|
+
fixer.replaceTextRange(
|
|
206
|
+
[attribute.sourceSpan.start.offset, attribute.sourceSpan.end.offset],
|
|
207
|
+
`[style]="${literal}"`,
|
|
208
|
+
)
|
|
209
|
+
: null,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const input of node.inputs ?? []) {
|
|
214
|
+
if (input.name === 'style' && String(input.keySpan) === 'attr.style') {
|
|
215
|
+
reportSpan(input.sourceSpan, 'styleAttribute');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @param {any} scope @param {string} target */
|
|
223
|
+
const setsNonce = (scope, target) => {
|
|
224
|
+
let found = false;
|
|
225
|
+
|
|
226
|
+
/** @param {any} node */
|
|
227
|
+
const matches = (node) => node && sourceCode.getText(node) === target;
|
|
228
|
+
|
|
229
|
+
/** @param {any} node */
|
|
230
|
+
const visit = (node) => {
|
|
231
|
+
if (found || !node || typeof node.type !== 'string') return;
|
|
232
|
+
|
|
233
|
+
if (
|
|
234
|
+
node.type === 'AssignmentExpression' &&
|
|
235
|
+
node.left.type === 'MemberExpression' &&
|
|
236
|
+
!node.left.computed &&
|
|
237
|
+
node.left.property.name === 'nonce' &&
|
|
238
|
+
matches(node.left.object)
|
|
239
|
+
) {
|
|
240
|
+
found = true;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (node.type === 'CallExpression') {
|
|
245
|
+
const method = calleeMemberName(node);
|
|
246
|
+
const [first, second] = node.arguments;
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
(method === 'setAttribute' && matches(node.callee.object) && stringValue(first) === 'nonce') ||
|
|
250
|
+
(method === 'setAttribute' && matches(first) && stringValue(second) === 'nonce') ||
|
|
251
|
+
((method === 'setAttributes' || method === 'assign') && matches(first) && hasProperty(second, 'nonce'))
|
|
252
|
+
) {
|
|
253
|
+
found = true;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const key of sourceCode.visitorKeys[node.type] ?? []) {
|
|
259
|
+
const child = node[key];
|
|
260
|
+
|
|
261
|
+
if (Array.isArray(child)) child.forEach(visit);
|
|
262
|
+
else visit(child);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
visit(scope);
|
|
267
|
+
|
|
268
|
+
return found;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** @param {any} node @param {string} text */
|
|
272
|
+
const checkHtmlString = (node, text) => {
|
|
273
|
+
if (HTML_STYLE_ATTRIBUTE.test(text) && !isDecoratorTemplate(node)) {
|
|
274
|
+
context.report({ node, messageId: 'htmlStyleAttribute' });
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
/** @param {any} node */
|
|
280
|
+
CallExpression(node) {
|
|
281
|
+
const method = calleeMemberName(node);
|
|
282
|
+
const [first, second] = node.arguments;
|
|
283
|
+
|
|
284
|
+
if (method === 'setAttribute') {
|
|
285
|
+
if (stringValue(first) === 'style' || (node.arguments.length >= 3 && stringValue(second) === 'style')) {
|
|
286
|
+
context.report({ node, messageId: 'styleAttribute' });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (method === 'setAttributes' && hasProperty(second, 'style')) {
|
|
293
|
+
context.report({ node, messageId: 'styleAttribute' });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (method === 'createElement') {
|
|
298
|
+
const tag = stringValue(first)?.toLowerCase();
|
|
299
|
+
|
|
300
|
+
if (!tag || !NONCED_ELEMENTS.has(tag)) return;
|
|
301
|
+
|
|
302
|
+
const target = boundTarget(node);
|
|
303
|
+
const scope = enclosingScopeNode(node);
|
|
304
|
+
|
|
305
|
+
if (!target || !scope || !setsNonce(scope, sourceCode.getText(target))) {
|
|
306
|
+
context.report({ node, messageId: 'missingNonce', data: { tag } });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const callee = node.callee;
|
|
313
|
+
const name = callee.type === 'Identifier' ? callee.name : callee.type === 'MemberExpression' ? method : null;
|
|
314
|
+
|
|
315
|
+
if (!name) return;
|
|
316
|
+
|
|
317
|
+
if (callee.type === 'Identifier' && (name === 'eval' || name === 'Function')) {
|
|
318
|
+
if (isGlobalReference(sourceCode, callee)) context.report({ node, messageId: 'dynamicCode', data: { name } });
|
|
319
|
+
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (TIMER_FUNCTIONS.has(name) && (stringValue(first) !== null || first?.type === 'TemplateLiteral')) {
|
|
324
|
+
if (callee.type === 'MemberExpression' || isGlobalReference(sourceCode, callee)) {
|
|
325
|
+
context.report({ node, messageId: 'dynamicCode', data: { name: `${name}(string)` } });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
/** @param {any} node */
|
|
330
|
+
NewExpression(node) {
|
|
331
|
+
if (
|
|
332
|
+
node.callee.type === 'Identifier' &&
|
|
333
|
+
node.callee.name === 'Function' &&
|
|
334
|
+
isGlobalReference(sourceCode, node.callee)
|
|
335
|
+
) {
|
|
336
|
+
context.report({ node, messageId: 'dynamicCode', data: { name: 'new Function' } });
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
/** @param {any} node */
|
|
340
|
+
ObjectExpression(node) {
|
|
341
|
+
if (!isHostMetadata(node)) return;
|
|
342
|
+
|
|
343
|
+
for (const property of node.properties) {
|
|
344
|
+
const key = propertyName(property);
|
|
345
|
+
|
|
346
|
+
if (key === 'style' || key === '[attr.style]')
|
|
347
|
+
context.report({ node: property, messageId: 'styleAttribute' });
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
/** @param {any} node */
|
|
351
|
+
Literal(node) {
|
|
352
|
+
if (typeof node.value === 'string') checkHtmlString(node, node.value);
|
|
353
|
+
},
|
|
354
|
+
/** @param {any} node */
|
|
355
|
+
TemplateLiteral(node) {
|
|
356
|
+
checkHtmlString(node, node.quasis.map(/** @param {any} q */ (q) => q.value.cooked ?? q.value.raw).join('x'));
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
module.exports = noCspUnsafe;
|