@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/migrations.json +12 -0
  3. package/package.json +13 -2
  4. package/src/configs/recommended.js +37 -27
  5. package/src/generators/generators.json +10 -0
  6. package/src/generators/restore-module-augmentation-interfaces/migration.js +50 -0
  7. package/src/generators/restore-module-augmentation-interfaces/restore-module-augmentation-interfaces.js +103 -0
  8. package/src/generators/restore-module-augmentation-interfaces/schema.json +14 -0
  9. package/src/index.js +8 -2
  10. package/src/rules/angular-decorator-property-order.js +24 -7
  11. package/src/rules/class-constant-property.js +1 -1
  12. package/src/rules/class-member-order.js +60 -8
  13. package/src/rules/consistent-type-definitions.js +60 -0
  14. package/src/rules/enforce-routing-view-naming.js +34 -23
  15. package/src/rules/guard-return-newline.js +4 -7
  16. package/src/rules/internals/angular-member-visibility.js +24 -2
  17. package/src/rules/internals/angular-metadata-fix.js +231 -1
  18. package/src/rules/internals/angular-version.js +50 -0
  19. package/src/rules/internals/implemented-contract-members.js +26 -11
  20. package/src/rules/no-angular-router-api.js +86 -33
  21. package/src/rules/no-angular-seo-services.js +1 -0
  22. package/src/rules/no-async-await.js +5 -1
  23. package/src/rules/no-cdk-import.js +32 -7
  24. package/src/rules/no-csp-unsafe.js +362 -0
  25. package/src/rules/no-direct-dom-manipulation.js +50 -28
  26. package/src/rules/no-document-cookie.js +51 -8
  27. package/src/rules/no-dom-query.js +5 -2
  28. package/src/rules/no-empty-angular-metadata-arrays.js +2 -0
  29. package/src/rules/no-empty-newlines-between-imports.js +4 -1
  30. package/src/rules/no-impure-top-level-provider.js +42 -7
  31. package/src/rules/no-leading-underscore-class-member.js +27 -2
  32. package/src/rules/no-legacy-angular-decorators.js +11 -6
  33. package/src/rules/no-legacy-prepare-without-injector.js +57 -12
  34. package/src/rules/no-legacy-query-import.js +61 -16
  35. package/src/rules/no-member-alias.js +0 -5
  36. package/src/rules/no-native-observers.js +89 -24
  37. package/src/rules/no-pipe-logic.js +1 -2
  38. package/src/rules/no-readonly-signal.js +1 -5
  39. package/src/rules/no-redundant-internal.js +5 -2
  40. package/src/rules/no-redundant-on-push-change-detection.js +3 -48
  41. package/src/rules/no-rxjs-in-effect.js +7 -5
  42. package/src/rules/no-screaming-case-local.js +3 -5
  43. package/src/rules/no-standalone-flag.js +1 -0
  44. package/src/rules/no-subscribe-with-body.js +30 -1
  45. package/src/rules/no-template-literal-before-inline-template.js +6 -0
  46. package/src/rules/no-trivial-return-type.js +18 -1
  47. package/src/rules/no-unused-class-member.js +1 -1
  48. package/src/rules/no-window-location.js +60 -22
  49. package/src/rules/prefer-clone-equal.js +61 -8
  50. package/src/rules/prefer-concise-angular-host-directives.js +39 -11
  51. package/src/rules/prefer-concise-angular-style-metadata.js +10 -2
  52. package/src/rules/prefer-element-dimensions.js +264 -11
  53. package/src/rules/prefer-linked-signal.js +37 -39
  54. package/src/rules/prefer-match-media.js +17 -3
  55. package/src/rules/prefer-rxjs-timer.js +26 -4
  56. package/src/rules/prefer-scroll-state.js +0 -4
  57. package/src/rules/prefer-static-boolean-properties.js +1 -0
  58. package/src/rules/prefer-viewport-size.js +153 -16
  59. package/src/rules/require-dollar-suffix.js +11 -3
  60. package/src/rules/require-form-submit.js +4 -1
  61. package/src/rules/require-on-push-change-detection.js +7 -259
  62. package/src/rules/require-view-encapsulation-none.js +6 -214
  63. 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
- * Names of variables/properties assigned from inject(Router).
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 routerBindings = new Set();
89
+ const getClassRouterMembers = (classBody) => {
90
+ let members = classRouterMembers.get(classBody);
91
91
 
92
- return {
93
- // ── Track inject(Router) bindings ──────────────────────────────────────
94
- // const router = inject(Router);
95
- VariableDeclarator(node) {
96
- if (isInjectOf(node.init, 'Router') && node.id.type === 'Identifier') {
97
- routerBindings.add(node.id.name);
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
- // ── Track class property assignments: router = inject(Router) ──────────
102
- PropertyDefinition(node) {
103
- if (isInjectOf(node.value, 'Router') && node.key.type === 'Identifier') {
104
- routerBindings.add(node.key.name);
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
- // ── Detect router.{stateProp} access ───────────────────────────────────
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' });
@@ -36,6 +36,7 @@ const rule = {
36
36
  type: 'suggestion',
37
37
  docs: {
38
38
  description: "Disallow Angular's Title/Meta services. Use @ethlete/core SEO utilities instead.",
39
+ recommended: true,
39
40
  },
40
41
  schema: [],
41
42
  messages: {
@@ -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
- /** @type {Map<string, Record<string, { kind: string, to?: string, package?: string, docs?: string, note?: string }> | null>} */
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 (mapCache.has(cacheKey)) return mapCache.get(cacheKey) ?? null;
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
- map = null;
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?.[name] ?? null;
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;