@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 +14 -0
- package/migrations.json +12 -0
- package/package.json +12 -2
- package/src/configs/recommended.js +30 -11
- 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/consistent-type-definitions.js +60 -0
- package/src/rules/no-csp-unsafe.js +362 -0
- package/src/rules/prefer-linked-signal.js +37 -39
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
|
package/migrations.json
ADDED
|
@@ -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.
|
|
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':
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*
|
|
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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
},
|