@ethlete/eslint-plugin 1.0.0-next.24 → 1.0.0-next.25

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.25
4
+
5
+ ### Minor Changes
6
+
7
+ - The `@angular-eslint/*` output rules moved from `recommendedTs` into a new `recommendedAngularTs` config, which `recommended` includes, so NestJS and other non-Angular projects can use `recommendedTs` without the `@angular-eslint` plugin.
8
+ - `recommendedTs` now uses `ethlete/consistent-type-definitions` instead of `@typescript-eslint/consistent-type-definitions`, so `--fix` no longer turns an interface inside `declare module` or `declare global` (such as the theme-name registry the `@ethlete/core` generators emit) into a type alias that merges into nothing.
9
+ - New `no-csp-unsafe` rule (error in `recommendedTs` and `recommendedTemplate`) flags style attributes, un-nonced `<script>`/`<style>` elements, `eval` and string timers that a strict CSP blocks.
10
+ - `et update` turns the type aliases the old `consistent-type-definitions` fix left in `declare module` and `declare global` blocks back into interfaces, and renames the rule in `eslint-disable` directives.
11
+ - `prefer-linked-signal` now also reports an `effect()` that only calls `.set()` behind an `if`/`else`.
12
+
13
+ ### Patch Changes
14
+
15
+ - `ethlete/no-csp-unsafe` fixes a static template `style="…"` attribute. It rewrites `style="width: 40%; margin-top: 4px"` to `[style]="{ width: '40%', 'margin-top': '4px' }"`, which a strict CSP allows. A value with a quote, `url(`, `!important` or a comment stays a manual fix.
16
+
3
17
  ## 1.0.0-next.24
4
18
 
5
19
  ### Minor Changes
@@ -0,0 +1,12 @@
1
+ {
2
+ "migrations": [
3
+ {
4
+ "name": "module-augmentation-interfaces",
5
+ "version": "1.0.0-next.25",
6
+ "kind": "auto",
7
+ "description": "Turn the type aliases the old consistent-type-definitions fix left in declare module / declare global blocks back into interfaces, and rename its eslint-disable directives to ethlete/consistent-type-definitions.",
8
+ "generator": "@ethlete/eslint-plugin:restore-module-augmentation-interfaces",
9
+ "docs": "/eslint/rules"
10
+ }
11
+ ]
12
+ }
package/package.json CHANGED
@@ -1,14 +1,24 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "1.0.0-next.24",
3
+ "version": "1.0.0-next.25",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
7
7
  "peerDependencies": {
8
8
  "@angular-eslint/eslint-plugin-template": ">=21.0.0",
9
9
  "@angular/core": ">=21.0.0",
10
+ "@nx/devkit": ">=21.0.0",
10
11
  "@typescript-eslint/eslint-plugin": ">=8.0.0",
11
12
  "eslint": ">=9.0.0",
12
13
  "typescript": ">=5.9.0"
13
- }
14
+ },
15
+ "peerDependenciesMeta": {
16
+ "@nx/devkit": {
17
+ "optional": true
18
+ }
19
+ },
20
+ "ethlete": {
21
+ "migrations": "./migrations.json"
22
+ },
23
+ "generators": "./src/generators/generators.json"
14
24
  }
