@gost-kit/eslint-plugin 0.0.0-stage → 0.1.0
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 +26 -0
- package/LICENSE +21 -0
- package/README.md +45 -2
- package/RULES.md +249 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/lib/common-source.d.ts +2 -0
- package/dist/lib/common-source.js +39 -0
- package/dist/lib/component-refs.d.ts +5 -0
- package/dist/lib/component-refs.js +58 -0
- package/dist/lib/dispatch-type.d.ts +2 -0
- package/dist/lib/dispatch-type.js +103 -0
- package/dist/lib/handler-body.d.ts +2 -0
- package/dist/lib/handler-body.js +32 -0
- package/dist/lib/member-key.d.ts +2 -0
- package/dist/lib/member-key.js +13 -0
- package/dist/lib/optional-call.d.ts +2 -0
- package/dist/lib/optional-call.js +55 -0
- package/dist/lib/raw-state.d.ts +2 -0
- package/dist/lib/raw-state.js +27 -0
- package/dist/lib/reactive-context.d.ts +6 -0
- package/dist/lib/reactive-context.js +48 -0
- package/dist/lib/rune-callee.d.ts +2 -0
- package/dist/lib/rune-callee.js +19 -0
- package/dist/lib/selector-fix.d.ts +2 -0
- package/dist/lib/selector-fix.js +122 -0
- package/dist/lib/store-state.d.ts +2 -0
- package/dist/lib/store-state.js +57 -0
- package/dist/lib/svelte-scripts.d.ts +5 -0
- package/dist/lib/svelte-scripts.js +25 -0
- package/dist/lib/ui-handlers.d.ts +10 -0
- package/dist/lib/ui-handlers.js +84 -0
- package/dist/lib/value-variable.d.ts +2 -0
- package/dist/lib/value-variable.js +25 -0
- package/dist/rules/no-component-state.d.ts +2 -0
- package/dist/rules/no-component-state.js +46 -0
- package/dist/rules/require-feature-state-selector.d.ts +2 -0
- package/dist/rules/require-feature-state-selector.js +58 -0
- package/dist/rules/single-intent-dispatch.d.ts +2 -0
- package/dist/rules/single-intent-dispatch.js +89 -0
- package/package.json +55 -4
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
2
|
+
const hasOptionalAccess = (node) => {
|
|
3
|
+
let current = node;
|
|
4
|
+
while (true) {
|
|
5
|
+
if (current.type === AST_NODE_TYPES.CallExpression ||
|
|
6
|
+
current.type === AST_NODE_TYPES.MemberExpression) {
|
|
7
|
+
if (current.optional) {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
current =
|
|
11
|
+
current.type === AST_NODE_TYPES.CallExpression
|
|
12
|
+
? current.callee
|
|
13
|
+
: current.object;
|
|
14
|
+
}
|
|
15
|
+
else if (current.type === AST_NODE_TYPES.TSAsExpression ||
|
|
16
|
+
current.type === AST_NODE_TYPES.TSSatisfiesExpression ||
|
|
17
|
+
current.type === AST_NODE_TYPES.TSNonNullExpression ||
|
|
18
|
+
current.type === AST_NODE_TYPES.TSTypeAssertion ||
|
|
19
|
+
current.type === AST_NODE_TYPES.TSInstantiationExpression) {
|
|
20
|
+
current = current.expression;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
// A nested ChainExpression ends an earlier chain (e.g. `(store?.dispatch)()`).
|
|
24
|
+
// Arguments are not part of this path; enclosing argument/key gates are checked below.
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
export const isOptionalCall = (call) => {
|
|
30
|
+
if (hasOptionalAccess(call)) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
let current = call;
|
|
34
|
+
while (current.parent) {
|
|
35
|
+
const parent = current.parent;
|
|
36
|
+
if (parent.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
37
|
+
parent.type === AST_NODE_TYPES.FunctionExpression ||
|
|
38
|
+
parent.type === AST_NODE_TYPES.FunctionDeclaration) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
// Optional calls/accesses skip their arguments and computed keys too.
|
|
42
|
+
if (((parent.type === AST_NODE_TYPES.CallExpression &&
|
|
43
|
+
parent.arguments.some((argument) => {
|
|
44
|
+
return argument === current;
|
|
45
|
+
})) ||
|
|
46
|
+
(parent.type === AST_NODE_TYPES.MemberExpression &&
|
|
47
|
+
parent.computed &&
|
|
48
|
+
parent.property === current)) &&
|
|
49
|
+
hasOptionalAccess(parent)) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
current = parent;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, } from '@typescript-eslint/utils';
|
|
2
|
+
import { runeCallee } from './rune-callee.js';
|
|
3
|
+
import { findValueVariable } from './value-variable.js';
|
|
4
|
+
// Inspect the original scope: Svelte's typed parser synthesizes rune declarations
|
|
5
|
+
// in a virtual TS file. Plain aliases of raw state capture a nonreactive snapshot.
|
|
6
|
+
export const createRawStateCheck = (source) => {
|
|
7
|
+
return (node) => {
|
|
8
|
+
if (node.type !== AST_NODE_TYPES.Identifier) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const variable = findValueVariable(source.getScope(node), node.name);
|
|
12
|
+
return (variable?.defs.some((definition) => {
|
|
13
|
+
if (definition.type !== 'Variable' ||
|
|
14
|
+
definition.node.id.type !== AST_NODE_TYPES.Identifier ||
|
|
15
|
+
definition.node.id.name !== node.name) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const init = definition.node.init;
|
|
19
|
+
if (init?.type !== AST_NODE_TYPES.CallExpression ||
|
|
20
|
+
init.callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
21
|
+
!runeCallee(init.callee, source, '$state', 'raw')) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
}) ?? false);
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
export declare const isDerived: (node: TSESTree.CallExpression, source: TSESLint.SourceCode) => boolean;
|
|
3
|
+
export declare const reactiveContext: (node: TSESTree.Node, source: TSESLint.SourceCode) => {
|
|
4
|
+
derived: TSESTree.CallExpression;
|
|
5
|
+
call: TSESTree.CallExpression | TSESTree.NewExpression | TSESTree.TaggedTemplateExpression;
|
|
6
|
+
} | undefined;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, } from '@typescript-eslint/utils';
|
|
2
|
+
import { runeCallee } from './rune-callee.js';
|
|
3
|
+
import { memberKey } from './member-key.js';
|
|
4
|
+
export const isDerived = (node, source) => {
|
|
5
|
+
return !!runeCallee(node.callee, source, '$derived', 'by');
|
|
6
|
+
};
|
|
7
|
+
export const reactiveContext = (node, source) => {
|
|
8
|
+
let current = node;
|
|
9
|
+
let call;
|
|
10
|
+
while (current.parent) {
|
|
11
|
+
const parent = current.parent;
|
|
12
|
+
if (parent.type === AST_NODE_TYPES.CallExpression) {
|
|
13
|
+
if (isDerived(parent, source)) {
|
|
14
|
+
return call ? { derived: parent, call } : undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (parent.type === AST_NODE_TYPES.CallExpression ||
|
|
18
|
+
parent.type === AST_NODE_TYPES.NewExpression ||
|
|
19
|
+
parent.type === AST_NODE_TYPES.TaggedTemplateExpression) {
|
|
20
|
+
call ??= parent;
|
|
21
|
+
}
|
|
22
|
+
// Instance fields execute only when an instance is created. Static fields
|
|
23
|
+
// and computed keys execute while the class expression itself is evaluated.
|
|
24
|
+
if ((parent.type === AST_NODE_TYPES.PropertyDefinition ||
|
|
25
|
+
parent.type === AST_NODE_TYPES.AccessorProperty) &&
|
|
26
|
+
!parent.static &&
|
|
27
|
+
parent.value === current) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
if (parent.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
31
|
+
parent.type === AST_NODE_TYPES.FunctionExpression ||
|
|
32
|
+
parent.type === AST_NODE_TYPES.FunctionDeclaration) {
|
|
33
|
+
const owner = parent.parent;
|
|
34
|
+
if (owner?.type === AST_NODE_TYPES.CallExpression &&
|
|
35
|
+
owner.callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
36
|
+
memberKey(owner.callee) === 'by' &&
|
|
37
|
+
owner.arguments[0] === parent &&
|
|
38
|
+
!parent.async &&
|
|
39
|
+
!parent.generator &&
|
|
40
|
+
isDerived(owner, source)) {
|
|
41
|
+
return call ? { derived: owner, call } : undefined;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
current = parent;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, } from '@typescript-eslint/utils';
|
|
2
|
+
import { memberKey } from './member-key.js';
|
|
3
|
+
import { findValueVariable } from './value-variable.js';
|
|
4
|
+
// Return the intrinsic identifier, preserving static member-key support.
|
|
5
|
+
export const runeCallee = (callee, source, name, key) => {
|
|
6
|
+
const identifier = callee.type === AST_NODE_TYPES.Identifier
|
|
7
|
+
? callee
|
|
8
|
+
: key &&
|
|
9
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
10
|
+
memberKey(callee) === key &&
|
|
11
|
+
callee.object.type === AST_NODE_TYPES.Identifier
|
|
12
|
+
? callee.object
|
|
13
|
+
: undefined;
|
|
14
|
+
if (identifier?.name !== name) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
const variable = findValueVariable(source.getScope(identifier), name);
|
|
18
|
+
return variable && variable.defs.length > 0 ? undefined : identifier;
|
|
19
|
+
};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';
|
|
2
|
+
export declare const selectorFix: (state: TSESTree.Expression, derived: TSESTree.CallExpression, call: TSESTree.CallExpression, source: TSESLint.SourceCode) => ((fixer: TSESLint.RuleFixer) => TSESLint.RuleFix[]) | undefined;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, } from '@typescript-eslint/utils';
|
|
2
|
+
import { memberKey } from './member-key.js';
|
|
3
|
+
const reserved = new Set([
|
|
4
|
+
'arguments',
|
|
5
|
+
'eval',
|
|
6
|
+
'await',
|
|
7
|
+
'break',
|
|
8
|
+
'case',
|
|
9
|
+
'catch',
|
|
10
|
+
'class',
|
|
11
|
+
'const',
|
|
12
|
+
'continue',
|
|
13
|
+
'debugger',
|
|
14
|
+
'default',
|
|
15
|
+
'delete',
|
|
16
|
+
'do',
|
|
17
|
+
'else',
|
|
18
|
+
'enum',
|
|
19
|
+
'export',
|
|
20
|
+
'extends',
|
|
21
|
+
'false',
|
|
22
|
+
'finally',
|
|
23
|
+
'for',
|
|
24
|
+
'function',
|
|
25
|
+
'if',
|
|
26
|
+
'implements',
|
|
27
|
+
'import',
|
|
28
|
+
'in',
|
|
29
|
+
'instanceof',
|
|
30
|
+
'interface',
|
|
31
|
+
'let',
|
|
32
|
+
'new',
|
|
33
|
+
'null',
|
|
34
|
+
'package',
|
|
35
|
+
'private',
|
|
36
|
+
'protected',
|
|
37
|
+
'public',
|
|
38
|
+
'return',
|
|
39
|
+
'static',
|
|
40
|
+
'super',
|
|
41
|
+
'switch',
|
|
42
|
+
'this',
|
|
43
|
+
'throw',
|
|
44
|
+
'true',
|
|
45
|
+
'try',
|
|
46
|
+
'typeof',
|
|
47
|
+
'var',
|
|
48
|
+
'void',
|
|
49
|
+
'while',
|
|
50
|
+
'with',
|
|
51
|
+
'yield',
|
|
52
|
+
]);
|
|
53
|
+
// A deliberately small syntax boundary: a standalone derived with one direct call.
|
|
54
|
+
export const selectorFix = (state, derived, call, source) => {
|
|
55
|
+
if (derived.callee.type !== AST_NODE_TYPES.Identifier ||
|
|
56
|
+
derived.arguments[0] !== call ||
|
|
57
|
+
call.optional ||
|
|
58
|
+
call.callee.type !== AST_NODE_TYPES.Identifier ||
|
|
59
|
+
call.arguments.length !== 1) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const selected = call.arguments[0];
|
|
63
|
+
const declarator = derived.parent;
|
|
64
|
+
const declaration = declarator?.parent;
|
|
65
|
+
if (selected.type !== AST_NODE_TYPES.MemberExpression ||
|
|
66
|
+
declarator?.type !== AST_NODE_TYPES.VariableDeclarator ||
|
|
67
|
+
declarator.init !== derived ||
|
|
68
|
+
declaration?.type !== AST_NODE_TYPES.VariableDeclaration ||
|
|
69
|
+
declaration.declarations.length !== 1 ||
|
|
70
|
+
declaration.parent.type === AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
71
|
+
declaration.parent.type === AST_NODE_TYPES.ForStatement ||
|
|
72
|
+
declaration.parent.type === AST_NODE_TYPES.ForOfStatement ||
|
|
73
|
+
declaration.parent.type === AST_NODE_TYPES.ForInStatement) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
// Accept one immediate feature property only; deeper user-defined getters
|
|
77
|
+
// and accesses through containers have no static purity guarantee.
|
|
78
|
+
if (selected.object !== state ||
|
|
79
|
+
(state.type !== AST_NODE_TYPES.Identifier &&
|
|
80
|
+
(state.type !== AST_NODE_TYPES.MemberExpression ||
|
|
81
|
+
state.object.type !== AST_NODE_TYPES.Identifier ||
|
|
82
|
+
state.optional)) ||
|
|
83
|
+
selected.optional) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const base = memberKey(selected);
|
|
87
|
+
if (!base || base.startsWith('$') || !/^[A-Z_a-z][$\w]*$/u.test(base)) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const occupied = new Set();
|
|
91
|
+
let scope = source.getScope(declaration);
|
|
92
|
+
while (scope) {
|
|
93
|
+
scope.variables.forEach((variable) => {
|
|
94
|
+
occupied.add(variable.name);
|
|
95
|
+
});
|
|
96
|
+
scope.through.forEach((reference) => {
|
|
97
|
+
occupied.add(reference.identifier.name);
|
|
98
|
+
});
|
|
99
|
+
scope = scope.upper;
|
|
100
|
+
}
|
|
101
|
+
let name = base;
|
|
102
|
+
let version = 2;
|
|
103
|
+
while (occupied.has(name) || reserved.has(name)) {
|
|
104
|
+
name = `${base}V${version}`;
|
|
105
|
+
version += 1;
|
|
106
|
+
}
|
|
107
|
+
// Svelte adds script elements to ESTree; its parser supplies this parent.
|
|
108
|
+
const parent = declaration.parent;
|
|
109
|
+
if (parent.type !== AST_NODE_TYPES.Program &&
|
|
110
|
+
parent.type !== 'SvelteScriptElement') {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const indentation = source.lines[declaration.loc.start.line - 1].slice(0, declaration.loc.start.column);
|
|
114
|
+
const leading = /^\s*$/u.test(indentation) ? indentation : '';
|
|
115
|
+
const lineEnd = source.text.includes('\r\n') ? '\r\n' : '\n';
|
|
116
|
+
return (fixer) => {
|
|
117
|
+
return [
|
|
118
|
+
fixer.insertTextBefore(declaration, `const ${name} = $derived(${source.getText(selected)});${lineEnd}${leading}`),
|
|
119
|
+
fixer.replaceText(selected, name),
|
|
120
|
+
];
|
|
121
|
+
};
|
|
122
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { createCommonSourceCheck } from './common-source.js';
|
|
3
|
+
export const createStoreStateCheck = (services) => {
|
|
4
|
+
const program = services.program;
|
|
5
|
+
const checker = program.getTypeChecker();
|
|
6
|
+
const isCommonSource = createCommonSourceCheck();
|
|
7
|
+
const declarations = new Map();
|
|
8
|
+
const isStoreDeclaration = (declaration) => {
|
|
9
|
+
const cached = declarations.get(declaration);
|
|
10
|
+
if (cached !== undefined) {
|
|
11
|
+
return cached;
|
|
12
|
+
}
|
|
13
|
+
const source = declaration.getSourceFile();
|
|
14
|
+
let matches = false;
|
|
15
|
+
if (isCommonSource(source)) {
|
|
16
|
+
const module = checker.getSymbolAtLocation(source);
|
|
17
|
+
const exported = module &&
|
|
18
|
+
checker.getExportsOfModule(module).find((symbol) => {
|
|
19
|
+
return symbol.name === 'Store';
|
|
20
|
+
});
|
|
21
|
+
const store = exported &&
|
|
22
|
+
(exported.flags & ts.SymbolFlags.Alias
|
|
23
|
+
? checker.getAliasedSymbol(exported)
|
|
24
|
+
: exported);
|
|
25
|
+
matches = store
|
|
26
|
+
? (checker
|
|
27
|
+
.getDeclaredTypeOfSymbol(store)
|
|
28
|
+
.getProperty('state')
|
|
29
|
+
?.getDeclarations()
|
|
30
|
+
?.includes(declaration) ?? false)
|
|
31
|
+
: false;
|
|
32
|
+
}
|
|
33
|
+
declarations.set(declaration, matches);
|
|
34
|
+
return matches;
|
|
35
|
+
};
|
|
36
|
+
const isStore = (type) => {
|
|
37
|
+
if (type.flags &
|
|
38
|
+
(ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (type.isUnion()) {
|
|
42
|
+
const members = type.types.filter((member) => {
|
|
43
|
+
return !(member.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined));
|
|
44
|
+
});
|
|
45
|
+
return members.length > 0 && members.every(isStore);
|
|
46
|
+
}
|
|
47
|
+
return (type
|
|
48
|
+
.getProperty('state')
|
|
49
|
+
?.getDeclarations()
|
|
50
|
+
?.some((declaration) => {
|
|
51
|
+
return isStoreDeclaration(declaration);
|
|
52
|
+
}) ?? false);
|
|
53
|
+
};
|
|
54
|
+
return (node) => {
|
|
55
|
+
return isStore(services.getTypeAtLocation(node));
|
|
56
|
+
};
|
|
57
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AST } from 'svelte-eslint-parser';
|
|
2
|
+
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
|
3
|
+
export declare const svelteScripts: (source: TSESLint.SourceCode) => AST.SvelteScriptElement[] | undefined;
|
|
4
|
+
export declare const scriptHasAttribute: (script: AST.SvelteScriptElement, name: string, value?: string) => boolean;
|
|
5
|
+
export declare const inScript: (node: TSESTree.Node, scripts: readonly AST.SvelteScriptElement[]) => boolean;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const svelteScripts = (source) => {
|
|
2
|
+
const services = source.parserServices;
|
|
3
|
+
if (!services || !('isSvelte' in services) || services.isSvelte !== true) {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
// Only the Svelte parser supplies the extended ESTree script elements.
|
|
7
|
+
return source.ast.body.filter((node) => {
|
|
8
|
+
return node.type === 'SvelteScriptElement';
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
export const scriptHasAttribute = (script, name, value) => {
|
|
12
|
+
return script.startTag.attributes.some((attribute) => {
|
|
13
|
+
return (attribute.type === 'SvelteAttribute' &&
|
|
14
|
+
attribute.key.name === name &&
|
|
15
|
+
(value === undefined ||
|
|
16
|
+
attribute.value.some((item) => {
|
|
17
|
+
return item.type === 'SvelteLiteral' && item.value === value;
|
|
18
|
+
})));
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
export const inScript = (node, scripts) => {
|
|
22
|
+
return scripts.some((script) => {
|
|
23
|
+
return node.range[0] >= script.range[0] && node.range[1] <= script.range[1];
|
|
24
|
+
});
|
|
25
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AST } from 'svelte-eslint-parser';
|
|
2
|
+
import { type TSESLint, type TSESTree } from '@typescript-eslint/utils';
|
|
3
|
+
type Handler = TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression | TSESTree.FunctionDeclaration;
|
|
4
|
+
export declare const createUiHandlerCollector: (source: TSESLint.SourceCode, scripts: readonly AST.SvelteScriptElement[]) => {
|
|
5
|
+
handlers: Set<Handler>;
|
|
6
|
+
attribute(node: TSESTree.Node): void;
|
|
7
|
+
shorthand(node: TSESTree.Node): void;
|
|
8
|
+
directive(node: TSESTree.Node): void;
|
|
9
|
+
};
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, } from '@typescript-eslint/utils';
|
|
2
|
+
import { findValueVariable } from './value-variable.js';
|
|
3
|
+
import { inScript } from './svelte-scripts.js';
|
|
4
|
+
export const createUiHandlerCollector = (source, scripts) => {
|
|
5
|
+
const handlers = new Set();
|
|
6
|
+
const resolve = (node, inline, seen) => {
|
|
7
|
+
if (node.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
8
|
+
node.type === AST_NODE_TYPES.FunctionExpression ||
|
|
9
|
+
node.type === AST_NODE_TYPES.FunctionDeclaration) {
|
|
10
|
+
return inline || inScript(node, scripts) ? node : undefined;
|
|
11
|
+
}
|
|
12
|
+
if (node.type === AST_NODE_TYPES.TSSatisfiesExpression ||
|
|
13
|
+
node.type === AST_NODE_TYPES.TSAsExpression ||
|
|
14
|
+
node.type === AST_NODE_TYPES.TSTypeAssertion ||
|
|
15
|
+
node.type === AST_NODE_TYPES.TSNonNullExpression ||
|
|
16
|
+
node.type === AST_NODE_TYPES.ChainExpression) {
|
|
17
|
+
return resolve(node.expression, inline, seen);
|
|
18
|
+
}
|
|
19
|
+
if (node.type !== AST_NODE_TYPES.Identifier) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const variable = findValueVariable(source.getScope(node), node.name);
|
|
23
|
+
if (!variable ||
|
|
24
|
+
seen.has(variable) ||
|
|
25
|
+
variable.references.some((reference) => {
|
|
26
|
+
return reference.isWrite() && !reference.init;
|
|
27
|
+
})) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const next = new Set(seen).add(variable);
|
|
31
|
+
return variable.defs
|
|
32
|
+
.map((definition) => {
|
|
33
|
+
if (definition.type === 'FunctionName' &&
|
|
34
|
+
definition.node.type === AST_NODE_TYPES.FunctionDeclaration) {
|
|
35
|
+
return resolve(definition.node, false, next);
|
|
36
|
+
}
|
|
37
|
+
if (definition.type === 'Variable' &&
|
|
38
|
+
definition.parent.kind === 'const' &&
|
|
39
|
+
definition.node.id.type === AST_NODE_TYPES.Identifier &&
|
|
40
|
+
definition.node.init) {
|
|
41
|
+
return resolve(definition.node.init, false, next);
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
})
|
|
45
|
+
.find((handler) => {
|
|
46
|
+
return !!handler;
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
const add = (expression) => {
|
|
50
|
+
const handler = resolve(expression, true, new Set());
|
|
51
|
+
if (handler) {
|
|
52
|
+
handlers.add(handler);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
handlers,
|
|
57
|
+
attribute(node) {
|
|
58
|
+
// Parser-specific listeners are the only boundary for extended Svelte nodes.
|
|
59
|
+
const attribute = node;
|
|
60
|
+
if (!attribute.key.name.startsWith('on') ||
|
|
61
|
+
attribute.key.name.length <= 2) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
attribute.value.forEach((value) => {
|
|
65
|
+
if (value.type === 'SvelteMustacheTag') {
|
|
66
|
+
add(value.expression);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
shorthand(node) {
|
|
71
|
+
const attribute = node;
|
|
72
|
+
if (attribute.key.name.startsWith('on') &&
|
|
73
|
+
attribute.key.name.length > 2) {
|
|
74
|
+
add(attribute.value);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
directive(node) {
|
|
78
|
+
const directive = node;
|
|
79
|
+
if (directive.kind === 'EventHandler' && directive.expression) {
|
|
80
|
+
add(directive.expression);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
|
2
|
+
// A nearer type-only declaration does not shadow a runtime value in an outer scope.
|
|
3
|
+
export const findValueVariable = (initialScope, name) => {
|
|
4
|
+
for (let scope = initialScope; scope; scope = scope.upper) {
|
|
5
|
+
const variable = scope.set.get(name);
|
|
6
|
+
// Plain JavaScript parsers do not supply the TypeScript namespace flag.
|
|
7
|
+
if (variable &&
|
|
8
|
+
variable.isValueVariable !== false &&
|
|
9
|
+
(variable.defs.length === 0 ||
|
|
10
|
+
variable.defs.some((definition) => {
|
|
11
|
+
if (definition.isVariableDefinition === false) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (definition.type !== 'ImportBinding') {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
return (definition.parent.importKind !== 'type' &&
|
|
18
|
+
!(definition.node.type === AST_NODE_TYPES.ImportSpecifier &&
|
|
19
|
+
definition.node.importKind === 'type'));
|
|
20
|
+
}))) {
|
|
21
|
+
return variable;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, ESLintUtils, } from '@typescript-eslint/utils';
|
|
2
|
+
import { runeCallee } from '../lib/rune-callee.js';
|
|
3
|
+
import { createComponentRefCheck } from '../lib/component-refs.js';
|
|
4
|
+
export const noComponentState = ESLintUtils.RuleCreator.withoutDocs({
|
|
5
|
+
meta: {
|
|
6
|
+
type: 'suggestion',
|
|
7
|
+
docs: {
|
|
8
|
+
description: 'Disallow component-owned logical state in explicitly selected files.',
|
|
9
|
+
},
|
|
10
|
+
schema: [],
|
|
11
|
+
messages: {
|
|
12
|
+
componentState: '{{rune}} creates component-owned state. Move logical state to root/feature state and use intent actions, or exclude this independent UI component from the rule scope.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
defaultOptions: [],
|
|
16
|
+
create(context) {
|
|
17
|
+
const source = context.sourceCode;
|
|
18
|
+
const refs = createComponentRefCheck(source);
|
|
19
|
+
const calls = [];
|
|
20
|
+
return {
|
|
21
|
+
SvelteDirective: refs.recordBinding,
|
|
22
|
+
CallExpression(node) {
|
|
23
|
+
const callee = node.callee;
|
|
24
|
+
const identifier = runeCallee(callee, source, '$state', 'raw') ??
|
|
25
|
+
runeCallee(callee, source, '$bindable');
|
|
26
|
+
if (!identifier) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
calls.push({
|
|
30
|
+
node,
|
|
31
|
+
rune: callee.type === AST_NODE_TYPES.MemberExpression
|
|
32
|
+
? '$state.raw'
|
|
33
|
+
: identifier.name,
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
'Program:exit'() {
|
|
37
|
+
calls.forEach(({ node, rune }) => {
|
|
38
|
+
if (rune !== '$bindable' && refs.allows(node)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
context.report({ node, messageId: 'componentState', data: { rune } });
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { svelteScripts, scriptHasAttribute, inScript, } from '../lib/svelte-scripts.js';
|
|
2
|
+
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils';
|
|
3
|
+
import { memberKey } from '../lib/member-key.js';
|
|
4
|
+
import { createStoreStateCheck } from '../lib/store-state.js';
|
|
5
|
+
import { reactiveContext } from '../lib/reactive-context.js';
|
|
6
|
+
import { createRawStateCheck } from '../lib/raw-state.js';
|
|
7
|
+
import { selectorFix } from '../lib/selector-fix.js';
|
|
8
|
+
export const requireFeatureStateSelector = ESLintUtils.RuleCreator.withoutDocs({
|
|
9
|
+
meta: {
|
|
10
|
+
type: 'suggestion',
|
|
11
|
+
docs: { description: 'Select Store state before reactive calls.' },
|
|
12
|
+
schema: [],
|
|
13
|
+
fixable: 'code',
|
|
14
|
+
messages: {
|
|
15
|
+
selectFeature: 'Select the feature with a separate $derived before computing its presentation: const user = $derived(store.state.user); const view = $derived(presenter(user)); This skips downstream updates when the feature reference is unchanged.',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
defaultOptions: [],
|
|
19
|
+
create(context) {
|
|
20
|
+
const typedScripts = svelteScripts(context.sourceCode)?.filter((script) => {
|
|
21
|
+
return scriptHasAttribute(script, 'lang', 'ts');
|
|
22
|
+
});
|
|
23
|
+
if (typedScripts?.length === 0) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
const services = ESLintUtils.getParserServices(context);
|
|
27
|
+
const isStore = createStoreStateCheck(services);
|
|
28
|
+
const isRawState = createRawStateCheck(context.sourceCode);
|
|
29
|
+
return {
|
|
30
|
+
MemberExpression(node) {
|
|
31
|
+
if (typedScripts && !inScript(node, typedScripts)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const storeState = memberKey(node) === 'state' && isStore(node.object);
|
|
35
|
+
if (!storeState && !isRawState(node.object)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const reactive = reactiveContext(node, context.sourceCode);
|
|
39
|
+
if (!reactive) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const parent = node.parent;
|
|
43
|
+
const location = storeState &&
|
|
44
|
+
parent.type === AST_NODE_TYPES.MemberExpression &&
|
|
45
|
+
parent.object === node
|
|
46
|
+
? parent
|
|
47
|
+
: node;
|
|
48
|
+
context.report({
|
|
49
|
+
node: location,
|
|
50
|
+
messageId: 'selectFeature',
|
|
51
|
+
fix: reactive.call.type === AST_NODE_TYPES.CallExpression
|
|
52
|
+
? selectorFix(storeState ? node : node.object, reactive.derived, reactive.call, context.sourceCode)
|
|
53
|
+
: undefined,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
});
|