@@ -15,8 +15,8 @@ const recommendedTs = {
15
15
  rules: {
16
16
  // ── TypeScript ──────────────────────────────────────────────────────────
17
17
 
18
- // No interface — use type
19
- '@typescript-eslint/consistent-type-definitions': ['error', 'type'],
18
+ // No interface — use type (owned by ethlete/consistent-type-definitions)
19
+ '@typescript-eslint/consistent-type-definitions': 'off',
20
20
 
21
21
  // No any
22
22
  '@typescript-eslint/no-explicit-any': 'error',
@@ -248,6 +248,9 @@ const recommendedTs = {
248
248
  // No enum / const enum — use a const object with `as const` plus a derived union type
249
249
  'ethlete/no-enum': 'error',
250
250
 
251
+ // No interface — use type; an interface augmenting a module or the global scope is exempt
252
+ 'ethlete/consistent-type-definitions': 'error',
253
+
251
254
  // No .subscribe() inside effect() or computed()
252
255
  'ethlete/no-rxjs-in-effect': 'error',
253
256
 
@@ -311,6 +314,10 @@ const recommendedTs = {
311
314
  // — use getCookie / setCookie / hasCookie / deleteCookie from '@ethlete/core' instead
312
315
  'ethlete/no-document-cookie': 'error',
313
316
 
317
+ // No code a strict CSP (no 'unsafe-inline' / 'unsafe-eval') blocks: style attributes,
318
+ // un-nonced <script>/<style> elements, eval / new Function / string timers
319
+ 'ethlete/no-csp-unsafe': 'error',
320
+
314
321
  // Injected providers must be private by default, and protected only when
315
322
  // referenced from an Angular template or host binding.
316
323
  'ethlete/inject-member-accessibility': 'error',
@@ -350,14 +357,6 @@ const recommendedTs = {
350
357
  // Angular language service completions for the rest of the file
351
358
  'ethlete/no-template-literal-before-inline-template': 'error',
352
359
 
353
- // ── Angular outputs ─────────────────────────────────────────────────────
354
-
355
- // No on-prefixed outputs (onSelectDate → selectDate)
356
- '@angular-eslint/no-output-on-prefix': 'error',
357
-
358
- // No outputs named after native DOM events (change, click, etc.)
359
- '@angular-eslint/no-output-native': 'error',
360
-
361
360
  // ── Angular components ──────────────────────────────────────────────────
362
361
 
363
362
  // Require ChangeDetectionStrategy.OnPush on Angular <= 21, where it is opt-in
@@ -402,6 +401,23 @@ const recommendedTs = {
402
401
  },
403
402
  };
404
403
 
404
+ /**
405
+ * `@angular-eslint/*` rules for TypeScript files in Angular projects; the base config must register
406
+ * the `@angular-eslint` plugin.
407
+ *
408
+ * @type {import('eslint').Linter.Config}
409
+ */
410
+ const recommendedAngularTs = {
411
+ files: ['**/*.ts'],
412
+ rules: {
413
+ // No on-prefixed outputs (onSelectDate → selectDate)
414
+ '@angular-eslint/no-output-on-prefix': 'error',
415
+
416
+ // No outputs named after native DOM events (change, click, etc.)
417
+ '@angular-eslint/no-output-native': 'error',
418
+ },
419
+ };
420
+
405
421
  /**
406
422
  * Flat config entries for Angular HTML template files.
407
423
  * @type {import('eslint').Linter.Config}
@@ -419,6 +435,8 @@ const recommendedTemplate = {
419
435
  'ethlete/prefer-static-boolean-properties': 'warn',
420
436
  // A form must handle its own submission
421
437
  'ethlete/require-form-submit': 'error',
438
+ // No static style attribute or [attr.style] binding - a strict CSP blocks both
439
+ 'ethlete/no-csp-unsafe': 'error',
422
440
  },
423
441
  };
424
442
 
@@ -432,6 +450,7 @@ const recommendedSpec = {
432
450
  '@typescript-eslint/no-non-null-assertion': 'off',
433
451
  'no-restricted-globals': 'off',
434
452
  'ethlete/no-async-await': 'off',
453
+ 'ethlete/no-csp-unsafe': 'off',
435
454
  'ethlete/no-direct-dom-manipulation': 'off',
436
455
  'ethlete/no-document-cookie': 'off',
437
456
  'ethlete/no-dom-query': 'off',
@@ -444,4 +463,4 @@ const recommendedSpec = {
444
463
  },
445
464
  };
446
465
 
447
- module.exports = { recommendedTs, recommendedTemplate, recommendedSpec };
466
+ module.exports = { recommendedTs, recommendedAngularTs, recommendedTemplate, recommendedSpec };
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/nx-generators",
3
+ "generators": {
4
+ "restore-module-augmentation-interfaces": {
5
+ "implementation": "./restore-module-augmentation-interfaces/migration",
6
+ "schema": "./restore-module-augmentation-interfaces/schema.json",
7
+ "description": "Turn type aliases in declare module / declare global blocks back into interfaces, and rename consistent-type-definitions eslint directives"
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,50 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const { formatFiles, visitNotIgnoredFiles } = require('@nx/devkit');
5
+ const { restoreModuleAugmentationInterfaces } = require('./restore-module-augmentation-interfaces');
6
+
7
+ const SOURCE_FILE = /\.(?:[cm]?ts|tsx|[cm]?js|jsx)$/;
8
+
9
+ /**
10
+ * @param {import('@nx/devkit').Tree} tree
11
+ * @param {{ skipFormat?: boolean }} schema
12
+ */
13
+ const migrateModuleAugmentationInterfaces = async (tree, schema) => {
14
+ console.log('\n🔄 Restoring module augmentation interfaces...');
15
+
16
+ let filesChanged = 0;
17
+ let reviewCount = 0;
18
+
19
+ visitNotIgnoredFiles(tree, '', (filePath) => {
20
+ if (!SOURCE_FILE.test(filePath)) return;
21
+
22
+ const before = tree.read(filePath, 'utf-8');
23
+
24
+ if (!before) return;
25
+
26
+ const result = restoreModuleAugmentationInterfaces(filePath, before);
27
+
28
+ for (const entry of result.review) {
29
+ reviewCount++;
30
+ console.warn(
31
+ ` ⚠ ${filePath}:${entry.line}: type ${entry.name} in a module augmentation is not an object literal, so it was left alone. An augmentation only merges as an interface.`,
32
+ );
33
+ }
34
+
35
+ if (!result.changed) return;
36
+
37
+ tree.write(filePath, result.content);
38
+ filesChanged++;
39
+ console.log(` ✓ ${filePath}`);
40
+ });
41
+
42
+ console.log(filesChanged === 0 ? ' Nothing to migrate.\n' : `\n✅ Migrated ${filesChanged} file(s).\n`);
43
+
44
+ if (reviewCount > 0) console.warn(` Review ${reviewCount} type alias(es) in module augmentations manually.\n`);
45
+
46
+ if (!schema.skipFormat) await formatFiles(tree);
47
+ };
48
+
49
+ module.exports = migrateModuleAugmentationInterfaces;
50
+ module.exports.default = migrateModuleAugmentationInterfaces;
@@ -0,0 +1,103 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const ts = require('typescript');
5
+
6
+ const OLD_RULE = '@typescript-eslint/consistent-type-definitions';
7
+ const NEW_RULE = 'ethlete/consistent-type-definitions';
8
+
9
+ const LINE_DIRECTIVE = /\/\/[ \t]*eslint-(?:disable|enable)(?:-next-line|-line)?\b[^\n]*/g;
10
+ const BLOCK_DIRECTIVE = /\/\*[ \t]*eslint-(?:disable|enable)(?:-next-line|-line)?\b(?:(?!\*\/)[\s\S])*\*\//g;
11
+
12
+ const AUGMENTATION = /\bdeclare\s+(?:module|global)\b/;
13
+
14
+ const renameInDirective = (/** @type {string} */ directive) => directive.split(OLD_RULE).join(NEW_RULE);
15
+
16
+ /** Renames `@typescript-eslint/consistent-type-definitions` in every eslint-disable/enable directive. */
17
+ const renameDirectives = (/** @type {string} */ content) =>
18
+ content.replace(LINE_DIRECTIVE, renameInDirective).replace(BLOCK_DIRECTIVE, renameInDirective);
19
+
20
+ const isAugmentation = (/** @type {ts.Statement} */ statement) =>
21
+ ts.isModuleDeclaration(statement) &&
22
+ (ts.isStringLiteral(statement.name) || (statement.flags & ts.NodeFlags.GlobalAugmentation) !== 0) &&
23
+ statement.body !== undefined &&
24
+ ts.isModuleBlock(statement.body);
25
+
26
+ /**
27
+ * @param {readonly ts.Statement[]} statements
28
+ * @param {ts.TypeAliasDeclaration[]} found
29
+ */
30
+ const collectAugmentationAliases = (statements, found) => {
31
+ for (const statement of statements) {
32
+ if (!isAugmentation(statement)) continue;
33
+
34
+ const body = /** @type {ts.ModuleBlock} */ (/** @type {ts.ModuleDeclaration} */ (statement).body);
35
+
36
+ for (const member of body.statements) {
37
+ if (ts.isTypeAliasDeclaration(member)) found.push(member);
38
+ }
39
+
40
+ collectAugmentationAliases(body.statements, found);
41
+ }
42
+
43
+ return found;
44
+ };
45
+
46
+ /**
47
+ * Turns every object-literal `type X = { … }` directly inside a `declare module '…'` or
48
+ * `declare global` block back into `interface X { … }`, and renames
49
+ * `@typescript-eslint/consistent-type-definitions` in eslint directives to
50
+ * `ethlete/consistent-type-definitions`. Aliases of any other shape are returned in `review`.
51
+ *
52
+ * @param {string} filePath
53
+ * @param {string} content
54
+ * @returns {{ content: string; changed: boolean; review: { name: string; line: number }[] }}
55
+ */
56
+ const restoreModuleAugmentationInterfaces = (filePath, content) => {
57
+ if (!AUGMENTATION.test(content)) {
58
+ const renamed = renameDirectives(content);
59
+
60
+ return { content: renamed, changed: renamed !== content, review: [] };
61
+ }
62
+
63
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
64
+ const aliases = collectAugmentationAliases(sourceFile.statements, []);
65
+
66
+ /** @type {{ name: string; line: number }[]} */
67
+ const review = [];
68
+ /** @type {{ start: number; end: number; text: string }[]} */
69
+ const edits = [];
70
+
71
+ for (const alias of aliases) {
72
+ const name = alias.name.text;
73
+
74
+ if (!ts.isTypeLiteralNode(alias.type)) {
75
+ review.push({ name, line: sourceFile.getLineAndCharacterOfPosition(alias.getStart(sourceFile)).line + 1 });
76
+ continue;
77
+ }
78
+
79
+ const modifiers = (alias.modifiers ?? []).map((modifier) => `${modifier.getText(sourceFile)} `).join('');
80
+ const typeParameters = content
81
+ .slice(alias.name.end, alias.type.pos)
82
+ .replace(/\s*=\s*$/, '')
83
+ .trim();
84
+
85
+ edits.push({
86
+ start: alias.modifiers?.[0]?.getStart(sourceFile) ?? alias.getStart(sourceFile),
87
+ end: alias.end,
88
+ text: `${modifiers}interface ${name}${typeParameters} ${alias.type.getText(sourceFile)}`,
89
+ });
90
+ }
91
+
92
+ let next = content;
93
+
94
+ for (const edit of edits.sort((left, right) => right.start - left.start)) {
95
+ next = next.slice(0, edit.start) + edit.text + next.slice(edit.end);
96
+ }
97
+
98
+ next = renameDirectives(next);
99
+
100
+ return { content: next, changed: next !== content, review };
101
+ };
102
+
103
+ module.exports = { restoreModuleAugmentationInterfaces };
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "restore-module-augmentation-interfaces",
4
+ "title": "Restore module augmentation interfaces",
5
+ "type": "object",
6
+ "properties": {
7
+ "skipFormat": {
8
+ "type": "boolean",
9
+ "description": "Skip formatting files after migration",
10
+ "default": false
11
+ }
12
+ },
13
+ "required": []
14
+ }
package/src/index.js CHANGED
@@ -30,6 +30,7 @@ const noLegacyAngularDecorators = require('./rules/no-legacy-angular-decorators'
30
30
  const noAngularSeoServices = require('./rules/no-angular-seo-services');
31
31
  const preferCloneEqual = require('./rules/prefer-clone-equal');
32
32
  const noDocumentCookie = require('./rules/no-document-cookie');
33
+ const noCspUnsafe = require('./rules/no-csp-unsafe');
33
34
  const injectMemberAccessibility = require('./rules/inject-member-accessibility');
34
35
  const noRedundantInternal = require('./rules/no-redundant-internal');
35
36
  const templateMemberAccessibility = require('./rules/template-member-accessibility');
@@ -60,8 +61,9 @@ const noCdkImport = require('./rules/no-cdk-import');
60
61
  const noLegacyQueryImport = require('./rules/no-legacy-query-import');
61
62
  const noAsyncAwait = require('./rules/no-async-await');
62
63
  const noEnum = require('./rules/no-enum');
64
+ const consistentTypeDefinitions = require('./rules/consistent-type-definitions');
63
65
  const takeUntilDestroyedLast = require('./rules/take-until-destroyed-last');
64
- const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
66
+ const { recommendedTs, recommendedAngularTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
65
67
  const { version } = require('../package.json');
66
68
 
67
69
  /** @type {import('eslint').ESLint.Plugin} */
@@ -102,6 +104,7 @@ const plugin = {
102
104
  'no-angular-seo-services': noAngularSeoServices,
103
105
  'prefer-clone-equal': preferCloneEqual,
104
106
  'no-document-cookie': noDocumentCookie,
107
+ 'no-csp-unsafe': noCspUnsafe,
105
108
  'inject-member-accessibility': injectMemberAccessibility,
106
109
  'no-redundant-internal': noRedundantInternal,
107
110
  'template-member-accessibility': templateMemberAccessibility,
@@ -131,6 +134,7 @@ const plugin = {
131
134
  'no-legacy-query-import': noLegacyQueryImport,
132
135
  'no-async-await': noAsyncAwait,
133
136
  'no-enum': noEnum,
137
+ 'consistent-type-definitions': consistentTypeDefinitions,
134
138
  },
135
139
  };
136
140
 
@@ -150,6 +154,8 @@ const recommendedTemplateWithPlugin = {
150
154
  const configs = {
151
155
  /** TypeScript rules (merged into a files: ['**\/*.ts'] block) */
152
156
  recommendedTs: recommendedTsWithPlugin,
157
+ /** `@angular-eslint/*` rules for TypeScript files (files: ['**\/*.ts']) */
158
+ recommendedAngularTs,
153
159
  /** Angular template rules (merged into a files: ['**\/*.html'] block) */
154
160
  recommendedTemplate: recommendedTemplateWithPlugin,
155
161
  /** Relaxed rules for spec files (files: ['**\/*.spec.ts']) */
@@ -158,7 +164,7 @@ const configs = {
158
164
  * Both together as a flat array — the most common usage:
159
165
  * export default [...baseConfig, ...ethlete.configs.recommended]
160
166
  */
161
- recommended: [recommendedTsWithPlugin, recommendedTemplateWithPlugin, recommendedSpec],
167
+ recommended: [recommendedTsWithPlugin, recommendedAngularTs, recommendedTemplateWithPlugin, recommendedSpec],
162
168
  };
163
169
 
164
170
  const ethletePlugin = { ...plugin, configs };
@@ -0,0 +1,60 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const { rules } = require('@typescript-eslint/eslint-plugin');
5
+
6
+ const upstream = rules['consistent-type-definitions'];
7
+
8
+ /**
9
+ * `@typescript-eslint/consistent-type-definitions` with `'type'`, except that an interface inside a
10
+ * `declare module`, `declare global` or `namespace` block is left alone.
11
+ *
12
+ * Only an interface merges into an existing declaration. The upstream fix turns
13
+ * `declare module '@ethlete/core' { interface EthleteColorThemeNameRegistry { … } }` into a type
14
+ * alias, which merges into nothing, and under `skipLibCheck` the registration disappears without an
15
+ * error.
16
+ *
17
+ * BAD:
18
+ * interface User { name: string }
19
+ *
20
+ * GOOD:
21
+ * type User = { name: string };
22
+ *
23
+ * declare module '@ethlete/core' {
24
+ * interface EthleteColorThemeNameRegistry { name: 'brand' }
25
+ * }
26
+ */
27
+
28
+ const isInsideModuleDeclaration = (sourceCode, node) =>
29
+ sourceCode.getAncestors(node).some((ancestor) => ancestor.type === 'TSModuleDeclaration');
30
+
31
+ /** @type {import('eslint').Rule.RuleModule} */
32
+ const consistentTypeDefinitions = {
33
+ meta: {
34
+ ...upstream.meta,
35
+ docs: {
36
+ description:
37
+ 'Use `type` instead of `interface`, except for an interface that augments a module or the global scope.',
38
+ recommended: true,
39
+ },
40
+ schema: [],
41
+ },
42
+ create(context) {
43
+ const { sourceCode } = context;
44
+
45
+ const filteredContext = Object.create(context, {
46
+ options: { value: ['type'] },
47
+ report: {
48
+ value: (descriptor) => {
49
+ if ('node' in descriptor && isInsideModuleDeclaration(sourceCode, descriptor.node)) return;
50
+
51
+ context.report(descriptor);
52
+ },
53
+ },
54
+ });
55
+
56
+ return upstream.create(filteredContext);
57
+ },
58
+ };
59
+
60
+ module.exports = consistentTypeDefinitions;
@@ -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;
@@ -24,27 +24,43 @@ const { isImportedAs } = require('./internals/import-resolution');
24
24
  * GOOD:
25
25
  * selectedItem = linkedSignal(() => this.items()[0] ?? null);
26
26
  *
27
- * NOTE: Only flag when .set() is called directly inside an effect() callback.
28
- * Setting signals outside effects (in methods, event handlers, etc.) is fine.
27
+ * Also flagged when the write sits behind an `if`, as long as every branch only
28
+ * writes signals: `linkedSignal` keeps the previous value for the untaken branch.
29
29
  */
30
30
 
31
31
  /**
32
- * Returns true only when the `.set()` call node is a *pure derivation* inside
33
- * an effect() callback — meaning:
34
- *
35
- * 1. The IMMEDIATE enclosing function (the first function boundary walking up
36
- * from the node) IS the first argument passed to `effect()`. This ensures
37
- * that `.set()` calls nested inside `untracked(() => ...)` or other inner
38
- * functions within the effect are NOT flagged — those are intentional
39
- * write-back patterns, not derivations.
40
- *
41
- * 2. The `.set()` call is the SOLE expression in that callback:
42
- * - Arrow with expression body: `effect(() => sig.set(x))`
43
- * - Block body with a single ExpressionStatement: `effect(() => { sig.set(x); })`
32
+ * @param {any} node
33
+ * @returns {boolean}
34
+ */
35
+ const isSetCall = (node) =>
36
+ node.type === 'CallExpression' &&
37
+ node.callee.type === 'MemberExpression' &&
38
+ node.callee.property.type === 'Identifier' &&
39
+ node.callee.property.name === 'set' &&
40
+ node.arguments.length === 1;
41
+
42
+ /**
43
+ * A statement that does nothing but write signals: a lone `.set()`, a block holding one, or an
44
+ * `if`/`else` whose every branch is one.
44
45
  *
45
- * Multi-statement blocks (even if they end with a `.set()`) indicate that the
46
- * effect is performing side effects alongside the signal write, so they are
47
- * not flagged either.
46
+ * @param {any} statement
47
+ * @returns {boolean}
48
+ */
49
+ const isSetOnlyStatement = (statement) => {
50
+ if (!statement) return false;
51
+ if (statement.type === 'ExpressionStatement') return isSetCall(statement.expression);
52
+ if (statement.type === 'BlockStatement') return statement.body.length === 1 && isSetOnlyStatement(statement.body[0]);
53
+ if (statement.type === 'IfStatement') {
54
+ return (
55
+ isSetOnlyStatement(statement.consequent) && (!statement.alternate || isSetOnlyStatement(statement.alternate))
56
+ );
57
+ }
58
+ return false;
59
+ };
60
+
61
+ /**
62
+ * True when the `.set()` call sits directly in an `effect()` callback (not in a nested function such as
63
+ * `untracked(() => …)`) and that callback does nothing but write signals, optionally behind an `if`.
48
64
  *
49
65
  * @param {import('eslint').SourceCode} sourceCode
50
66
  * @param {import('eslint').Rule.Node} node The `.set()` CallExpression node
@@ -75,20 +91,10 @@ const isPureSetInDirectEffectCallback = (sourceCode, node) => {
75
91
  return false;
76
92
  }
77
93
 
78
- // The .set() must be the sole expression — no surrounding statements.
79
- // Case A: arrow with expression body — the body IS the .set() call.
80
- if (immediateFunction.type === 'ArrowFunctionExpression' && immediateFunction.body === node) {
81
- return true;
82
- }
83
-
84
- // Case B: block body with exactly one ExpressionStatement wrapping the .set() call.
85
94
  const body = immediateFunction.body;
86
- return (
87
- body.type === 'BlockStatement' &&
88
- body.body.length === 1 &&
89
- body.body[0].type === 'ExpressionStatement' &&
90
- body.body[0].expression === node
91
- );
95
+ if (body.type !== 'BlockStatement') return body === node;
96
+
97
+ return node.parent.type === 'ExpressionStatement' && body.body.length === 1 && isSetOnlyStatement(body.body[0]);
92
98
  };
93
99
 
94
100
  /** @type {import('eslint').Rule.RuleModule} */
@@ -108,15 +114,7 @@ const preferLinkedSignal = {
108
114
  create(context) {
109
115
  return {
110
116
  CallExpression(node) {
111
- const { callee } = node;
112
-
113
- if (
114
- callee.type === 'MemberExpression' &&
115
- callee.property.type === 'Identifier' &&
116
- callee.property.name === 'set' &&
117
- node.arguments.length === 1 &&
118
- isPureSetInDirectEffectCallback(context.sourceCode, node)
119
- ) {
117
+ if (isSetCall(node) && isPureSetInDirectEffectCallback(context.sourceCode, node)) {
120
118
  context.report({ node, messageId: 'preferLinkedSignal' });
121
119
  }
122
120
  },